Skip to content
GantryCD

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

VariableDefaultNotes
DATABASE_URL— (required)Primary Postgres connection (writes). See High availability.
DATABASE_URL_READfalls back to DATABASE_URLOptional read replica (reads). See High availability.
DATABASE_URL_DIRECTfalls back to DATABASE_URLDirect-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.
PORT8080HTTP API.
METRICS_PORT9090Prometheus metrics.
LOG_LEVELinfo
CORS_ORIGINShttp://localhost:5173Comma-separated; must include the web origin.
SESSION_COOKIE_SECUREsecureSet false only for plain-HTTP local dev.
GANTRYCD_BACKEND_PUBLIC_BASE_URLPublic origin. Required for Google/OIDC SSO and for webhook URLs.
GANTRYCD_DATA_ENCRYPTION_PROVIDERRequired. At-rest encryption provider: aes-256 or aws-kms-aes-256.
GANTRYCD_DATA_ENCRYPTION_AES_256_KEY_B64Required when provider is aes-256. Base64 32-byte AES key. Long-lived — changing it breaks existing rows.
GANTRYCD_DATA_ENCRYPTION_AWS_KMS_KEY_IDRequired when provider is aws-kms-aes-256. CMK ARN, key id, or alias.
GANTRYCD_DATA_ENCRYPTION_AWS_KMS_REGIONAWS_REGIONKMS region for aws-kms-aes-256.

Rate limiting

VariableDefaultNotes
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:

VariablePoint it atWhy
DATABASE_URLthe read-write service (e.g. CloudNativePG -rw)Follows the primary across failover; all writes and the LISTEN/NOTIFY listener require the primary.
DATABASE_URL_READthe 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:

VariableDefaultNotes
GANTRYCD_REQUEST_TIMEOUT30sPer-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_TIMEOUT15s / 5s / 60sServer-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_TIMEOUT5m / 1m / 5mSame, 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_CONNS10 / 2Sizing for the background pools (scheduler, queue drains, dispatchers, metric scrapes).
EXPLORE_DB_JOBS_MAX_OPEN_CONNS / EXPLORE_DB_JOBS_MAX_IDLE_CONNS5 / 2Background-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_TIMEOUT does not cover. With DB_*_TIMEOUT=0 and 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 lane

Per-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_timeout also 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_URL to the Sentinel endpoint, roll out the new backend image first, then flip REDIS_URL to the :26379 Sentinel address with the sentinel_master marker. 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:

VariableNotes
RUNTIME_PROVIDERSComma-separated: aws, gcp. A persisted integration is unusable if its provider isn’t registered here.
RUNTIME_AWS_EXTERNAL_ID_REALMRequired 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:

VariableDefaultNotes
RUN_TTL1hTotal run budget shared by every credential a run holds.
RUN_CREDENTIAL_GRACE_PERIOD10mShutdown 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 ≤ 1h in that case (e.g. RUN_TTL=50m), or run the backend under a static IAM user and raise each role’s MaxSessionDuration.

Local plans

Local plans (gantrycli local-plan) upload a source tarball before triggering a plan-only deployment:

VariableDefaultNotes
LOCAL_DEPLOY_UPLOAD_TTL15mReservation window (reserve → tar → upload → trigger). 15m is the STS AssumeRole floor.
LOCAL_DEPLOY_MAX_SOURCE_BYTES536870912 (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.

VariableNotes
GANTRYCD_SSO_GITHUB_CLIENT_ID / _SECRETBoth required to enable GitHub login.
GANTRYCD_SSO_GOOGLE_CLIENT_ID / _SECRETBoth required to enable Google login (plus PUBLIC_BASE_URL).

Runner-group dispatch (GitHub Actions)

VariableDefaultNotes
GANTRYCD_API_PUBLIC_ENDPOINTRequired for github-actions groups — the URL runners call back on.
GANTRYCD_DISPATCH_MAX_PER_ORG20Cap on in-flight dispatched workflows per org; 0 disables.

Runner side

Every runner group reads these shared variables, whatever its launcher:

VariableDefaultNotes
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_LABELSComma-separated extra capability labels.
POLL_INTERVAL10sHow often the group polls for work.
MAX_CONCURRENT1Max simultaneous runs the group handles.
RUN_BUDGET2hMax time one runner is tracked before its slot is reclaimed.
METRICS_PORTListen addr (e.g. :9090) for /metrics, /healthz, /readyz; empty disables it.
REAP_INTERVAL5mHow often the docker/kubernetes launchers sweep finished runners; 0 disables.
REAP_GRACE_PERIOD0Keep a finished runner at least this long so its logs stay inspectable.
REAP_KEEP_LATEST_FAILED0Retain 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.