Skip to main content

Configuration reference

The backend is configured through environment variables, loaded by backend/app/core/config.py (Pydantic settings). Variable names are the field names in upper case. The frontend reads a small set of NEXT_PUBLIC_* build-time variables. This page lists them all.

Start from the example

Copy backend/.env.example to backend/.env and fill it in. Never commit .env.

Where a value comes from​

Settings are read from four layers, highest priority first (settings_customise_sources in config.py):

  1. Arguments passed to Settings() (tests only).
  2. A real environment variable.
  3. This checkout's backend/.env.
  4. ~/.contentsvision/local.env, machine-wide and local development only.
  5. The default declared on the field.

Blank values do not shadow. Both .env layers ignore any key whose value is empty, so the MAILGUN_API_KEY= that .env.example seeds into every new checkout falls through to layer 4 rather than blanking it. Every consumer of these settings already treats "" and unset identically, so nothing is lost; to force a value off regardless of the files, export a real environment variable, which still wins.

The machine-wide file​

~/.contentsvision/local.env exists for one problem: several people and agents work in this repo at the same time, each in their own git worktree with its own untracked backend/.env that start.sh seeds from .env.example with every secret blank. A credential that is the same for the whole machine (the Mailgun sending key used to test the insured invitation email locally, say) would otherwise have to be pasted into every new worktree before it could be used there.

  • It sits outside the repo, so it can never be committed. Keep it chmod 600.
  • The test suite ignores it entirely. conftest.py sets CONTENTSVISION_IGNORE_MACHINE_ENV=1, which drops this layer. The file holds real, working credentials, and a suite that read them would call third parties; that is not hypothetical (see offline by design).
  • It is entirely optional. On CI, staging, and production the file does not exist and this is a no-op, so nothing about deployment changes.
  • A checkout can still override it: a real value in that checkout's backend/.env wins.
  • Put only things that are genuinely the same across every checkout in it. Anything per-instance (PUBLIC_APP_BASE_URL, which has to match the port that instance's frontend is served on) belongs in backend/.env.
mkdir -p ~/.contentsvision && chmod 700 ~/.contentsvision
cat > ~/.contentsvision/local.env <<'EOF'
MAILGUN_API_KEY=<a domain-scoped sending key>
MAILGUN_DOMAIN=mg.adjustsquare.com
EOF
chmod 600 ~/.contentsvision/local.env

AI services​

VariableDescriptionDefaultRequired
OPENROUTER_API_KEYVision and analysis via OpenRouter (Claude). The pipeline's main AI.emptyYes
DEEPGRAM_API_KEYTranscription via Deepgram Nova-2. Without it, inventories are visual-only.emptyYes (for narration/audio)
ANTHROPIC_API_KEYAnthropic key. Present for compatibility; vision goes through OpenRouter.emptySituational
OPENROUTER_BASE_URLOpenRouter API base.https://openrouter.ai/api/v1No
OPENROUTER_MODELThe vision/analysis model id, also reported by /health. Opus 4.7 replaced Opus 4.5 (same price) for sharper image recognition.anthropic/claude-opus-4-7No
OPENROUTER_TEMPERATURESampling temperature, sent only on models that accept one (Opus 4.5 and earlier). Pinned to 0 so repeated uploads of the same video/photos return the same items and names (AS-719). Opus 4.7 and later reject the field, so it is left out for them regardless of this value.0No
OPENROUTER_CONCURRENCYMax simultaneous AI calls per job for video and audio jobs.6No
OPENROUTER_PHOTO_CONCURRENCYMax simultaneous AI calls per job for photo jobs (grouped photos and "read the rest"). Photo calls are small (one to five images), so more can run at once. Watch 429 retries on the admin API Requests page after raising it.12No
OPENROUTER_GLOBAL_CONCURRENCYMax simultaneous AI calls across all in-flight jobs in one process (a shared limiter on top of the per-job caps, so concurrent jobs don't collectively trip the provider rate limit). Keep at or below the httpx max_connections (32).24No
CLAUDE_MODELUnused. Still accepted so an existing .env carrying it does not fail startup; /health reports OPENROUTER_MODEL. Remove the line from your .env.emptyNo
DEEPGRAM_MODELTranscription model.nova-2No

AdjustSquare SSO (login)​

VariableDescriptionDefaultRequired
ADJUSTSQUARE_JWT_SECRETShared HS256 secret used to validate login tokens. If unset, all logins fail.emptyYes
ADJUSTSQUARE_LOGIN_URLWhere unauthenticated users are redirected to sign in.https://demo.adjustsquare.com/loginYes
ADJUSTSQUARE_COOKIE_NAMECookie name AdjustSquare sets on shared subdomains.bt_shared_dataNo
ADMIN_EXTERNAL_USER_IDSAllowlist of Adjust Square user IDs (matched against users.external_id) that may see the internal admin dashboard. JSON list, e.g. [7, 10].{7, 10} (Dan, Nathan)No

Adjust Square API (claim submission)​

VariableDescriptionDefaultRequired
ADJUST_SQUARE_BASE_URLBase URL of the Adjust Square API. One host per deployment.emptyYes (to submit)
ADJUST_SQUARE_BEARER_TOKENOptional global Adjust Square API token, used only as a fallback for a call made without a team token. Submission authenticates as the signed-in user's team.team_token (from the SSO JWT), so this is not required and is not the configured-gate.emptyNo (optional fallback)
ADJUST_SQUARE_APP_URLThe Adjust Square web app origin (distinct from the API base above), used only to deep-link a submitted claim into its assignment: {app_url}/lkq/{claim_key}. Empty renders the claim key as plain text (no link).emptyNo
Per-user auth

Claim submission authenticates as the signed-in user's team (team.team_token, captured from the SSO JWT's current_team_token) and attributes the claim to the user's user.external_id (see the Adjust Square integration), both read from the database at submit time. Only ADJUST_SQUARE_BASE_URL must be set for the integration to count as "configured"; ADJUST_SQUARE_BEARER_TOKEN is an optional fallback for calls made without a team token and is not required. There is no longer a shared adjuster-id environment variable (the old ADJUST_SQUARE_ADJUSTER_ID was removed; the adjuster is the signed-in user).

Contents Estimation (claim push)​

Contents Estimation is the clean-stack rebuild of Adjust Square. A finished claim can be pushed to it in one call, which creates the claim there and starts pricing. This is a separate action from the Adjust Square submission above; both can be used independently on the same claim.

VariableDescriptionDefaultRequired
CONTENTS_ESTIMATION_BASE_URLBase URL of the Contents Estimation API. One host per deployment. Prod: https://api.contentsestimation.com. Stage: https://api-staging.contentsestimation.com. Local: http://localhost:8000. Not their web app (see the warning below).emptyYes (to send)
CONTENTS_ESTIMATION_APP_URLThe Contents Estimation web app origin (distinct from the API base above). Deep-links a pushed claim ({app_url}/claims/{claim_uuid}) and is where the Integrations page's ContentsEstimation card opens, already signed in ({app_url}/auth/contentsvision?token=...).https://contentsestimation.comNo
CONTENTS_ESTIMATION_SSO_SECRETSigning secret for the sign-in hand-off INTO Contents Estimation. Leave empty and ADJUSTSQUARE_JWT_SECRET is used, which is the intended setup: that same secret already signs the tokens CE mints to log people in here, so the hand-off needs no new secret. Set it only if the two directions are split onto separate keys. Both empty => the hand-off is off and the endpoint returns 503.emptyNo
CONTENTS_ESTIMATION_SSO_TTL_MINUTESHow long a minted hand-off token stays valid. Short by design: it rides in the opened tab's URL and is spent on arrival.3No
PUBLIC_BASE_URLContentsVision's own externally reachable origin (https://contentsvision.com), used to turn item photo paths (/api/photos/...) into absolute URLs, since the intake takes photos as URLs, not bytes. Empty pushes every claim with no photos at all, and still reports success, so treat it as required in any deployment that sends. Check it on GET /api/auth/debug (contents_estimation.public_base_url). Also prefixes the photo links written into an Excel export, though that falls back to the origin the export was requested from when this is blank, so exports do not depend on it.emptyNo, but photos need it
The API host and the web app host are different values, and swapping them fails silently

CONTENTS_ESTIMATION_BASE_URL is the API. CONTENTS_ESTIMATION_APP_URL is the web app a user clicks through to. They are different hostnames, and putting the web app in the API slot is a mistake nothing catches:

EnvironmentAPI (..._BASE_URL)Web app (..._APP_URL)
Productionhttps://api.contentsestimation.comhttps://contentsestimation.com
Stagehttps://api-staging.contentsestimation.comhttps://stage.contentsestimation.com

This happened on stage (2026-09-03): CONTENTS_ESTIMATION_BASE_URL was set to https://stage.contentsestimation.com, which is their web app. It answers every intake path with a 404 HTML page, so every call failed. What the user saw was not an error. The modal reported no depreciation schedules and greyed out "An existing claim", because both of those routes are deliberately soft-failing: the catalog returns an empty list rather than 502 the dropdowns, and the picker reports available: false rather than block a send. Two soft failures together look exactly like a firm that has not been set up.

GET /api/auth/debug will read ready_to_submit: true throughout, because it checks that a base URL is SET, not that it is the right one. The quick test, on any host, is a single unauthenticated request:

curl -s -o /dev/null -w '%{http_code}\n' https://<host>/intake/catalog # 401 = the API. 404 = wrong host.

A 401 means you found the API and it wants a key. A 404, especially with an HTML body, means you are pointed at a web app. The same probe also tells you whether a deployment carries a given endpoint: on 2026-09-03 their production API answered /intake/catalog with 401 and /intake/claims with 404, which is exactly what "the AS-1540 endpoints are on their stage but not yet in production" looks like from outside.

Per-team auth

CONTENTS_ESTIMATION_BASE_URL is the only instance-level setting, and it alone decides whether the integration counts as "configured" (settings.contents_estimation_configured), exactly like ADJUST_SQUARE_BASE_URL. On its own it is not enough to send.

The credential is per-team and per-team only: Team.contents_estimation_key, seeded by backend/scripts/seed_team_ce_tokens.py (or captured from the AdjustSquare SSO JWT's current_team_ce_intake_key claim when that is non-empty), resolved from the signed-in user's team at submit. There is deliberately no instance-wide fallback key. On the Contents Estimation side the token is what identifies the firm, so a shared key would file every team's claims under whoever owns it. A team without its own key gets a clear 409 and sends nothing, and a 401 from their API means that firm is not provisioned there. See the Contents Estimation integration.

CONTENTS_ESTIMATION_API_KEY is gone

This variable was an instance-wide fallback key and has been removed from the code, .env.example and this table. If it is still set in a deployment's .env it now does nothing, and it should be deleted so nobody mistakes it for live configuration.

:::

Contents Capture (media import)​

VariableDescriptionDefaultRequired
CONTENTS_CAPTURE_API_BASE_URLBase URL of the Contents Capture API. Empty disables the integration entirely (the Integrations page reports it unavailable). Dev/stage: https://contents-capture-api.dev-908.workers.dev.emptyYes (to import)
CONTENTS_CAPTURE_WEBHOOK_SECRETHMAC secret verifying Contents Capture webhook deliveries at POST /api/integrations/contents-capture/webhook (submission auto-import). The same value is registered as the endpoint secret on the Capture side. Empty disables the endpoint (it answers 503); manual import works regardless.emptyNo
CONTENTS_CAPTURE_IMPORT_CONCURRENCYParallel media downloads per import run.4No
Per-team tokens

The org integration token (cci_..., minted by a Capture org admin) is pasted by a team under Settings, then Integrations, and stored on that team's storage_connections row. It is not an environment variable. See the Contents Capture integration.

Storage and database​

VariableDescriptionDefaultRequired
DATABASE_URLSQLAlchemy async DB URL.sqlite+aiosqlite:///./storage/inventory.dbNo
UPLOAD_DIRWhere raw uploads are streamed../uploadsNo
STORAGE_DIRPer-job files (frames, photos, crops), the SQLite DB, and the settings JSON files../storageNo
MAX_UPLOAD_SIZE_MBPer-file upload size cap for video/audio (returns 413 if exceeded).2000No
MAX_PHOTO_UPLOAD_MBRunning-total cap for one photo job across all its batched requests. Far higher than a single video because one room can hold hundreds of photos (a 757-photo phone upload is ~3GB).8000No
STUCK_JOB_MINUTESMinutes a processing job's row may go without an update before the recovery watchdog requeues (or fails) it. Frame extraction heartbeats every FRAME_EXTRACTION_HEARTBEAT_SECONDS, so a long ffmpeg run never trips this. See troubleshooting.15No

Pipeline worker (production)​

VariableDescriptionDefaultRequired
REDIS_URLThe switch between the two ways of running a pipeline. Set (the prod compose sets redis://redis:6379 on the backend and the worker): every "start processing" enqueues the job for the separate worker container, and live job logs are mirrored through Redis. Unset (local development, the test suite): pipelines run as in-process background tasks. Read from the environment directly, not through config.py.unsetProduction
WORKER_CONCURRENCYPipelines one worker container runs at once (ARQ max_jobs).4No
JOB_TIMEOUT_SSeconds one run of one job may take before ARQ kills it (pipelines are then re-run once).3600No
WEB_CONCURRENCYuvicorn worker processes for the backend (uvicorn reads this itself). Only makes sense with REDIS_URL set; without it, a job's live logs would only be visible to the one process that ran it.2 (prod compose) / 1No

Pipeline tuning​

VariableDescriptionDefault
FRAME_EXTRACTION_FPSFrames extracted from video per second.1.0
FRAME_LONG_EDGE_PXFrames are shrunk inside ffmpeg so the long edge is at most this many pixels (never upscaled). 0 keeps full resolution.1600
FRAME_JPEG_QUALITYffmpeg MJPEG quality for frame files (2 best, 31 worst).4
KEYFRAME_DECODE_MIN_RATIODecode only keyframes when the video's keyframe rate is at least this fraction of FRAME_EXTRACTION_FPS (phones write about one a second). 0 disables the shortcut.0.8
FRAME_EXTRACTION_HEARTBEAT_SECONDSHow often the pipeline logs and commits a "still extracting" heartbeat while ffmpeg runs.10
CHUNK_DURATION_SECONDSLength of each analyzed time chunk.30.0
FRAMES_PER_MINUTETarget frame density per chunk.60
MAX_FRAMES_PER_CALLHard cap on frames per vision call.60
VIDEO_DEDUP_WINDOW_SECONDSBoundary window for cross-chunk dedup.3.0
MAX_PHOTOS_PER_CALLPhotos per call in the legacy batched photo pipeline.8
MAX_PHOTOS_PER_ITEMHard cap on photos in one group, whoever made it: a person merging in the grouping screen, a PDF label the importer split into balanced blocks, or a homeowner group from an inventory link split the same way at submit. Dropped from 10 to 5 when photos started going to the AI at full size. Keep in sync with the same constant in GroupPhotosModal.tsx.5
PHOTO_IMAGE_MAX_PIXELSPhotos are shrunk to at most this many pixels before the vision call (Anthropic's 1.15 megapixel ceiling, ~1,530 tokens for a 4:3 photo). Was a 768px long edge.1150000
PHOTO_IMAGE_MAX_LONG_EDGEPhotos are also never wider than this on the long side.1568
VIDEO_FRAME_LONG_EDGEVideo frames stay at this long edge; a chunk sends up to 60 of them.768
MAX_FRAMES_PER_ROOMLegacy, unused after the time-chunk pipeline.8
PHOTO_BUDGET_CREDIT_RATE_USDThe per-credit rate a photo request's output budget is priced at. Deliberately the LOWEST rate any customer is on, so the budget is safe for every account.0.10
PHOTO_BUDGET_TARGET_MARGINShare of that credit a single pass may spend.0.8
PHOTO_BUDGET_MIN_OUTPUT_TOKENSFloor on a photo request's output budget, so every request can name a few items.800
PHOTO_BUDGET_MAX_OUTPUT_TOKENSCeiling on a photo request's output budget.3500
PHOTO_BUDGET_TRUNCATION_RATIOShare of its budget an answer must use to count as cut short when the provider does not say finish_reason=length.0.95
PHOTO_MAX_PASSESPasses allowed over one photo group: the first call plus two more.3

These defaults are tuned together; see the AI pipeline before changing them.

Billing rates (credits)​

VariableDescriptionDefault
BILLING_BLOCK_SECONDSLength of one video/audio billing block, in seconds. Time is rounded up to whole blocks.20
COST_PER_VIDEO_BLOCKCredits per block of video, so 9 credits a minute at the defaults.3
COST_PER_AUDIO_BLOCKCredits per block of audio, so 3 credits a minute at the defaults.1
COST_PER_PHOTO_REQUESTCredits per photo AI request (one per group).1
COST_PER_CONTENTS_ESTIMATION_ITEMCredits charged when a claim is sent to Contents Estimation, per active item (charged once per claim, at send). Only applies with ENABLE_BILLING on.1
DEFAULT_TEAM_CREDIT_RATEFallback dollars of revenue per credit, used for any team with no per-team Team.credit_rate. Feeds the Revenue field of the Slack notification.0.15

Prepaid billing (external service)​

Optional, instance-wide. Off by default; invoiced / enterprise instances leave it off and the app only meters usage. When on, uploads are gated on a team's prepaid balance held by an external billing service, and completed jobs are charged against it. See billing.

VariableDescriptionDefault
ENABLE_BILLINGMaster switch for the prepaid balance gate + charge.false
BILLING_APP_DOMAINBase URL of the external billing service (no trailing slash).(empty)
BILLING_APP_API_KEYBearer key for the billing service.(empty)
BILLING_EXTERNAL_APPIdentifier sent to the billing service as external_app.contentsvision

When ENABLE_BILLING is true but the domain/key are missing, the integration degrades to fail-open (no balance read, no charge) and logs a warning, so a misconfigured instance never blocks uploads.

Product analytics (PostHog)​

Optional. Off by default: with no key the backend no-ops and the frontend ships with no tracking, so neither touches the network. See analytics.

VariableDescriptionDefaultRequired
POSTHOG_API_KEYBackend project key (phc_…). Enables server-side capture of every user action and job outcome. Empty = off.emptyNo
POSTHOG_HOSTBackend ingestion host: https://us.i.posthog.com, https://eu.i.posthog.com, or a self-hosted URL.https://us.i.posthog.comNo
NEXT_PUBLIC_POSTHOG_KEYFrontend project key (use the same project as the backend). Enables page views, autocapture, session replay, and identify. Build-time (see below). Empty = off.emptyNo
NEXT_PUBLIC_POSTHOG_HOSTFrontend ingestion host. App defaults to US Cloud when blank. Build-time.empty (US Cloud)No

The backend keys are read at runtime (a restart picks up a change). The two NEXT_PUBLIC_* keys are public and build-time: compose passes them to the frontend image as build args and Next.js inlines them into the bundle, so changing them requires a rebuild, not just a restart.

Slack notifications (media processing)​

Optional. Off by default: with no webhook the backend no-ops and never calls out. When set, a message is posted to a Slack channel every time a media-processing job finishes (success and failure), carrying environment (Stage or Production, from APP_ENV), company, user, media type, credits used, and revenue (credits used × the team credit rate). See billing.

VariableDescriptionDefaultRequired
SLACK_WEBHOOK_URLSlack Incoming Webhook URL (https://hooks.slack.com/services/…). Empty = off.emptyNo
DEFAULT_TEAM_CREDIT_RATEFallback dollars per credit for the Revenue field (see billing rates); a per-team Team.credit_rate overrides it.0.15No

Read at runtime, so a restart picks up a change.

Support email (Mailgun)​

Optional. Off by default: with no API key the backend no-ops and never calls out. When set, the support address is emailed whenever a customer's PDF import fails or comes out looking wrong, with the offending PDF attached. A failed import almost always means a page layout the reader has not seen before; once the file is in hand the reader can be taught it, and every later upload of that format works on its own. See PDF import.

Matches the transport the main Adjust Square app already uses (Mailgun on mg.adjustsquare.com).

VariableDescriptionDefaultRequired
MAILGUN_API_KEYMailgun key used to send. A domain-scoped sending key is sufficient and is the right choice: the app only ever posts to <domain>/messages and never reads account data. Empty = off.emptyNo
MAILGUN_DOMAINSending domain configured in Mailgun.mg.adjustsquare.comNo
MAILGUN_BASE_URLMailgun API root. Use https://api.eu.mailgun.net/v3 for an EU account.https://api.mailgun.net/v3No
MAILGUN_FROMEnvelope sender. Must be on MAILGUN_DOMAIN or Mailgun rejects the message.ContentsVision <[email protected]>No
SUPPORT_EMAILWhere the alerts go.[email protected]No
MAX_EMAIL_ATTACHMENT_MBA PDF over this is reported without the attachment, naming its path on the server instead. Mailgun's own ceiling is 25MB.20No
MAILGUN_WEBHOOK_SIGNING_KEYMailgun's HTTP webhook signing key. Verifies delivery/open/click callbacks for the insured inventory link before they are allowed to update a row. Empty (the default) means every callback is rejected, which is the correct closed default for a public endpoint that writes.emptyNo

The same Mailgun credentials also send the insured's inventory invitation. Without an API key that email silently does not go, and the adjuster's screen reports not_configured rather than implying it was sent.

Read at runtime, so a restart picks up a change.

The public, unauthenticated link a homeowner uses to build their own inventory. Every limit here exists because nobody is authenticated behind these endpoints. See Insured inventory link.

VariableDescriptionDefaultRequired
PUBLIC_APP_BASE_URLThe origin the emailed link points at, e.g. https://stage.contentsvision.com. Falls back to the first CORS_ORIGINS entry, then to http://localhost:3000. Set this in every deployment: a wrong value sends homeowners a link that does not resolve.emptyRecommended
INSURED_LINK_TTL_DAYSHow long a link stays usable. Long enough not to chase someone rebuilding their life, short enough that a forwarded link is not permanent.30No
INSURED_MAX_FILE_MBLargest single PHOTO the public upload accepts. Kept under the ~100MB Cloudflare Tunnel body cap. Videos are not bound by it: they go up in chunks.80No
INSURED_MAX_VIDEO_MBLargest video one link may upload. Videos are sent in chunks, so this is not limited by what a single request can carry. Two minutes of phone footage is comfortably 200-400MB.1500No
INSURED_CHUNK_MBSize of each piece of a chunked video upload. Well under the tunnel cap even with the browser's four pieces in flight, so a dropped connection costs one chunk rather than the file.16No
INSURED_LINK_MAX_TOTAL_MBEverything ONE link may ever upload, across every request and session. Deleting an upload does not credit it back.4000No
INSURED_LINK_MAX_FILESFiles one link may hold.2000No
INSURED_LINK_MAX_ITEMSTyped rows one link may hold.5000No
INSURED_RATE_LIMIT_READS_PER_MINUTEReads per minute per token.120No
INSURED_RATE_LIMIT_WRITES_PER_MINUTEWrites per minute per token.120No
INSURED_RATE_LIMIT_UPLOADS_PER_MINUTEUploads per minute per token.60No
INSURED_RATE_LIMIT_UNKNOWN_TOKEN_PER_MINUTERequests per minute per client IP carrying a token we do not recognise. Bounds probing noise; the token itself is 32 random bytes, so guessing is hopeless regardless.20No

Rate-limit counters are in-process (core/rate_limit.py), so with N workers the effective ceiling is N times the number configured. Set with that in mind. Any limit set to 0 or less is disabled.

Read at runtime, so a restart picks up a change.

Networking​

VariableDescriptionDefault
CORS_ORIGINSComma-separated allowed origins (no trailing slash).http://localhost:3000

Error tracking (Sentry)​

Sentry is off until a DSN is set, on both halves of the stack, so local dev and the test suite never ship anything. See observability for what reports from where.

VariableDescriptionDefaultRequired
SENTRY_DSNBackend DSN (API process and ARQ worker). Empty = error tracking off.emptyNo
SENTRY_TRACES_SAMPLE_RATEBackend performance tracing sample rate. 0.0 keeps tracing off (OTel owns tracing); errors still report.0.0No
NEXT_PUBLIC_SENTRY_DSNFrontend DSN. Public and build-time: passed as a compose build arg and baked into the bundle, so changing it needs a frontend rebuild. Empty = off.emptyNo
SENTRY_AUTH_TOKENOptional, build-time. When present the frontend build uploads source maps so stack traces are readable. Needs SENTRY_ORG and SENTRY_PROJECT alongside it.emptyNo

Observability (OpenTelemetry)​

Telemetry (traces, metrics, logs to the Grafana/Loki/Prometheus/Tempo stack) is off until OTEL_ENABLED=true and an endpoint are both set, so local dev and the test suite never ship off-box. config.py reads OTEL_ENABLED, OTEL_EXPORTER_OTLP_ENDPOINT, APP_ENV, and COMMIT_SHA; the rest are read by the OpenTelemetry SDK and the frontend build directly. See observability for what flows where.

VariableDescriptionDefaultRequired
OTEL_ENABLEDMaster switch for backend telemetry.falseNo
OTEL_EXPORTER_OTLP_ENDPOINTOTLP HTTP collector URL. One endpoint receives all three signals; the SDK appends /v1/traces, /v1/metrics, /v1/logs.emptyWhen telemetry is on
OTEL_EXPORTER_OTLP_PROTOCOLOTLP transport. Keep http/protobuf: it traverses proxies and Railway TLS more reliably than gRPC.http/protobufNo
OTEL_EXPORTER_OTLP_HEADERSCollector auth, comma-separated key=value (e.g. Authorization=Basic <b64>).emptyIf the collector requires auth
OTEL_SERVICE_NAMEOverrides the default contentsvision-backend service name.contentsvision-backendNo
NEXT_PUBLIC_OTEL_ENABLEDMaster switch for browser telemetry. Baked at build time.falseNo
NEXT_PUBLIC_OTEL_BROWSER_ENDPOINTSame-origin path the browser posts OTLP to, forwarded to the collector by the backend./api/telemetryNo
APP_ENVStamped on all telemetry as deployment.environment.localNo
COMMIT_SHAStamped on all telemetry as service.version.devNo

Deployment and frontend (env-only, not in config.py)​

VariableDescriptionWhere
APP_ENVproduction, stage, or local. Drives the env badge and title.compose / build arg
NEXT_PUBLIC_APP_ENVThe above, exposed to the frontend at build time. Also gates the imgproxy thumbnail CDN: when unset it defaults to production, so a stage build that forgets it turns the thumbnail CDN on (see imgproxy rows below). The stage and prod compose files set it explicitly.frontend build
NEXT_PUBLIC_IMGPROXY_ENABLEDtrue/false to force the thumbnail CDN on or off. Unset = on only when NEXT_PUBLIC_APP_ENV is production.frontend build arg
NEXT_PUBLIC_IMGPROXY_URLimgproxy base host for server-resized thumbnails.frontend build arg (default https://cdn.adjustsquare.com)
NEXT_PUBLIC_PHOTO_PUBLIC_ORIGINPublic origin imgproxy prefixes onto relative /api/photos paths so it can fetch the source. Leave it blank: the browser then uses the origin of the page it is on, so contentsvision.com and every client instance (e.g. stage-sgk.contentsvision.com) each point the CDN at their own server. Set it only if photos are served from a different host than the app. It used to default to https://contentsvision.com, which broke every thumbnail on a separately hosted instance. The Dockerfile and compose files do not pass it through today.frontend build arg (default: blank = the page's own origin)
NEXT_PUBLIC_API_URLWhere the frontend proxies /api (e.g. http://backend:8000). Must be a build arg: the standalone build freezes the rewrite destination at build time, so a runtime-only value is ignored.frontend build arg
NEXT_PUBLIC_POSTHOG_KEYFrontend PostHog project key. Build arg (inlined into the bundle). Empty = analytics off. See analytics.frontend build arg
NEXT_PUBLIC_POSTHOG_HOSTFrontend PostHog ingestion host. Build arg. Defaults to US Cloud in app code.frontend build arg
CLOUDFLARE_TUNNEL_TOKENToken for the cloudflared service.prod compose
COMMIT_SHA / COMMIT_TIMEOptional build metadata baked into the frontend.build args

How settings are loaded​

config.py reads .env once at import, exposing a settings singleton. Auto-approval rules and AI prompt presets are not environment variables; they are stored as JSON files and edited from the Settings page. They are scoped per team, one file per team under STORAGE_DIR/team_settings/<team_id>/ (automation_settings.json, prompt_settings.json). See automation and prompts.