Skip to content

Docker Deployment ​

This guide shows how to deploy the Grant Platform using Docker Compose: from a single-instance stack to a production-style setup with API replicas and a load balancer. The flow is intentionally short and focused, similar to Umami’s deployment docs.

Compose files vs env files ​

  • Compose files = infrastructure topology (which services run, replicas, tmpfs, networks).
  • Env files = configuration (passwords, ports, DB names, URLs). Keep config in .env.<env>; do not duplicate it in YAML.
Compose fileEnv fileUse case
docker-compose.yml.env (or --env-file .env.demo for a demo-style run)Default full stack
docker-compose.demo.yml.env.demoDemo (gateway, host Postgres and Redis)
docker-compose.e2e.yml.env.testE2E tests (minimal stack, ephemeral storage)

Compose resolves ${VAR} in the YAML at parse time from the env passed to the process (e.g. docker compose --env-file .env.test up). Always pass --env-file .env.<env> when using a non-default env file so interpolation works. A minimal .env in the repo root (e.g. COMPOSE_PROJECT_NAME=grant) is kept for Compose and tooling; full config lives in .env.test, .env.demo, etc.

1. Choose your domains and ports ​

Decide how users will reach the platform:

ComponentDefault container portTypical public URL
Web (Next.js)3000https://grant.yourdomain.com
API4000https://grant.yourdomain.com/api/
Docs (optional)8080 (container) → 5173 (host)https://grant.yourdomain.com/docs/
Example app (optional)3000 (container) → 3004 (host)https://grant.yourdomain.com/example/

You can keep the default ports from docker-compose.yml and put a reverse proxy (nginx, Traefik, load balancer, ingress) in front, or expose ports directly during initial testing.

2. Environment: build vs runtime ​

  • env_file — The compose file sets env_file: .env (or .env.demo) on services. Those vars are injected into the container at runtime only; they are not available during docker build.
  • Build args — We do not pass URLs or demo flags as build args for web, docs, or example-nextjs. Images are built once and are deployment-agnostic; each deployment sets APP_URL (and optionally SECURITY_FRONTEND_URL) in its env file, and the containers read them at startup.
  • Compose interpolation — When you run docker compose up, Compose reads your env file and substitutes ${VAR} in the YAML (e.g. in environment: or image names). So the same compose file works for any domain.

3. Create .env from the template ​

From the repo root:

bash
cp .env.example .env

Update at least:

  • Secrets — POSTGRES_PASSWORD, REDIS_PASSWORD (e.g. openssl rand -base64 32)
  • Public URLs — APP_URL (single canonical URL; frontends get full config at runtime from API GET /api/config)
  • CORS — SECURITY_FRONTEND_URL (match APP_URL), SECURITY_ADDITIONAL_ORIGINS
  • System user — SYSTEM_USER_ID (must match seed)
  • Demo mode — DEMO_MODE_ENABLED=false unless you want a resettable sandbox

For the demo stack, copy cp .env.demo.example .env.demo and edit (e.g. with the Config app set to Environment: Demo). See Environment setup for the full list and how build vs runtime env works.

4. Start the stack with Docker Compose ​

From the repo root:

bash
docker compose up -d

This uses docker-compose.yml to start:

  • PostgreSQL, Redis, PgAdmin, Prometheus, Grafana, Jaeger, Umami
  • Grant API (api)
  • Grant web app (web)
  • Docs (docs)
  • Example Next.js app (example-nextjs)

Default host ports:

  • 3000 → web
  • 4000 → API
  • 5173 → docs
  • 3004 → example app

Postgres and Redis are only reachable on the internal Docker network; the API connects to them via postgres / redis hostnames.

5. Run migrations and seed data ​

With the stack running, apply migrations and seed:

bash
docker compose run --rm api pnpm run db:migrate
docker compose run --rm api pnpm run db:seed

After this, you can open the web app and complete onboarding.

For production, terminate TLS and route traffic through a reverse proxy or load balancer:

  • Route /, /auth/**, etc. → web (web:3000)
  • Route /api/**, /graphql, /health, /api-docs, /storage/** → api (api:4000)
  • Optionally route docs.yourdomain.com → docs (docs:8080)

You can use nginx, Traefik, Caddy, or your cloud’s load balancer / ingress. Reuse the URLs you configured in .env to keep CORS and redirects consistent. For a closed install with origin-verify, see Closed install: do not publish the API port; either let Next inject x-origin-verify or add proxy_set_header on API locations.

For a single canonical APP_URL (e.g. https://demo.grantjs.org) that routes to api, web, docs, and the example app by path, see the sample docs/deployment/nginx-gateway.conf.example in the repo. Copy and adapt server_name, upstream ports, and SSL paths for your host; it is not required for deployment.

Closed install: origin verify and public signup ​

A company can run this stack on the public internet without a VPN. Two independent controls (full reference: Configuration):

VariableClosed-install valueRole
AUTH_PUBLIC_SIGNUP_ENABLEDfalseFirst human may self-register; after that, platform register and GitHub/Google new users are refused. Invitations and Project App allowSignUp stay.
ORIGIN_VERIFY_SECRETa long random secretShared by API and the Next.js server. Never NEXT_PUBLIC_*.
SECURITY_ORIGIN_VERIFY_REQUIREDtrueMissing secret refuses every API request (fail closed).

SECURITY_API_KEY is unused and is not this gate.

Who attaches x-origin-verify: Next.js server proxy.ts (rewrites cannot add headers). Publish only the web origin. Do not publish API_PORT to the host when the gate is on — remove or comment the api.ports mapping in docker-compose.yml so the API stays on the Compose network.

Set APP_URL and SECURITY_FRONTEND_URL to that public web URL so OAuth callbacks and the CLI go through Next. The web service reads ORIGIN_VERIFY_SECRET from .env at runtime (env_file); the secret never ships in the image.

If a reverse proxy does route /api and /graphql straight to the API (as nginx-gateway.conf.example does), that proxy is the front: add proxy_set_header x-origin-verify … on those locations. Then Next does not need the secret. Direct curl against the unpublished API still must send the header; curl against the web origin must not.

7. Demo stack: gateway and host database ​

The docker-compose.demo.yml file is the stack used to run demo.grantjs.org:

  • deploy.replicas: 1 for the API. Docker Compose v2 honors this natively; --scale api=N raises it when clustering needs a live check
  • A single nginx gateway container that handles path-based routing and, when more than one API replica is running, round-robins across them via Docker's embedded DNS
  • Postgres and Redis on the host. The API reaches them through host.docker.internal
  • Database bootstrap (migrations + core seed) runs automatically on API startup, serialized with a PostgreSQL advisory lock when more than one replica starts

7.1 Prepare env and deploy ​

bash
cp .env.demo.example .env.demo   # or cp .env.example .env.demo, then adjust for demo
./scripts/stack-deploy.sh up     # builds images and starts all services

This script wraps docker compose with the demo compose file and env:

  • up — build + start (detached)
  • update — rebuild + force-recreate changed services
  • down — tear down containers and network
  • down -v — tear down and remove the API storage volume (the host database is untouched)
  • logs — tail API logs

You can override defaults:

bash
ENV_FILE=.env.demo \
COMPOSE_FILE=docker-compose.demo.yml \
  ./scripts/stack-deploy.sh up

Or use docker compose directly:

bash
docker compose -f docker-compose.demo.yml --env-file .env.demo up -d --build

In CI you can run the same script after providing .env.demo (or equivalent env) to the runner.

7.2 Database bootstrap ​

Migrations and core seeding happen automatically when the API starts (bootstrapDatabase in server.ts). A PostgreSQL advisory lock prevents two replicas from running migrations concurrently — one acquires the lock, runs migrations + seed, releases it; the other waits, then finds nothing to do (everything is idempotent).

No manual migrate/seed step is needed for the demo stack.

7.3 Rolling updates ​

After building or pulling new images:

bash
./scripts/stack-deploy.sh update
# or: docker compose -f docker-compose.demo.yml --env-file .env.demo up -d --build --force-recreate

Compose will recreate containers with the new images. The restart: unless-stopped policy handles recovery from transient failures.

8. Architecture diagrams ​

Single host with Docker Compose ​

Demo stack with gateway ​

9. E2E tests (docker-compose.e2e.yml) ​

E2E uses a minimal stack (Postgres, Redis, API only) and .env.test as the single config source:

  1. Copy the template: cp .env.test.example .env.test (or run ./scripts/e2e.sh once; it creates .env.test if missing).
  2. Start the stack with the env file so Compose interpolation works:
    docker compose -f docker-compose.e2e.yml --env-file .env.test up -d
    Or use the helper: ./scripts/e2e.sh --up (then ./scripts/e2e.sh --test to run tests; ./scripts/e2e.sh --down to tear down).

The test runner (Vitest) and the API container both use .env.test (host-side E2E vars like E2E_API_BASE_URL, E2E_DB_URL come from the same file).

10. Common operations ​

Optional: For local-only overrides (e.g. port changes, extra env), add docker-compose.override.yml; Compose loads it automatically. Add it to .gitignore if the team wants it to stay local.

Status

bash
docker compose ps
# Demo stack: docker compose -f docker-compose.demo.yml --env-file .env.demo ps

Logs

bash
docker compose logs -f api
# Demo stack: ./scripts/stack-deploy.sh logs

Stop

bash
docker compose down
# Add -v to remove volumes (DELETES database data)

Scale API

The demo stack uses deploy.replicas: 1 by default. To override:

bash
docker compose -f docker-compose.demo.yml --env-file .env.demo up -d --scale api=3

11. Data and volumes ​

Docker named volumes are used for:

  • PostgreSQL data: postgres_data
  • Redis data: redis_data
  • API storage: api_storage
  • Observability: prometheus_data, grafana_data, umami_data

Back them up like any Docker volume:

  • PostgreSQL: docker exec <postgres-container> pg_dump -U grant_user grant_db > backup.sql
  • API storage: back up the mounted volume path if you store uploads there

12. Pipelines and CI/CD ​

On push to main, three workflows run independently:

  • ci.yml — Lint, build, test.
  • deploy.yml — Build and push four images (grant-api, grant-web, grant-docs, example-nextjs) to GHCR with tags :demo and :demo-$sha; optionally deploy the demo stack via SSH.
  • release.yml — Version and publish npm packages via Changesets when the "chore: version packages" PR is merged.

Release surfaces ​

ArtifactTriggerVersioning
npm packagesChangesets (merge "Version packages" PR)semver
Docker imagespush to main:demo, :demo-$sha
Demo environmentdeploy workflowlatest main
Future releasesversion PR mergesemver images (e.g. :1.4.0)

GHCR authentication on the server ​

For the demo deploy job (or any pull from GitHub Container Registry), the server must authenticate to GHCR or pulls will fail. One-time setup on the server:

bash
docker login ghcr.io

Use a GitHub PAT (Personal Access Token) with the read:packages scope. Document this in your runbook; without it, the deploy step can silently fail.

Deploy job (optional) ​

To enable the deploy step in deploy.yml, set the repository variable DEMO_DEPLOY_ENABLED to true and configure secrets: SSH_HOST, SSH_USER, SSH_PRIVATE_KEY, SSH_DEPLOY_PATH (path on the server to the repo or directory containing docker-compose.demo.yml, .env.demo, and scripts/stack-deploy.sh). Optionally SSH_PORT. The job SSHs to the server and runs ./scripts/stack-deploy.sh up, which builds images and runs docker compose -f docker-compose.demo.yml --env-file .env.demo up -d.

Released under the MIT License.