A few settings switch off or redirect a security control so the gateway can run against local mocks in development
and in the end-to-end tests:
Variable
What it does
DEV_MODE=true
Relaxes cookies, CSP, origin checks and secret requirements, and runs migrations on startup
E2E_TEST_MODE=true
Skips WebAuthn assertion checks
COOKIE_SECURE=false
Leaves the Secure attribute off cookies
AWS_ENDPOINT_URL
Sends every AWS call (S3 audit anchors, STS, KMS) to another endpoint
AWS_ENDPOINT_URL_S3
Sends audit anchors to another S3 endpoint
AWS_ENDPOINT_URL_STS, AWS_ENDPOINT_URL_KMS
Sends AWS credential exchange or key operations to another endpoint
POSTMARK_API_BASE
Sends email and the Postmark token to another server
GOOGLE_OAUTH_BASE_URL without https://
Accepts identity tokens over plain HTTP
COLUMN_ENCRYPTION_KEY set to the development key
The key is published in the repository (mise.toml), so anyone could decrypt stored secrets
APP_SECRET_KEY or AUDIT_HMAC_SECRET set to a development value, or shorter than 32 bytes
The development values are published in the repository, so anyone could forge sessions or recompute audit hashes, and a short secret can be guessed. The server refuses them when it starts
They only work in a build compiled with the testbypass Go build tag, which the development and E2E stacks use. The
published Docker image is built without it, so it can’t honour these settings: a release build refuses to start while any of them is set, and the error names each one. Remove them from the environment and
restart. rack-gateway-api build-info shows whether a binary has the bypasses compiled in.
A testbypass build refuses them too when its database is marked production. The first time a gateway starts
against a database, it marks the database as production, or development in a testbypass build with
DEV_MODE=true.
DOMAIN, APP_SECRET_KEY, GOOGLE_ALLOWED_DOMAIN and AUDIT_HMAC_SECRET are required whenever DEV_MODE is off.
The gateway’s host name, e.g. gateway.example.com. It sets the public URL (https://<DOMAIN>) used for OAuth redirects, links in emails and Slack, and the WebAuthn origin, and requests for any other Host or Origin are refused
DEV_MODE
false
Development mode (testbypass builds only, see Test Bypasses)
APP_SECRET_KEY
(required)
Secret for sessions and CSRF: at least 32 bytes (openssl rand -base64 32)
COLUMN_ENCRYPTION_KEY
(required)
Key that encrypts stored secrets: standard base64 of exactly 32 random bytes (openssl rand -base64 32). See Column Encryption
COOKIE_SECURE
true
false leaves the Secure attribute off cookies, for plain-HTTP stacks (testbypass builds only). Release builds always set it
TRUSTED_PROXY_CIDRS
Comma-separated CIDRs of the reverse proxies in front of the gateway (e.g. an ingress). X-Forwarded-For is only believed from these, and the client IP is the right-most address that isn’t one of them. Leave empty when clients connect directly; set it whenever there is a proxy, or audit logs, rate limits and security emails all see the proxy’s address
LOG_LEVEL
info
Log level (debug, info, warn, error)
DEBUG_TOPICS
Comma-separated debug topics
RATE_LIMIT_RPS
10
Requests per second allowed from one client IP on the sign-in, MFA and API token routes. An IPv6 client is limited by its /64. The gateway keeps a limit for up to 50,000 clients at once; past that, new clients share one
RATE_LIMIT_BURST
20
Burst size for the per-IP rate limit
AUTH_FAILURE_LIMIT_BURST
30
Failed sign-ins (refused session or API tokens) one client IP can make before its requests are refused with 429 until some come back. Requests without credentials don’t count. A session or API token that signed in from anywhere in the last 24 hours, or a session just created, isn’t refused for its IP’s failures: someone else behind the same NAT or proxy may have made them. It has the same limit of its own instead, which also caps how many requests with it are checked at once
AUTH_FAILURE_LIMIT_PER_MINUTE
30
How many failed attempts come back each minute
DEPLOY_APPROVAL_LIMIT_BURST
20
Deploy approval requests one user can create, themselves or with any of their API tokens, before they’re refused with 429 until some come back. Each can cost several GitHub API calls on GITHUB_TOKEN. A request that repeats an open one is answered with it, and doesn’t count. Every attempt, repeats and refusals included, counts against a limit three times as large, checked before anything else
DEPLOY_APPROVAL_LIMIT_PER_HOUR
60
How many deploy approval requests come back each hour
COLUMN_ENCRYPTION_KEY encrypts the secrets the gateway stores in its database: TOTP seeds and YubiKey
public IDs (mfa_methods.secret) and the Slack bot token. Values are encrypted with AES-256-GCM and bound
to where they belong (the user and MFA method type, or the Slack workspace), so a value copied to another
user’s row, or to a method of another type, can’t be decrypted there. A plaintext value is never used: reading
one is an error, as for a value that doesn’t decrypt.
Generate it once with openssl rand -base64 32 and store it with your other secrets. Use a
different key per environment. It is separate from APP_SECRET_KEY, so rotating the session secret
doesn’t affect stored secrets.
It is required in every mode. The server and every maintenance command that opens the database
(migrate, reset-db, write-anchor) refuse to run without a valid key, so a missing key fails a
deploy before the new release is promoted. Development setups use a published key from mise.toml and
docker-compose.yml. The published image refuses to start with that key, and so does any build against a
production database.
Back it up. If the key is lost or changed, stored TOTP seeds, YubiKey public IDs and the Slack
token can’t be read. Those MFA methods stop working (WebAuthn security keys and backup codes still
work): users sign in with another factor or a backup code, remove the broken method and enroll again.
Codes from an authenticator app that can’t be used get a message saying so, and don’t count toward the
automatic account lock.
Slack must be reconnected. The gateway logs (and reports to Sentry, if configured) every value it fails
to decrypt when it tries to use it.
Upgrading from before v0.1.7 (plaintext TOTP seeds, base64 Slack tokens): deploy a release from v0.1.7 to
v0.1.10 first. Those releases encrypt the existing secrets when they start; later releases don’t, and can’t
read secrets stored before column encryption.
Rotation isn’t supported yet. Every encrypted value records which key encrypted it, so rotation can
be added later without changing the stored format.
Every start creates the listed users that don’t exist yet, with the role of each list that names them. Existing
users are never changed, so a role you change in the web UI stays changed; to take a seeded user’s access away, lock
or delete them in the web UI and remove them from the list. Each user it creates is recorded in the audit log as
user.create by system, in the same transaction, and the gateway doesn’t start if that record can’t be written.
Convox rack API URL. Inside a Convox v3 rack it can be left unset: the gateway then uses https://api.<RACK>-system.svc.cluster.local:5443
RACK_TOKEN
Convox rack API token
RACK_USERNAME
convox
Basic auth username
RACK
default
Rack name (internal identifier). Convox sets it for apps running on a rack
RACK_ALIAS
RACK
Short alias (user-facing). Authenticator apps show TOTP entries as Rack Gateway (<RACK_ALIAS>)
RACK_DISPLAY_NAME
RACK_ALIAS
Human-friendly display name; every email subject and Slack message starts with Rack Gateway (<name>)
ENABLE_RACK_TLS_PINNING
false
Pin the rack API’s self-signed certificate on first use. Off by default: the Convox rack API generates a new certificate every time it restarts, so a pinned certificate stops matching after each rack update
Settings resolve in this order: env var → DB value → default.
An environment variable pins its setting: the web UI shows it read-only, and the API refuses to change it
(409 Conflict). Change it in the deployment config instead. Values are parsed strictly by the setting’s type:
Booleans: true or false
Numbers: whole numbers from 1 to the setting’s maximum: 1440 (a day) for session_timeout_minutes and
mfa_step_up_window_minutes, 365 for mfa_trusted_device_ttl_days, and 10080 (a week) for
deploy_approval_window_minutes
Lists: a JSON array of strings, or a comma-separated list (use the JSON form when a value contains a comma,
e.g. an approved deploy command). A quoted value such as "A,B" (what jsonencode writes for a string) is
refused: use jsonencode on a list. The env var lists (protected_env_vars, secret_env_vars,
audit_visible_env_vars) must hold valid env var names, here and when saved through the API.
Maps (service_image_patterns): a JSON object of strings
Strings: the plain value, or a JSON string ("main", as Terraform’s jsonencode writes it). Other quoting is
refused. verify_git_commit_mode must be latest or branch.
Every value can be JSON-encoded, so a deployment tool can write jsonencode(value) for all of them.
The API checks the values it saves the same way, by JSON type: a boolean sent as the string "true", or a number
that’s fractional or out of range, is refused with 400 Bad Request. A string setting also takes null, which
reads as its default. Each settings group (a card in the web UI) is saved in one transaction, so a save changes all
of its settings or none. A database value saved before these checks that doesn’t fit its setting is an error, not
some other value: the gateway refuses to start with an invalid MFA setting, and the other settings fail safe (the
default session timeout, deploy approvals required, destructive actions refused). Save a new value, delete it, or
set its environment variable.
The gateway refuses to start when a RGW_SETTING_* or RGW_APP_*_SETTING_* variable names an unknown setting,
isn’t spelled the way the gateway reads it (upper case, with dashes in app names written as underscores), or has a
value that doesn’t fit its type. A database value that an environment variable overrides has no effect: the
startup log lists them, and the settings page marks them and lets an admin clear them.
HMAC chain secret: at least 32 bytes (openssl rand -base64 32). Without it (DEV_MODE only) the gateway doesn’t verify its audit log.
AUDIT_ANCHOR_BUCKET
S3 bucket for anchors. Unset, no anchors are written
AUDIT_ANCHOR_CHAIN_ID
Chain identifier, the anchors’ key prefix. Required with AUDIT_ANCHOR_BUCKET
AUDIT_ANCHOR_RETENTION_DAYS
400
The least the anchor bucket’s default Object Lock retention may be. The bucket’s rule locks the anchors; the gateway checks it at startup and in every verification, and refuses to start (verification fails) unless the bucket locks new objects in COMPLIANCE mode for at least this many days. Set it to the bucket’s retention. Needs s3:GetBucketObjectLockConfiguration
AUDIT_ANCHOR_INTERVAL_MINUTES
60
Anchor interval
AUDIT_HASH_V2_FROM
Chain index where hash v2 starts, recorded after upgrading, for the hourly verification and verify-audit (see Verification). The gateway refuses to start when it isn’t a chain index.
AWS_ENDPOINT_URL_S3
S3 endpoint override, e.g. MinIO (testbypass builds only)
The anchor writer uses the AWS SDK’s default credential chain: on Convox, the app’s IAM role (the
Terraform module creates it), or AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY and AWS_REGION elsewhere.
Heartbeat monitors (Cronitor, Healthchecks.io and the like) alert when the gateway stops requesting a URL on
schedule. See Heartbeats. The URLs must be https; they usually carry the
monitoring service’s key, so set them as secrets. An invalid value stops the gateway from starting.
Variable
Default
Description
UPTIME_HEARTBEAT_URL
Requested when the gateway starts and every UPTIME_HEARTBEAT_INTERVAL_MINUTES while it’s healthy
Comma-separated env vars treated as secrets in every app: masked, changed only with convox:secret:set, and never shown in audit logs
RGW_APP_<APP>_SETTING_AUDIT_VISIBLE_ENV_VARS
The app’s env vars whose values audit logs and Slack show (audit_visible_env_vars). Every other value is [REDACTED], and secret and protected keys always are