Configuration reference

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

VariableWhat it doesRequired?
POSTGRES_USERPostgres role Deckgauge connects asNo — defaults work locally
POSTGRES_PASSWORDPassword for that roleNo — defaults work locally
POSTGRES_DBDatabase name (cockpit)No — defaults work locally
DATABASE_URLFull Postgres connection string Prisma and the API useYes
DATABASE_TEST_URLSeparate connection string for integration testsOnly if running the test suite

Redis

VariableWhat it doesRequired?
REDIS_URLRedis connection string backing the BullMQ queues the worker runsYes

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.

VariableWhat it doesRequired?
CLICKHOUSE_URLConnection 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 suiteNo on Docker; required when running the apps on the host

Jira & Azure DevOps sync

VariableWhat it doesRequired?
JIRA_CONFIG_PATHDeclared 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 fileNo — inert; nothing breaks if you drop it
ADO_CONFIG_PATHPath to the Azure DevOps connection config, resolved relative to the repo root by the workerOnly for the file-configured ADO path
USE_FAKE_AZURE_DEVOPSSet 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 hostNo — 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.

VariableWhat it doesRequired?
CRON_INTERVALBase 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.

VariableWhat it doesRequired?
WORKER_MAX_CONCURRENT_JOBS_PER_ORGMaximum 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 entirelyNo — 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

VariableWhat it doesRequired?
API_URLNot 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 unsetNo — defaults work locally
NEXT_PUBLIC_API_URLAPI base URL the browser calls directly (used for uploads)Yes

Keycloak (identity)

VariableWhat it doesRequired?
KEYCLOAK_ISSUERRealm issuer URL the API verifies incoming JWTs againstYes
KEYCLOAK_CLIENT_IDOIDC client id the web app authenticates asYes
KEYCLOAK_CLIENT_SECRETSecret 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 failsYes
KEYCLOAK_JWKS_URIWhere the API fetches Keycloak's signing keysYes
KEYCLOAK_INTERNAL_URLNot 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 reachableNo — Docker-only; leave it unset on the host
NEXT_PUBLIC_KEYCLOAK_ISSUERRemoved. 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 runtimeNo — no longer read
NEXT_PUBLIC_KEYCLOAK_CLIENT_IDRemoved for the same reason; KEYCLOAK_CLIENT_ID covers itNo — no longer read
KEYCLOAK_DB_NAMENot 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 keycloakNo — defaults work locally
KEYCLOAK_DB_USERRole Keycloak connects to its own database as — same interpolation, falls back to keycloakNo — defaults work locally
KEYCLOAK_DB_PASSWORDPassword 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 databaseYes
KEYCLOAK_ADMIN_USERUsername for Keycloak's own admin console — interpolated into the keycloak service, falls back to adminNo — defaults work locally
KEYCLOAK_ADMIN_PASSWORDPassword 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 adminYes

NextAuth.js

VariableWhat it doesRequired?
NEXTAUTH_SECRETSigns 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 onceYes
NEXTAUTH_URLCanonical URL NextAuth uses for callbacksYes

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.

VariableWhat it doesRequired?
RATE_LIMIT_MAXMaximum requests per window before the API's global rate limiter (@fastify/rate-limit, applied to every route) starts returning 429No — defaults to 300
RATE_LIMIT_WINDOWWindow length for that cap, e.g. 1 minuteNo — 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.

VariableWhat it doesRequired?
DECKGAUGE_SINGLE_USERSet 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 reachNo — defaults to off (fully enforced)
COCKPIT_ADMIN_ROLEName of the Keycloak realm role that grants admin access — salary read/write, Owner-equivalent on every org tree, and (see below) the analytics role tooNo — defaults to cockpit-admin
COCKPIT_ANALYTICS_ROLEName 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 controlNo — 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.

VariableWhat it doesRequired?
MICROSOFT_TENANT_IDEntra tenant id for the SSO identity provider, and for the org-tree Graph sync's delegated-refresh-token fallbackNo — optional
MICROSOFT_CLIENT_IDEntra app registration's client id — same dual useNo — optional
MICROSOFT_CLIENT_SECRETSecret for that app registration — same dual useNo — 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.

VariableWhat it doesRequired?
GRAPH_TENANT_IDDeclared in .env.example, but read nowhere in the codebase — org-tree Graph sync uses MICROSOFT_TENANT_ID insteadNo — unused
GRAPH_CLIENT_IDDeclared in .env.example, but read nowhere — use MICROSOFT_CLIENT_IDNo — unused
GRAPH_CLIENT_SECRETDeclared in .env.example, but read nowhere — use MICROSOFT_CLIENT_SECRETNo — unused
USE_FAKE_GRAPHRuns the worker against a fake Graph client instead of the real APINo — dev/test convenience

Advisor (board Q&A)

VariableWhat it doesRequired?
ADVISOR_PROVIDERanthropic or ollama — leave blank and the panel reports itself unconfiguredNo — optional
ADVISOR_MODELModel name, e.g. claude-haiku-4-5 or llama3.2Only with ADVISOR_PROVIDER set
ADVISOR_ANTHROPIC_API_KEYAnthropic-only key; a bare ANTHROPIC_API_KEY is never picked upOnly for the Anthropic provider
ADVISOR_OLLAMA_BASE_URLOllama-only base URL, including scheme (e.g. http://localhost:11434)Only for the Ollama provider
ADVISOR_BRIDGE_AUTOSTARTStarts the local-agent bridge alongside every deployNo — optional
DECKGAUGE_TOKENRuns the advisor bridge headless, with no browser involvedNo — optional

Open-core edition & licensing

VariableWhat it doesRequired?
DECKGAUGE_EDITIONcommunity (default) or enterpriseNo — defaults to Community
DECKGAUGE_ENTERPRISE_MODULEAbsolute path to the built enterprise module entry pointOnly for the Enterprise edition
DECKGAUGE_LICENSE_FILEAbsolute path to the signed license fileOnly for the Enterprise edition
DECKGAUGE_ALLOW_DEV_LICENSE_KEYSTrusts an extra verify key so locally-signed dev licenses validate — never set to 1 in productionNo — dev/test only
DECKGAUGE_LICENSE_PUBKEYThe extra dev verify key trusted when the flag above is onNo — dev/test only

If it looks wrong

SymptomCauseFix
Login page shows no Microsoft option, though you set the variablesThe 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 itAdd 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 unconfiguredADVISOR_PROVIDER is blank and no local-agent bridge is connectedSet the Advisor variables, or configure a provider from Settings — see Configuring an LLM provider
API starts as Community even with DECKGAUGE_EDITION=enterpriseDECKGAUGE_ENTERPRISE_MODULE is unset or points at a path that doesn't existSet it to the module's absolute path — see Community and Enterprise editions
Every request succeeds even with no login at allDECKGAUGE_SINGLE_USER=true is set somewhere the API reads itUnset it (or set it to anything other than true) unless this really is a single-user instance nobody else can reach

Related

Last updated