Configuration
GantryCD is configured entirely through environment variables, read at startup. This page is the configuration reference, grouped by concern, with sane defaults called out.
Backend essentials
| Variable | Default | Notes |
|---|---|---|
DATABASE_URL | — (required) | Primary Postgres connection (writes). See High availability. |
DATABASE_URL_READ | falls back to DATABASE_URL | Optional read replica (reads). See High availability. |
DATABASE_URL_DIRECT | falls back to DATABASE_URL | Direct-to-primary DSN for the LISTEN/NOTIFY listener only. Set it when DATABASE_URL points at a transaction-pooling PgBouncer (LISTEN does not survive transaction pooling). See Postgres timeouts & workload isolation. |
PORT | 8080 | HTTP API. |
METRICS_PORT | 9090 | Prometheus metrics. |
LOG_LEVEL | info | |
CORS_ORIGINS | http://localhost:5173 | Comma-separated; must include the web origin. |
SESSION_COOKIE_SECURE | secure | Set false only for plain-HTTP local dev. |
GANTRYCD_BACKEND_PUBLIC_BASE_URL | — | Public origin. Required for Google/OIDC SSO and for webhook URLs. |
GANTRYCD_DATA_ENCRYPTION_PROVIDER | — | Required. At-rest encryption provider: aes-256 or aws-kms-aes-256. |
GANTRYCD_DATA_ENCRYPTION_AES_256_KEY_B64 | — | Required when provider is aes-256. Base64 32-byte AES key. Long-lived — changing it breaks existing rows. |
GANTRYCD_DATA_ENCRYPTION_AWS_KMS_KEY_ID | — | Required when provider is aws-kms-aes-256. CMK ARN, key id, or alias. |
GANTRYCD_DATA_ENCRYPTION_AWS_KMS_REGION | AWS_REGION | KMS region for aws-kms-aes-256. |
Rate limiting
| Variable | Default | Notes |
|---|---|---|
RATE_LIMIT_MODE | — (required) | redis or disabled. |
REDIS_URL | — (required) | Used for backend coordination and caches even when rate limiting is disabled. For Sentinel HA see High availability. |
High availability
GantryCD’s datastores are made highly available outside the application, by what their connection URLs point at — the backend runs no failover logic of its own. All three connection URLs — Postgres, Redis and the explore database (resource index + dependency graph) — are required to be configured, and the backend exits on startup if one is missing. Their availability is what differs: Postgres is load-bearing, while Redis and the explore database are best-effort, so the backend still boots and keeps serving (degraded) while those two are down.
Postgres (CQRS read/write split)
Writes always use DATABASE_URL; reads use DATABASE_URL_READ when set, else
they reuse DATABASE_URL. Point each at a failover-aware endpoint:
| Variable | Point it at | Why |
|---|---|---|
DATABASE_URL | the read-write service (e.g. CloudNativePG -rw) | Follows the primary across failover; all writes and the LISTEN/NOTIFY listener require the primary. |
DATABASE_URL_READ | the read-only replica service (e.g. CNPG -ro; or -r to include the primary) | Spreads read queries across hot standbys. Leave unset to send reads to the primary. |
Reads may briefly observe replication lag — a write you just committed may not
yet be visible on a replica. The deployment state machine is unaffected (every
invariant runs inside a primary write transaction), but a UI list fetched
immediately after a write can trail by the replication delay. Each pool defaults
to 25 open connections and is tunable via DB_* (write) and DB_READ_* (read);
note that with DATABASE_URL_READ unset both pools open against the primary.
Postgres timeouts & workload isolation
The backend refuses to wait on a stalled database indefinitely; it fails the request and cancels the work instead. Two layers enforce this, both tunable:
| Variable | Default | Notes |
|---|---|---|
GANTRYCD_REQUEST_TIMEOUT | 30s | Per-request context deadline for every HTTP route. When it fires the client gets a 504 and all in-flight queries for that request are cancelled. Cannot be disabled. |
DB_STATEMENT_TIMEOUT / DB_LOCK_TIMEOUT / DB_IDLE_IN_TX_TIMEOUT | 15s / 5s / 60s | Server-side session timeouts for the interactive pools, sent as connection startup parameters. Set to 0 to disable one. A parameter already present in DATABASE_URL wins over these. |
DB_JOBS_STATEMENT_TIMEOUT / DB_JOBS_LOCK_TIMEOUT / DB_JOBS_IDLE_IN_TX_TIMEOUT | 5m / 1m / 5m | Same, for the background pools. Generous on purpose: scheduler jobs are already deadline-bounded in the app. |
DB_JOBS_MAX_OPEN_CONNS / DB_JOBS_MAX_IDLE_CONNS | 10 / 2 | Sizing for the background pools (scheduler, queue drains, dispatchers, metric scrapes). |
EXPLORE_DB_JOBS_MAX_OPEN_CONNS / EXPLORE_DB_JOBS_MAX_IDLE_CONNS | 5 / 2 | Background-pool sizing for the explore database (reindex tasks). Session-timeout profiles are shared with the primary’s DB_* values. |
Background work (scheduler jobs, NOTIFY queue drains, runner dispatch, metric
scrapes) runs on its own connection pools, so a burst of background writes
cannot starve interactive requests of connections — or vice versa during a
database stall. Per-pool saturation is visible in the
gantrycd_db_connections_*{pool=...} gauges.
Connection budget: each backend replica opens up to
DB_MAX_OPEN_CONNS + DB_READ_MAX_OPEN_CONNS + 2×DB_JOBS_MAX_OPEN_CONNS
connections against Postgres (default 25+25+10+10 = 70, up from 50 before the
background pools existed; plus 40 against the explore database). With a read
replica, the write DSN carries only the write + jobs-write pools (35) and the
read DSN’s pools land on the replica. Pools connect lazily, so this is a
ceiling under load, not a boot-time allocation.
For a small, fixed replica count, size max_connections for
replicas × that ceiling with headroom. This does not scale: a horizontally
autoscaled fleet multiplies the ceiling by the max replica count, and Postgres
max_connections is memory-bound (~5–10 MB/connection), so raising it far
enough to cover a large fleet is wasteful and eventually infeasible. Past a
handful of replicas the correct answer is a transaction-pooling PgBouncer,
which multiplexes many app connections onto a small, fixed set of server
connections — then max_connections stays modest. Interactive-pool saturation
(gantrycd_db_connections_in_use{pool=~"write|read"} approaching max_open) is
the signal that the fleet has outgrown a direct-connection budget.
The session timeouts are sent as connection startup parameters, which apply
for direct connections and for a session-pooling PgBouncer. They do not
apply behind a transaction-pooling pooler (the mode you would use to scale):
startup parameters are not forwarded per transaction — PgBouncer rejects the
connection unless the parameter is in ignore_startup_parameters, and listing
it there makes the connection succeed while silently dropping the setting.
If you add a transaction pooler, set the timeouts where the connection multiplexing already lives — in the pooler’s server-connection options — which keeps the database role untouched:
# pgbouncer [databases]: one alias per timeout profile, same DB + role.
gantrycd_interactive = host=... dbname=gantrycd options='-c statement_timeout=15s -c lock_timeout=5s -c idle_in_transaction_session_timeout=60s'
gantrycd_jobs = host=... dbname=gantrycd options='-c statement_timeout=5min -c lock_timeout=1min -c idle_in_transaction_session_timeout=5min'
Point the interactive DSNs (DATABASE_URL / DATABASE_URL_READ) at the first
alias and the app applies the profile through the pooler; the background pools
have no separate DSN, so at scale give them their own via the jobs alias if you
need the split. Set the DB_*_TIMEOUT envs to 0 so the app stops sending
startup parameters that would be dropped.
Required, not optional: replace the statement timeout you just disabled. Some queries have no application-side bound and rely entirely on the session profile — the Insights sweep’s two org-wide duplicate scans are the clearest case, and they run on the background pool, which
GANTRYCD_REQUEST_TIMEOUTdoes not cover. WithDB_*_TIMEOUT=0and nothing set at the pooler or the role, those statements are unbounded. Set the timeouts in the pooler options above or per role, one role per lane:ALTER ROLE gantrycd_app SET statement_timeout = '15s'; -- request lane ALTER ROLE gantrycd_jobs SET statement_timeout = '5min'; -- background lanePer-role settings survive transaction pooling because they are applied by the server at connection time, not forwarded as startup parameters. A single
ALTER DATABASE … SET statement_timeoutalso works but collapses the interactive/background split into one value, which will either kill the sweep or leave the request path unbounded. The app-side request deadline (GANTRYCD_REQUEST_TIMEOUT) is pooler-agnostic and stays the primary control in every topology, so even with no server-side backstop the request path is bounded.
One more cutover step: LISTEN/NOTIFY needs a direct connection. The backend’s
queue listeners register a LISTEN and hold it open, which transaction pooling
breaks — it releases the session after each transaction, so the LISTEN stops
receiving events. Set DATABASE_URL_DIRECT to the direct -rw DSN: only the
listener uses it, while the query pools keep using the pooled DATABASE_URL.
Leave it unset for direct-connection or session-pooling topologies.
Server-side alternatives that also survive transaction pooling, if you prefer
not to encode timeouts in the pooler: ALTER DATABASE gantrycd SET statement_timeout = ... (one profile for all connections — it cannot express
the interactive/background split), or per-role ALTER ROLE. The pooler-options
approach above is the least stateful and the only one that keeps the split
without extra roles. (A value already present in DATABASE_URL also wins over
the DB_* defaults, so a direct-connection DSN can pin them there.)
Redis (Sentinel)
By default REDIS_URL is a single node. For a Sentinel-managed HA deployment,
point REDIS_URL at the Sentinel port and add ?sentinel_master=<name>
naming the monitored master:
REDIS_URL=redis://:password@gantrycd-redis:26379?sentinel_master=gantrycd-redis
The marker — not a startup probe — is what selects HA mode, so the backend still
starts when Redis is temporarily unreachable. In HA mode the client follows
master failover automatically and spreads read-only commands across the data
nodes (master and replicas) while writes, and the rate limiter’s Lua script, stay
on the master. The host is normally a Kubernetes Service fronting every Sentinel
pod; go-redis discovers the rest from it. Connection tuning in the URL query
(?pool_size=, ?read_timeout=, ?max_retries=, …) is honored in both
single-node and Sentinel modes.
Caution — upgrade ordering. When switching an existing deployment from a single-node
REDIS_URLto the Sentinel endpoint, roll out the new backend image first, then flipREDIS_URLto the:26379Sentinel address with thesentinel_mastermarker. A backend image that predates Sentinel support rejects the unknown query parameter and fails to construct its client, so flipping the URL first breaks rate limiting until the new image lands.
Explore database: resource index & dependency graph
The cross-stack resource explorer index and the dependency graph share the
gantrycd_explore Postgres database (EXPLORE_DATABASE_URL, plus
EXPLORE_DATABASE_URL_READ for an optional read replica).
EXPLORE_DATABASE_URL is required: the backend exits on startup without it.
Its availability is a separate matter and is best-effort — the pool connects
lazily and never pings, so an unreachable explore database does not stop the
backend booting or serving; it degrades search, insights and the dependency
view, whose background work retries. You must also apply the schema-explore
schema to that database (enable the chart’s Explore migration hook, or apply the
schema yourself). See
Dependency graph and
Datastores.
Storage
State, logs, and artifacts are three independent S3 concerns, each configured
under its own prefix (STATE_*, LOGS_*, ARTIFACTS_*). This has enough nuance
— STS, non-AWS endpoints, off-network runners — to get its own page:
Storage.
Cloud runtime providers
For cloud credentials to work, register the providers the backend may serve:
| Variable | Notes |
|---|---|
RUNTIME_PROVIDERS | Comma-separated: aws, gcp. A persisted integration is unusable if its provider isn’t registered here. |
RUNTIME_AWS_EXTERNAL_ID_REALM | Required when aws is enabled — pick prod/dev so a misconfigured trust policy can’t cross environments. |
The backend authenticates to clouds with its own identity (AWS default chain
/ EKS IRSA, GCP Application Default Credentials), optionally overridden by
RUNTIME_AWS_* so the runtime principal differs from the storage principals.
Run duration
A run’s credentials all share one budget and expire together:
| Variable | Default | Notes |
|---|---|---|
RUN_TTL | 1h | Total run budget shared by every credential a run holds. |
RUN_CREDENTIAL_GRACE_PERIOD | 10m | Shutdown grace; must be ≤ RUN_TTL/2. |
Caution: When the backend’s own AWS principal is an assumed role (EKS IRSA, instance profile), AssumeRole is capped at one hour regardless of role settings. Keep
RUN_TTL + grace ≤ 1hin that case (e.g.RUN_TTL=50m), or run the backend under a static IAM user and raise each role’sMaxSessionDuration.
Local plans
Local plans (gantrycli local-plan) upload a source
tarball before triggering a plan-only deployment:
| Variable | Default | Notes |
|---|---|---|
LOCAL_DEPLOY_UPLOAD_TTL | 15m | Reservation window (reserve → tar → upload → trigger). 15m is the STS AssumeRole floor. |
LOCAL_DEPLOY_MAX_SOURCE_BYTES | 536870912 (512 MiB) | Cap on an uploaded source tarball, enforced at trigger time. |
SSO
GitHub and Google are platform-wide and configured here; OIDC is per-org in the UI. See Single sign-on for the full setup.
| Variable | Notes |
|---|---|
GANTRYCD_SSO_GITHUB_CLIENT_ID / _SECRET | Both required to enable GitHub login. |
GANTRYCD_SSO_GOOGLE_CLIENT_ID / _SECRET | Both required to enable Google login (plus PUBLIC_BASE_URL). |
Runner-group dispatch (GitHub Actions)
| Variable | Default | Notes |
|---|---|---|
GANTRYCD_API_PUBLIC_ENDPOINT | — | Required for github-actions groups — the URL runners call back on. |
GANTRYCD_DISPATCH_MAX_PER_ORG | 20 | Cap on in-flight dispatched workflows per org; 0 disables. |
Runner side
Every runner group reads these shared variables, whatever its launcher:
| Variable | Default | Notes |
|---|---|---|
RUNNER_GROUP_ID | — (required) | The group’s ID, from the UI. |
BACKEND_URL | — (required) | Backend base URL the group polls. |
RUNNER_PRIVATE_KEY_FILE | — (required) | Path to the PEM key shown once at group creation. |
RUNNER_LABELS | — | Comma-separated extra capability labels. |
POLL_INTERVAL | 10s | How often the group polls for work. |
MAX_CONCURRENT | 1 | Max simultaneous runs the group handles. |
RUN_BUDGET | 2h | Max time one runner is tracked before its slot is reclaimed. |
METRICS_PORT | — | Listen addr (e.g. :9090) for /metrics, /healthz, /readyz; empty disables it. |
REAP_INTERVAL | 5m | How often the docker/kubernetes launchers sweep finished runners; 0 disables. |
REAP_GRACE_PERIOD | 0 | Keep a finished runner at least this long so its logs stay inspectable. |
REAP_KEEP_LATEST_FAILED | 0 | Retain this many recent failed runners for debugging. |
Per-launcher variables (image, resources, registry auth) are documented with each
launcher under Runner groups. The
ephemeral runner itself reads BACKEND_URL and a per-run EPHEMERAL_RUNNER_JWT,
both supplied by its launcher.