Deckgauge declares its configuration in .env.example. Run ./scripts/init-env.sh and it writes a .env whose defaults produce a working local install, with a freshly generated secret for every credential (copying the template by hand does not work — the credential lines are deliberately empty) — but on the Docker path, declared in .env.example and reaches a container are two different things, and the section below draws that line before the tables.
deckgauge · docker-compose.yml
interpolated from .envPOSTGRES_USER: ${POSTGRES_USER:-cockpit} — your .env value wins; falls back to cockpit when unset.
required, no fallbackNEXTAUTH_SECRET: "${NEXTAUTH_SECRET:?…}" — credentials use :? rather than :-. Compose refuses to render while one is unset instead of falling back to a published default.
Every secret-shaped value is interpolated from .env, falling back to a published default when unset.
How configuration reaches each container
docker compose reads .env from the repository root automatically and interpolates ${VAR:-default} references inside docker-compose.yml, so a variable can be absent from your .env and still resolve to a sane default. What it does not do is hand a container the whole file: docker-compose.yml carries no env_file: directive on any service. A line in .env therefore reaches a container only if compose interpolates it into that service's environment: block.
Every secret-shaped value on every service is interpolated that way: POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB; CLICKHOUSE_USER, CLICKHOUSE_PASSWORD (compose-only until the default-credential change; declared in .env.example now); MICROSOFT_TENANT_ID, MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET; KEYCLOAK_CLIENT_SECRET and NEXTAUTH_SECRET on the web service; and Keycloak's own database and admin-console credentials — KEYCLOAK_DB_NAME, KEYCLOAK_DB_USER, KEYCLOAK_DB_PASSWORD, KEYCLOAK_ADMIN_USER, KEYCLOAK_ADMIN_PASSWORD — on the keycloak-db and keycloak services. Setting any of those in .env changes what the container gets. The credentials among them no longer fall back to anything: every password and secret is ${VAR:?…}, so compose refuses to render while one is unset and names it. Usernames and database names keep their :- defaults, because they are identifiers rather than secrets and are what the data volumes were created with. ./scripts/init-env.sh generates the whole set; --check reports what an existing .env is missing. Everything else a container sees — the Keycloak URLs, the client id, NEXTAUTH_URL, hostname settings — is a non-secret literal written into docker-compose.yml; edit docker-compose.yml itself to change one of those.
Upgrading past the default-credential changeEvery credential fallback in docker-compose.yml is gone — POSTGRES_PASSWORD no longer defaults to cockpit, KEYCLOAK_ADMIN_PASSWORD no longer defaults to admin — so an existing .env that left them out stops the stack at docker compose up with the variable named. Run ./scripts/init-env.sh --check: it lists what is missing and changes nothing. Fill each one with the value your install is already using (cockpit, keycloak, deckgauge-secret), not a fresh secret: Postgres, ClickHouse and keycloak-db read their password only on first start, so a new one locks the stack out of its own volume rather than rotating anything. A fresh NEXTAUTH_SECRET is the one exception — it is safe, and signs everyone out once.
Change the defaults before exposing an instanceThe values above resolve to a working default when .env doesn't set them, and those defaults are published on this page — fine for a private local install, not fine once anyone outside your machine can reach it. Set KEYCLOAK_CLIENT_SECRET, NEXTAUTH_SECRET, KEYCLOAK_ADMIN_PASSWORD, and KEYCLOAK_DB_PASSWORD in .env before then (and match the client secret in Keycloak's admin console).
The tables below list what each variable does. Where a variable is declared in .env.example but doesn't reach a container on the Docker path, the row says so; those still apply when you run apps/api, apps/web, or apps/worker directly on the host, which reads .env normally. A few rows below aren't in .env.example at all — either a compose-only literal or a variable the code reads with a working default — and each of those says so too.
Variables by service
Postgres & database
Variable
What it does
Required?
POSTGRES_USER
Postgres role Deckgauge connects as
No — defaults work locally
POSTGRES_PASSWORD
Password for that role
No — defaults work locally
POSTGRES_DB
Database name (cockpit)
No — defaults work locally
DATABASE_URL
Full Postgres connection string Prisma and the API use
Yes
DATABASE_TEST_URL
Separate connection string for integration tests
Only if running the test suite
Redis
Variable
What it does
Required?
REDIS_URL
Redis connection string backing the BullMQ queues the worker runs
Yes
ClickHouse
Not declared in .env.example at all, and deliberately so — see the row below. On the Docker path it's a compose literal on both the api and worker services, built from CLICKHOUSE_USER/CLICKHOUSE_PASSWORD, which are declared in .env.example since the default-credential change.
Variable
What it does
Required?
CLICKHOUSE_URL
Connection string the API and worker use for engineering-intelligence queries and dual-writes. On the Docker path docker-compose.yml writes this value itself, from CLICKHOUSE_USER (defaulted to cockpit) and CLICKHOUSE_PASSWORD (required — it is the one of the two with no fallback); it is not an interpolation of CLICKHOUSE_URL, so setting that key in .env does nothing to a container either way. There is no fallback on the host path. It used to default to a localhost URL carrying a password committed to this repository; that is gone, and an unset variable now makes the client refuse on use with a message naming the variable rather than reaching whatever owns port 8123. Export it for the command when you run apps/api or apps/worker outside Docker — deliberately not via .env, which the worker's test setup also reads, so a value there would reach the test suite
No on Docker; required when running the apps on the host
Jira & Azure DevOps sync
Variable
What it does
Required?
JIRA_CONFIG_PATH
Declared in .env.example and passed to the api and worker containers, but nothing reads it — Jira connections live in the database and are set up from a board's Sources tab, not from a file
No — inert; nothing breaks if you drop it
ADO_CONFIG_PATH
Path to the Azure DevOps connection config, resolved relative to the repo root by the worker
Only for the file-configured ADO path
USE_FAKE_AZURE_DEVOPS
Set to true, the worker's work-item adapter returns fake data instead of calling the real API. Its PR and commit adapters gate on a separate USE_FAKE_ADO. Note that .env.example ships this switched on, and that compose passes neither variable to the worker container — both only bite when you run the worker on the host
No — dev/test convenience
Sync scheduling
Not declared in .env.example and not passed through docker-compose.yml to the worker service — on the Docker path this always runs at its default. It only takes effect when you run apps/worker on the host with it set directly.
Variable
What it does
Required?
CRON_INTERVAL
Base repeat interval, in milliseconds, for every scheduled sync and intelligence job the worker runs — Jira, GitHub, GitLab and Azure DevOps sync, plus Jira/ADO intelligence sync (the daily prune queue is hardcoded separately and ignores it)
No — defaults to 900000 (15 minutes)
Per-organization concurrency
Declared in .env.example (commented out) and interpolated into the worker service, so unlike CRON_INTERVAL above this one does reach the container on the Docker path.
Variable
What it does
Required?
WORKER_MAX_CONCURRENT_JOBS_PER_ORG
Maximum sync jobs a single organization may run at once on the worker's tier queues. When one organization hits the cap, its next job is put back on the delayed set instead of holding a worker slot, which is what lets a quieter organization's job run instead of queueing behind it. Zero or unset disables the cap entirely
No — defaults to 0 (disabled)
Leave it unset unless you host several organizationsDisabled is the default precisely so that a single-organization install — every self-host, and the source-available distribution — behaves exactly as it did before the cap existed. With one organization there is nobody to be fair to, and a cap can only slow you down.
The cap counts per worker process, not per clusterIt is an in-process counter. The standard deployment runs one worker container, so per-process and cluster-wide are the same number today. If you scale the worker to two replicas, the effective cap per organization doubles — plan for that rather than discovering it.
Web / API base URL
Variable
What it does
Required?
API_URL
Not declared in .env.example. Server-side base URL the web app's Next.js server actions use to call the API directly — distinct from the browser-facing variable below. A compose literal (http://api:3001) on the Docker path; falls back to http://localhost:3001 when unset
No — defaults work locally
NEXT_PUBLIC_API_URL
API base URL the browser calls directly (used for uploads)
Yes
Keycloak (identity)
Variable
What it does
Required?
KEYCLOAK_ISSUER
Realm issuer URL the API verifies incoming JWTs against
Yes
KEYCLOAK_CLIENT_ID
OIDC client id the web app authenticates as
Yes
KEYCLOAK_CLIENT_SECRET
Secret for that client. Required — compose has no fallback for it, and init-env.sh generates one. It is substituted into the realm export's __CLIENT_SECRET__ placeholder when a FRESH realm is imported; changing it on a Keycloak that already exists needs kcadm as well, or the two disagree and login fails
Yes
KEYCLOAK_JWKS_URI
Where the API fetches Keycloak's signing keys
Yes
KEYCLOAK_INTERNAL_URL
Not declared in .env.example. Docker-internal Keycloak URL (container→container) the web app uses server-side for the token/userinfo/JWKS calls, since a container can't reach the browser-facing KEYCLOAK_ISSUER (localhost:8080) — a compose literal on the Docker path. Falls back to KEYCLOAK_ISSUER when unset, which is the correct behaviour on the host-run path, where localhost:8080 is directly reachable
No — Docker-only; leave it unset on the host
NEXT_PUBLIC_KEYCLOAK_ISSUER
Removed. NEXT_PUBLIC_* is inlined into the browser bundle at build time, so it described the machine that built the image rather than the install running it — sign-out sent the id_token to the wrong realm. Login and logout now read KEYCLOAK_ISSUER at runtime
No — no longer read
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID
Removed for the same reason; KEYCLOAK_CLIENT_ID covers it
No — no longer read
KEYCLOAK_DB_NAME
Not in the Keycloak section of .env.example's comments but declared alongside the DB vars below. Database name for Keycloak's own Postgres instance (the keycloak-db service) — interpolated into both keycloak-db and keycloak's compose environment, falling back to keycloak
No — defaults work locally
KEYCLOAK_DB_USER
Role Keycloak connects to its own database as — same interpolation, falls back to keycloak
No — defaults work locally
KEYCLOAK_DB_PASSWORD
Password for that role. Required — the keycloak fallback is gone. Read only on the volume's first start, so changing it on an existing install locks Keycloak out of its own database
Yes
KEYCLOAK_ADMIN_USER
Username for Keycloak's own admin console — interpolated into the keycloak service, falls back to admin
No — defaults work locally
KEYCLOAK_ADMIN_PASSWORD
Password for the Keycloak admin console. Required — the admin fallback is gone. Seeds the master realm on Keycloak's FIRST start only; afterwards use kcadm.sh set-password -r master --username admin
Yes
NextAuth.js
Variable
What it does
Required?
NEXTAUTH_SECRET
Signs the session. Required — the published deckgauge-nextauth-secret fallback is gone, and init-env.sh generates one. Changing it invalidates every live browser session, which is safe and happens once
Yes
NEXTAUTH_URL
Canonical URL NextAuth uses for callbacks
Yes
API rate limiting
Neither variable is declared in .env.example, and neither is passed through docker-compose.yml to the api service — on the Docker path the limiter always runs at its defaults. Setting them has an effect only when you run apps/api on the host with them set directly.
Variable
What it does
Required?
RATE_LIMIT_MAX
Maximum requests per window before the API's global rate limiter (@fastify/rate-limit, applied to every route) starts returning 429
No — defaults to 300
RATE_LIMIT_WINDOW
Window length for that cap, e.g. 1 minute
No — defaults to 1 minute
API authorization
Not declared in .env.example, and not passed through docker-compose.yml to the api service — on the Docker path the API always runs fully enforced. Setting any of these has an effect only when you run apps/api on the host with them set directly, or after adding them to docker-compose.yml's api service yourself.
Variable
What it does
Required?
DECKGAUGE_SINGLE_USER
Set to true, every API request is allowed with no authentication or authorization check at all — every declared policy (board role, org-tree role, comparison, connection ownership, everything) is bypassed, not just the checks a single-user setup would plausibly need. Logs a warning on startup so an instance left this way isn't silently open. Meant for a genuinely single-user install nobody else can reach
No — defaults to off (fully enforced)
COCKPIT_ADMIN_ROLE
Name of the Keycloak realm role that grants admin access — salary read/write, Owner-equivalent on every org tree, and (see below) the analytics role too
No — defaults to cockpit-admin
COCKPIT_ANALYTICS_ROLE
Name of the Keycloak realm role required to read engineering-intelligence data; timesheet reads also require Viewer on the org tree named in the request — see Access control
No — defaults to cockpit-analytics
Both realm roles are declared in the shipped keycloak/realm-export.json (so a fresh realm import creates them ready to assign from Keycloak's admin console) but nobody holds either one on a fresh install — you assign them to a user from Keycloak's admin console under that user's Role mapping.
Org-tree access starts emptyPer-tree OrgTreeAccess rows (Viewer/Editor/Owner on a specific org tree) don't exist until someone creates them. On an instance with no admin account yet, run the recovery script from a shell with access to the database: DATABASE_URL=<your Postgres URL> pnpm --filter @deckgauge/db grant:org-access --email you@example.com --tree all --role OWNER. It looks the user up by email (they must have signed in at least once so a local User row exists), grants the given role — OWNER, EDITOR, or VIEWER — on one tree id or every tree with --tree all, and prints what it changed. This is the documented recovery path precisely so nobody has to hand-write SQL against production. See Access control for the full org-tree role model.
Microsoft Entra ID SSO
Leave all three blank to disable Microsoft sign-in entirely — see Keycloak and identity providers for what happens when they're only partially set. The worker also reads these same three for the delegated-refresh-token fallback in Microsoft Graph directory sync below.
Variable
What it does
Required?
MICROSOFT_TENANT_ID
Entra tenant id for the SSO identity provider, and for the org-tree Graph sync's delegated-refresh-token fallback
No — optional
MICROSOFT_CLIENT_ID
Entra app registration's client id — same dual use
No — optional
MICROSOFT_CLIENT_SECRET
Secret for that app registration — same dual use
No — optional
Microsoft Graph directory sync
An org tree's primary connection is a pasted Graph Explorer access token, set from the org tree's Source tab — see Syncing your org directory from Microsoft Graph. The MICROSOFT_* variables above are read only as a fallback, for the delegated device-code flow.
Variable
What it does
Required?
GRAPH_TENANT_ID
Declared in .env.example, but read nowhere in the codebase — org-tree Graph sync uses MICROSOFT_TENANT_ID instead
No — unused
GRAPH_CLIENT_ID
Declared in .env.example, but read nowhere — use MICROSOFT_CLIENT_ID
No — unused
GRAPH_CLIENT_SECRET
Declared in .env.example, but read nowhere — use MICROSOFT_CLIENT_SECRET
No — unused
USE_FAKE_GRAPH
Runs the worker against a fake Graph client instead of the real API
No — dev/test convenience
Advisor (board Q&A)
Variable
What it does
Required?
ADVISOR_PROVIDER
anthropic or ollama — leave blank and the panel reports itself unconfigured
No — optional
ADVISOR_MODEL
Model name, e.g. claude-haiku-4-5 or llama3.2
Only with ADVISOR_PROVIDER set
ADVISOR_ANTHROPIC_API_KEY
Anthropic-only key; a bare ANTHROPIC_API_KEY is never picked up
Only for the Anthropic provider
ADVISOR_OLLAMA_BASE_URL
Ollama-only base URL, including scheme (e.g. http://localhost:11434)
Only for the Ollama provider
ADVISOR_BRIDGE_AUTOSTART
Starts the local-agent bridge alongside every deploy
No — optional
DECKGAUGE_TOKEN
Runs the advisor bridge headless, with no browser involved
No — optional
Open-core edition & licensing
Variable
What it does
Required?
DECKGAUGE_EDITION
community (default) or enterprise
No — defaults to Community
DECKGAUGE_ENTERPRISE_MODULE
Absolute path to the built enterprise module entry point
Only for the Enterprise edition
DECKGAUGE_LICENSE_FILE
Absolute path to the signed license file
Only for the Enterprise edition
DECKGAUGE_ALLOW_DEV_LICENSE_KEYS
Trusts an extra verify key so locally-signed dev licenses validate — never set to 1 in production
No — dev/test only
DECKGAUGE_LICENSE_PUBKEY
The extra dev verify key trusted when the flag above is on
No — dev/test only
If it looks wrong
Symptom
Cause
Fix
Login page shows no Microsoft option, though you set the variables
The realm import is only reprocessed on the realm's first boot — once it exists in the persisted keycloak-db volume, recreating the keycloak container doesn't reliably reprocess it
Add the Microsoft identity provider directly from the Keycloak admin console — see Keycloak and identity providers for why a recreate isn't the fix
Advisor panel says it's unconfigured
ADVISOR_PROVIDER is blank and no local-agent bridge is connected