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.
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):
- Arguments passed to
Settings()(tests only). - A real environment variable.
- This checkout's
backend/.env. ~/.contentsvision/local.env, machine-wide and local development only.- 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.pysetsCONTENTSVISION_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/.envwins. - 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 inbackend/.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
| Variable | Description | Default | Required |
|---|---|---|---|
OPENROUTER_API_KEY | Vision and analysis via OpenRouter (Claude). The pipeline's main AI. | empty | Yes |
DEEPGRAM_API_KEY | Transcription via Deepgram Nova-2. Without it, inventories are visual-only. | empty | Yes (for narration/audio) |
ANTHROPIC_API_KEY | Anthropic key. Present for compatibility; vision goes through OpenRouter. | empty | Situational |
OPENROUTER_BASE_URL | OpenRouter API base. | https://openrouter.ai/api/v1 | No |
OPENROUTER_MODEL | The 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-7 | No |
OPENROUTER_TEMPERATURE | Sampling 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. | 0 | No |
OPENROUTER_CONCURRENCY | Max simultaneous AI calls per job for video and audio jobs. | 6 | No |
OPENROUTER_PHOTO_CONCURRENCY | Max 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. | 12 | No |
OPENROUTER_GLOBAL_CONCURRENCY | Max 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). | 24 | No |
CLAUDE_MODEL | Unused. Still accepted so an existing .env carrying it does not fail startup; /health reports OPENROUTER_MODEL. Remove the line from your .env. | empty | No |
DEEPGRAM_MODEL | Transcription model. | nova-2 | No |
AdjustSquare SSO (login)
| Variable | Description | Default | Required |
|---|---|---|---|
ADJUSTSQUARE_JWT_SECRET | Shared HS256 secret used to validate login tokens. If unset, all logins fail. | empty | Yes |
ADJUSTSQUARE_LOGIN_URL | Where unauthenticated users are redirected to sign in. | https://demo.adjustsquare.com/login | Yes |
ADJUSTSQUARE_COOKIE_NAME | Cookie name AdjustSquare sets on shared subdomains. | bt_shared_data | No |
ADMIN_EXTERNAL_USER_IDS | Allowlist 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)
| Variable | Description | Default | Required |
|---|---|---|---|
ADJUST_SQUARE_BASE_URL | Base URL of the Adjust Square API. One host per deployment. | empty | Yes (to submit) |
ADJUST_SQUARE_BEARER_TOKEN | Optional 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. | empty | No (optional fallback) |
ADJUST_SQUARE_APP_URL | The 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). | empty | No |
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.
| Variable | Description | Default | Required |
|---|---|---|---|
CONTENTS_ESTIMATION_BASE_URL | Base 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). | empty | Yes (to send) |
CONTENTS_ESTIMATION_APP_URL | The 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.com | No |
CONTENTS_ESTIMATION_SSO_SECRET | Signing 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. | empty | No |
CONTENTS_ESTIMATION_SSO_TTL_MINUTES | How long a minted hand-off token stays valid. Short by design: it rides in the opened tab's URL and is spent on arrival. | 3 | No |
PUBLIC_BASE_URL | ContentsVision'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. | empty | No, but photos need it |
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:
| Environment | API (..._BASE_URL) | Web app (..._APP_URL) |
|---|---|---|
| Production | https://api.contentsestimation.com | https://contentsestimation.com |
| Stage | https://api-staging.contentsestimation.com | https://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.
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 goneThis 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)
| Variable | Description | Default | Required |
|---|---|---|---|
CONTENTS_CAPTURE_API_BASE_URL | Base 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. | empty | Yes (to import) |
CONTENTS_CAPTURE_WEBHOOK_SECRET | HMAC 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. | empty | No |
CONTENTS_CAPTURE_IMPORT_CONCURRENCY | Parallel media downloads per import run. | 4 | No |
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
| Variable | Description | Default | Required |
|---|---|---|---|
DATABASE_URL | SQLAlchemy async DB URL. | sqlite+aiosqlite:///./storage/inventory.db | No |
UPLOAD_DIR | Where raw uploads are streamed. | ./uploads | No |
STORAGE_DIR | Per-job files (frames, photos, crops), the SQLite DB, and the settings JSON files. | ./storage | No |
MAX_UPLOAD_SIZE_MB | Per-file upload size cap for video/audio (returns 413 if exceeded). | 2000 | No |
MAX_PHOTO_UPLOAD_MB | Running-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). | 8000 | No |
STUCK_JOB_MINUTES | Minutes 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. | 15 | No |
Pipeline worker (production)
| Variable | Description | Default | Required |
|---|---|---|---|
REDIS_URL | The 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. | unset | Production |
WORKER_CONCURRENCY | Pipelines one worker container runs at once (ARQ max_jobs). | 4 | No |
JOB_TIMEOUT_S | Seconds one run of one job may take before ARQ kills it (pipelines are then re-run once). | 3600 | No |
WEB_CONCURRENCY | uvicorn 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) / 1 | No |
Pipeline tuning
| Variable | Description | Default |
|---|---|---|
FRAME_EXTRACTION_FPS | Frames extracted from video per second. | 1.0 |
FRAME_LONG_EDGE_PX | Frames are shrunk inside ffmpeg so the long edge is at most this many pixels (never upscaled). 0 keeps full resolution. | 1600 |
FRAME_JPEG_QUALITY | ffmpeg MJPEG quality for frame files (2 best, 31 worst). | 4 |
KEYFRAME_DECODE_MIN_RATIO | Decode 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_SECONDS | How often the pipeline logs and commits a "still extracting" heartbeat while ffmpeg runs. | 10 |
CHUNK_DURATION_SECONDS | Length of each analyzed time chunk. | 30.0 |
FRAMES_PER_MINUTE | Target frame density per chunk. | 60 |
MAX_FRAMES_PER_CALL | Hard cap on frames per vision call. | 60 |
VIDEO_DEDUP_WINDOW_SECONDS | Boundary window for cross-chunk dedup. | 3.0 |
MAX_PHOTOS_PER_CALL | Photos per call in the legacy batched photo pipeline. | 8 |
MAX_PHOTOS_PER_ITEM | Hard 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_PIXELS | Photos 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_EDGE | Photos are also never wider than this on the long side. | 1568 |
VIDEO_FRAME_LONG_EDGE | Video frames stay at this long edge; a chunk sends up to 60 of them. | 768 |
MAX_FRAMES_PER_ROOM | Legacy, unused after the time-chunk pipeline. | 8 |
PHOTO_BUDGET_CREDIT_RATE_USD | The 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_MARGIN | Share of that credit a single pass may spend. | 0.8 |
PHOTO_BUDGET_MIN_OUTPUT_TOKENS | Floor on a photo request's output budget, so every request can name a few items. | 800 |
PHOTO_BUDGET_MAX_OUTPUT_TOKENS | Ceiling on a photo request's output budget. | 3500 |
PHOTO_BUDGET_TRUNCATION_RATIO | Share 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_PASSES | Passes 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)
| Variable | Description | Default |
|---|---|---|
BILLING_BLOCK_SECONDS | Length of one video/audio billing block, in seconds. Time is rounded up to whole blocks. | 20 |
COST_PER_VIDEO_BLOCK | Credits per block of video, so 9 credits a minute at the defaults. | 3 |
COST_PER_AUDIO_BLOCK | Credits per block of audio, so 3 credits a minute at the defaults. | 1 |
COST_PER_PHOTO_REQUEST | Credits per photo AI request (one per group). | 1 |
COST_PER_CONTENTS_ESTIMATION_ITEM | Credits 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_RATE | Fallback 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.
| Variable | Description | Default |
|---|---|---|
ENABLE_BILLING | Master switch for the prepaid balance gate + charge. | false |
BILLING_APP_DOMAIN | Base URL of the external billing service (no trailing slash). | (empty) |
BILLING_APP_API_KEY | Bearer key for the billing service. | (empty) |
BILLING_EXTERNAL_APP | Identifier 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.
| Variable | Description | Default | Required |
|---|---|---|---|
POSTHOG_API_KEY | Backend project key (phc_…). Enables server-side capture of every user action and job outcome. Empty = off. | empty | No |
POSTHOG_HOST | Backend ingestion host: https://us.i.posthog.com, https://eu.i.posthog.com, or a self-hosted URL. | https://us.i.posthog.com | No |
NEXT_PUBLIC_POSTHOG_KEY | Frontend project key (use the same project as the backend). Enables page views, autocapture, session replay, and identify. Build-time (see below). Empty = off. | empty | No |
NEXT_PUBLIC_POSTHOG_HOST | Frontend 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.
| Variable | Description | Default | Required |
|---|---|---|---|
SLACK_WEBHOOK_URL | Slack Incoming Webhook URL (https://hooks.slack.com/services/…). Empty = off. | empty | No |
DEFAULT_TEAM_CREDIT_RATE | Fallback dollars per credit for the Revenue field (see billing rates); a per-team Team.credit_rate overrides it. | 0.15 | No |
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).
| Variable | Description | Default | Required |
|---|---|---|---|
MAILGUN_API_KEY | Mailgun 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. | empty | No |
MAILGUN_DOMAIN | Sending domain configured in Mailgun. | mg.adjustsquare.com | No |
MAILGUN_BASE_URL | Mailgun API root. Use https://api.eu.mailgun.net/v3 for an EU account. | https://api.mailgun.net/v3 | No |
MAILGUN_FROM | Envelope sender. Must be on MAILGUN_DOMAIN or Mailgun rejects the message. | ContentsVision <[email protected]> | No |
SUPPORT_EMAIL | Where the alerts go. | [email protected] | No |
MAX_EMAIL_ATTACHMENT_MB | A PDF over this is reported without the attachment, naming its path on the server instead. Mailgun's own ceiling is 25MB. | 20 | No |
MAILGUN_WEBHOOK_SIGNING_KEY | Mailgun'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. | empty | No |
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.
Insured inventory links
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.
| Variable | Description | Default | Required |
|---|---|---|---|
PUBLIC_APP_BASE_URL | The 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. | empty | Recommended |
INSURED_LINK_TTL_DAYS | How long a link stays usable. Long enough not to chase someone rebuilding their life, short enough that a forwarded link is not permanent. | 30 | No |
INSURED_MAX_FILE_MB | Largest 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. | 80 | No |
INSURED_MAX_VIDEO_MB | Largest 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. | 1500 | No |
INSURED_CHUNK_MB | Size 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. | 16 | No |
INSURED_LINK_MAX_TOTAL_MB | Everything ONE link may ever upload, across every request and session. Deleting an upload does not credit it back. | 4000 | No |
INSURED_LINK_MAX_FILES | Files one link may hold. | 2000 | No |
INSURED_LINK_MAX_ITEMS | Typed rows one link may hold. | 5000 | No |
INSURED_RATE_LIMIT_READS_PER_MINUTE | Reads per minute per token. | 120 | No |
INSURED_RATE_LIMIT_WRITES_PER_MINUTE | Writes per minute per token. | 120 | No |
INSURED_RATE_LIMIT_UPLOADS_PER_MINUTE | Uploads per minute per token. | 60 | No |
INSURED_RATE_LIMIT_UNKNOWN_TOKEN_PER_MINUTE | Requests 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. | 20 | No |
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
| Variable | Description | Default |
|---|---|---|
CORS_ORIGINS | Comma-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.
| Variable | Description | Default | Required |
|---|---|---|---|
SENTRY_DSN | Backend DSN (API process and ARQ worker). Empty = error tracking off. | empty | No |
SENTRY_TRACES_SAMPLE_RATE | Backend performance tracing sample rate. 0.0 keeps tracing off (OTel owns tracing); errors still report. | 0.0 | No |
NEXT_PUBLIC_SENTRY_DSN | Frontend 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. | empty | No |
SENTRY_AUTH_TOKEN | Optional, build-time. When present the frontend build uploads source maps so stack traces are readable. Needs SENTRY_ORG and SENTRY_PROJECT alongside it. | empty | No |
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.
| Variable | Description | Default | Required |
|---|---|---|---|
OTEL_ENABLED | Master switch for backend telemetry. | false | No |
OTEL_EXPORTER_OTLP_ENDPOINT | OTLP HTTP collector URL. One endpoint receives all three signals; the SDK appends /v1/traces, /v1/metrics, /v1/logs. | empty | When telemetry is on |
OTEL_EXPORTER_OTLP_PROTOCOL | OTLP transport. Keep http/protobuf: it traverses proxies and Railway TLS more reliably than gRPC. | http/protobuf | No |
OTEL_EXPORTER_OTLP_HEADERS | Collector auth, comma-separated key=value (e.g. Authorization=Basic <b64>). | empty | If the collector requires auth |
OTEL_SERVICE_NAME | Overrides the default contentsvision-backend service name. | contentsvision-backend | No |
NEXT_PUBLIC_OTEL_ENABLED | Master switch for browser telemetry. Baked at build time. | false | No |
NEXT_PUBLIC_OTEL_BROWSER_ENDPOINT | Same-origin path the browser posts OTLP to, forwarded to the collector by the backend. | /api/telemetry | No |
APP_ENV | Stamped on all telemetry as deployment.environment. | local | No |
COMMIT_SHA | Stamped on all telemetry as service.version. | dev | No |
Deployment and frontend (env-only, not in config.py)
| Variable | Description | Where |
|---|---|---|
APP_ENV | production, stage, or local. Drives the env badge and title. | compose / build arg |
NEXT_PUBLIC_APP_ENV | The 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_ENABLED | true/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_URL | imgproxy base host for server-resized thumbnails. | frontend build arg (default https://cdn.adjustsquare.com) |
NEXT_PUBLIC_PHOTO_PUBLIC_ORIGIN | Public 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_URL | Where 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_KEY | Frontend PostHog project key. Build arg (inlined into the bundle). Empty = analytics off. See analytics. | frontend build arg |
NEXT_PUBLIC_POSTHOG_HOST | Frontend PostHog ingestion host. Build arg. Defaults to US Cloud in app code. | frontend build arg |
CLOUDFLARE_TUNNEL_TOKEN | Token for the cloudflared service. | prod compose |
COMMIT_SHA / COMMIT_TIME | Optional 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.