Skip to content

Docker Deployment

This guide covers deploying Rack Gateway using Docker, suitable for development, testing, and simple production deployments.

The official Docker image is available on Docker Hub:

Terminal window
docker pull docker.io/docspringcom/rack-gateway:latest
TagDescription
v0.x.xSpecific release (use this in production)
<short-sha>The commit a release was built from
latestMost recent release (mutable, avoid in production)

Every release image has a signed build provenance attestation generated by GitHub Actions. Verify it before deploying:

Terminal window
gh attestation verify oci://docker.io/docspringcom/rack-gateway:v0.x.x --repo DocSpring/rack-gateway
Terminal window
docker network create rack-gateway-net
Terminal window
docker run -d \
--name postgres \
--network rack-gateway-net \
-e POSTGRES_USER=rack_gateway \
-e POSTGRES_PASSWORD=your-secure-password \
-e POSTGRES_DB=rack_gateway \
-v pgdata:/var/lib/postgresql/data \
postgres:17

The audit log lives in its own schema, owned by roles the migrations expect to exist. Create them once per PostgreSQL cluster:

Terminal window
docker exec -i postgres psql -U rack_gateway -d rack_gateway <<'SQL'
CREATE ROLE audit_owner NOLOGIN;
CREATE ROLE audit_writer NOLOGIN;
CREATE ROLE audit_reader NOLOGIN;
SQL

Here rack_gateway is a superuser (the postgres image makes POSTGRES_USER one), so it can use these roles directly. With separate, non-superuser roles, grant audit_owner to the role that runs migrations, and audit_writer and audit_reader to the role the gateway connects as.

The image never migrates on startup. Run the migrations with the same configuration as the gateway:

Terminal window
docker run --rm \
--network rack-gateway-net \
-e DATABASE_URL=postgres://rack_gateway:your-secure-password@postgres:5432/rack_gateway \
-e COLUMN_ENCRYPTION_KEY=your-column-encryption-key \
docker.io/docspringcom/rack-gateway:latest ./rack-gateway-api migrate
Terminal window
docker run -d \
--name rack-gateway \
--network rack-gateway-net \
-p 8080:8080 \
-e DATABASE_URL=postgres://rack_gateway:your-secure-password@postgres:5432/rack_gateway \
-e DOMAIN=gateway.example.com \
-e APP_SECRET_KEY=$(openssl rand -base64 32) \
-e AUDIT_HMAC_SECRET=$(openssl rand -base64 32) \
-e COLUMN_ENCRYPTION_KEY=your-column-encryption-key \
-e GOOGLE_CLIENT_ID=your-client-id \
-e GOOGLE_CLIENT_SECRET=your-client-secret \
-e GOOGLE_ALLOWED_DOMAIN=example.com \
-e RACK_TOKEN=your-convox-rack-token \
-e RACK_HOST=https://your-rack.convox.cloud \
-e ADMIN_USERS=admin@example.com \
docker.io/docspringcom/rack-gateway:latest

Keep APP_SECRET_KEY, AUDIT_HMAC_SECRET and COLUMN_ENCRYPTION_KEY somewhere safe and pass the same values on every start: a new AUDIT_HMAC_SECRET breaks the audit log’s hash chain, and a new COLUMN_ENCRYPTION_KEY makes stored TOTP seeds and the Slack token unreadable. Generate the column key once with openssl rand -base64 32; see Column Encryption.

Terminal window
curl http://localhost:8080/api/v1/health

To try the published image locally with Docker Compose:

docker-compose.yml
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: rack_gateway
POSTGRES_PASSWORD: development
POSTGRES_DB: rack_gateway
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rack_gateway"]
interval: 5s
timeout: 5s
retries: 5
gateway:
image: docker.io/docspringcom/rack-gateway:latest
depends_on:
postgres:
condition: service_healthy
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://rack_gateway:development@postgres:5432/rack_gateway
DOMAIN: localhost:8080
APP_SECRET_KEY: dev-secret-key-32-bytes-minimum!!
AUDIT_HMAC_SECRET: dev-audit-secret-32-bytes-minimum
COLUMN_ENCRYPTION_KEY: ${COLUMN_ENCRYPTION_KEY}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GOOGLE_ALLOWED_DOMAIN: ${GOOGLE_ALLOWED_DOMAIN}
RACK_TOKEN: ${RACK_TOKEN}
RACK_HOST: ${RACK_HOST}
ADMIN_USERS: ${ADMIN_USERS}
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/api/v1/health"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:

The image never runs migrations on startup. Create the audit roles, apply the migrations, then start:

Terminal window
docker compose up -d postgres
docker compose exec -T postgres psql -U rack_gateway -d rack_gateway \
-c 'CREATE ROLE audit_owner NOLOGIN' -c 'CREATE ROLE audit_writer NOLOGIN' -c 'CREATE ROLE audit_reader NOLOGIN'
docker compose run --rm gateway ./rack-gateway-api migrate
docker compose up -d
VariableDescriptionExample
DATABASE_URLPostgreSQL connection stringpostgres://user:pass@host:5432/db
DOMAINGateway domaingateway.example.com
APP_SECRET_KEY256-bit secret for sessionsopenssl rand -base64 32
AUDIT_HMAC_SECRETKey for the audit log’s hash chain; never change itopenssl rand -base64 32
COLUMN_ENCRYPTION_KEYEncrypts stored TOTP seeds and the Slack token; generate once and keep itopenssl rand -base64 32
GOOGLE_CLIENT_IDOAuth client IDFrom Google Cloud Console
GOOGLE_CLIENT_SECRETOAuth client secretFrom Google Cloud Console
GOOGLE_ALLOWED_DOMAINAllowed email domainexample.com
RACK_TOKENConvox rack API tokenFrom Convox
RACK_HOSTConvox rack URLhttps://rack.convox.cloud
VariableDefaultDescription
PORT8080HTTP listen port
ADMIN_USERS-Comma-separated admin emails
RACK_ALIAS-Short rack name (e.g., staging)

See Environment Variables for the complete list.

The image never migrates on startup: run migrate with the new image before starting it, after every upgrade.

Terminal window
docker run --rm --network rack-gateway-net -e DATABASE_URL=... -e COLUMN_ENCRYPTION_KEY=... \
docker.io/docspringcom/rack-gateway:<version> ./rack-gateway-api migrate

The gateway exposes health endpoints:

EndpointPurpose
/api/v1/healthBasic health check

Example health check configuration:

healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
Terminal window
docker pull docker.io/docspringcom/rack-gateway:vX.Y.Z
services:
gateway:
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M

For production, run behind a reverse proxy with TLS:

server {
listen 443 ssl http2;
server_name gateway.example.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# Build uploads stream through the gateway: don't cap the body size here
client_max_body_size 0;
location / {
proxy_pass http://gateway:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# exec, run and logs use WebSockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
proxy_request_buffering off;
}
}

Behind any reverse proxy, set TRUSTED_PROXY_CIDRS to the proxy’s addresses (for example the Docker network’s subnet). The gateway only believes X-Forwarded-For from those, so without it the audit log, rate limits and security emails all see the proxy’s IP. The proxy must pass WebSocket upgrades through and must not limit request body sizes (the gateway caps API bodies itself and streams build archives).

Configure regular PostgreSQL backups:

Terminal window
# Add to cron
0 * * * * docker exec postgres pg_dump -U rack_gateway rack_gateway | gzip > /backups/gateway-$(date +\%Y\%m\%d\%H).sql.gz

Configure Docker logging driver for production:

services:
gateway:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

Or use a centralized logging solution:

services:
gateway:
logging:
driver: "awslogs"
options:
awslogs-group: "rack-gateway"
awslogs-region: "us-east-1"
awslogs-stream: "gateway"

The published image is a release build: it refuses to start with DEV_MODE, E2E_TEST_MODE or any other test bypass set, and ./rack-gateway-api build-info reports test bypasses: compiled out.

Development mode (non-secure cookies for HTTP, relaxed security checks, migrations on startup, email logging) needs a build from source with the testbypass Go build tag. task dev and task docker:up build one, or pass --build-arg GO_BUILD_TAGS=testbypass to docker build. Never deploy such a build.

Check logs:

Terminal window
docker logs rack-gateway

Common issues:

ErrorCauseSolution
failed to ping postgresPostgreSQL not reachableCheck DATABASE_URL and network
APP_SECRET_KEY is required in productionMissing secretGenerate with openssl rand -base64 32
AUDIT_HMAC_SECRET must be set in non-dev environmentsMissing secretGenerate with openssl rand -base64 32 and never change it
COLUMN_ENCRYPTION_KEY is requiredMissing keyGenerate once with openssl rand -base64 32 and keep it
DOMAIN is required in production / GOOGLE_ALLOWED_DOMAIN is required in productionMissing configSet them

Verify PostgreSQL is accessible:

Terminal window
docker exec -it postgres psql -U rack_gateway -d rack_gateway -c "SELECT 1"
Terminal window
docker ps -a
docker network inspect rack-gateway-net