Docker Compose infrastructure for openmentor.io: a Next.js frontend, a Go API, a Go background worker, and a Grafana Cloud observability pipeline — all running on a single Hetzner Cloud VM.
- Architecture Overview
- Services
- Repository Layout
- Quick Start (Local Development)
- Environment Variables
- Production Deployment
- CI/CD Pipeline
- Monitoring & Observability
- Backups
- Known TODOs
- Security Considerations
One active provider (Hetzner), passive AWS/Cloudflare accounts
(see ../docs/migration/05-infrastructure.md and DECISIONS.md D1/D2/D6/D15):
Hetzner VM (only thing operated day-to-day)
└── docker compose: traefik / docker-socket-proxy / frontend / backend(api)
/ worker / migrate / postgres / postgres-backup
/ alloy / cadvisor
AWS (passive, one account): S3 = profile images (D15) · SES = email (D1)
Cloudflare (passive): DNS
AWS ECR (passive, D19): container images (`${ECR_REGISTRY}` in compose)
Grafana Cloud (free tier): metrics / logs / traces / profiles
Request flow:
Internet (HTTPS)
│
Traefik :80/:443 ── Let's Encrypt (Cloudflare DNS-01), HTTP→HTTPS redirect
│
└── ${DOMAIN}, www.${DOMAIN} → frontend :3000 (Next.js)
│
frontend ── internal Docker network ──> backend (Go API)
│ │
│ fire-and-forget triggers
▼ ▼
PostgreSQL worker :8090 (Go)
/jobs/* + daily crons
alloy :12345 scrapes frontend/backend/worker/cadvisor and tails their logs,
shipping everything to Grafana Cloud.
Scale seams: DATABASE_URL → managed PG; VM resize; the worker container
scales/deploys independently of the API; S3/SES scale on their own.
| Service | Image | Exposure | Purpose |
|---|---|---|---|
traefik |
traefik:v3.7 | :80/:443 public | TLS termination (Let's Encrypt via Cloudflare DNS-01), routing, global :80 -> :443 redirect (entrypoint-level). Dev overlay: HTTP-only on :80, no redirect. Reads the Docker API through docker-socket-proxy, never the socket (H7) |
docker-socket-proxy |
tecnativa/docker-socket-proxy | internal, openmentor-docker-api network only |
The filtered Docker API Traefik's provider reads: GET /version, /_ping, /containers/* and /events, everything else 403 — including every POST, so a Traefik RCE cannot create a container (H7). Traefik 404s everything while this is down, so it is restart: always with a healthcheck. docs/runbooks/container-hardening.md |
frontend |
openmentor-frontend | via Traefik | Next.js web app |
backend |
openmentor-backend | internal only | Go REST API (/app/main) |
worker |
openmentor-backend (same image, /app/worker) |
internal :8090 | Async event triggers from the API (/jobs/*, X-Worker-Token auth) + daily cron jobs. Replaces the deprecated openmentor-func Azure Functions app (D6) |
migrate |
openmentor-backend (/app/migrate) |
— | Runs DB migrations once before backend/worker start |
postgres |
postgres:16.14-alpine | internal only (no published ports) | Production database (DECISIONS D2). Data in the external volume openmentor-postgres-data (survives compose down -v; created by deploy scripts). Admin access via docker exec -it openmentor-postgres psql. Dev overlay overrides it with dev creds + host :5433 |
postgres-backup |
built from postgres-backup/ |
internal only | Nightly pg_dump -Fc of the database at BACKUP_TIME (UTC) → S3 (BACKUP_S3_BUCKET) with BACKUP_RETENTION_DAYS pruning; local openmentor-postgres-backups volume fallback. Disabled in the dev overlay |
alloy |
grafana/alloy | internal :12345 | Metrics scraping, log tailing, OTLP traces, Pyroscope profiles → Grafana Cloud |
cadvisor |
cadvisor | internal | Container resource metrics. Keeps docker.sock + containerd.sock (its docker factory needs both), but no longer mounts the host root filesystem (H7) |
infra/
├── docker-compose.yml # Production stack
├── docker-compose.dev.yml # Dev overlay (local image tags, HTTP-only traefik, dev postgres creds, opt-in observability)
├── Makefile # `make check` — the fast infra checks the required CI gate runs
├── check-service-env.sh # Per-service env allowlist + secret-ownership check (P10)
├── env-allowlist.txt # The allowlist it enforces (machine-checked half of the env contract)
├── db-identity-test.sh # Applies ../api/migrations to a throwaway DB and asserts the
│ # per-service Postgres roles stay least-privilege (H8)
├── postgres-backup/ # Backup sidecar image (pg_dump → S3, see Backups) + its tests
├── .env.example # Local development env template
├── .env.production.example # Production env template (deploy creds + build args + runtime secrets)
├── deploy.sh # Deploy [frontend|backend|infra|all] to the VM (health checks + auto-rollback)
├── deploy-remote.sh # The remote deploy logic that runs ON the VM — single source shared
│ # by deploy.sh and the CI workflow (both pipe it over ssh stdin)
├── deploy-dev.sh # Same CLI/flow against the local docker stack
├── rollback.sh # Roll production back to previous image tags (per service)
├── alloy/config.alloy # Grafana Alloy pipeline
├── posthog/dashboards/ # Product analytics dashboards as code
├── migration/ # One-off Yandex Object Storage → AWS S3 image copy (D15)
├── DEPLOYMENT.md # Production deployment guide
├── ENVIRONMENT_VARIABLES.md # Env file layering explained
├── DOCKER_TAG_POLICY.md # Why images are tagged with commit SHAs
└── docs/troubleshooting.md # Operational troubleshooting
The sibling monorepo directories ../web (frontend) and ../api (Go API +
worker + migrations) are used for local builds — a single clone of the
monorepo brings everything.
- Docker 20.10+ with Compose 2.x
- The monorepo cloned (one repo contains everything):
git clone https://github.com/openmentor-io/openmentor.git
openmentor/
├── web/ # frontend
├── api/ # backend + worker
└── infra/ # this directory
cd openmentor/infra
./deploy-dev.sh all --yesdeploy-dev.sh has the same CLI and flow as the production deploy.sh
(targets frontend/backend/infra/all, default frontend backend;
options --tag, --yes, --dry-run), but targets the local docker daemon:
- creates
.envfrom.env.exampleon first run (dev defaults + generatedJWT_SECRET/WORKER_AUTH_TOKEN; fill in S3/SES/PostHog values for full functionality — never commit it), - builds
openmentor-frontend:dev-<sha>/openmentor-backend:dev-<sha>from../weband../api(real unique tags, sodocker compose up -dconverges exactly like production: only services whose tag changed are recreated), - writes the tags to
.env(FRONTEND_IMAGE_TAG/BACKEND_IMAGE_TAG), - converges the stack and runs the same health checks as production
(frontend, backend, worker, postgres), rolling
.envback to the previous tags on failure.
The dev overlay (docker-compose.dev.yml) keeps the same service set as
production — traefik / frontend / backend / worker / migrate / postgres —
with these differences:
traefikruns HTTP-only on :80 (no ACME/Cloudflare DNS-01, no :443) and routesHost(localhost)to the frontend. Production's entrypoint-level HTTP→HTTPS redirect is deliberately absent (nothing to redirect to), as are thesec-headers/edge-ratelimitmiddlewares — HSTS onlocalhostwould pin the browser tohttps://localhostand break local dev until cleared by hand. Note the dev overlay replaces traefik'scommandwholesale, so entrypoint flags added to the base file must be added here too,- app ports (3000/8081/8090) are additionally published for debugging,
postgresgets dev credentials, a disposableopenmentor-postgres-data-devvolume and host port 5433 (POSTGRES_DEV_PORToverrides it if taken; only one database runs in the merged stack),alloyandcadvisorare opt-in via--profile observability: alloy cannot start without Grafana Cloud credentials and thealloy-secrets/postgres_secret_openmentorfile, so it is not part of the default dev stack,postgres-backupnever runs in dev (profiles: [production-only]) — dev data is disposable and the local stack must not touch backup buckets.
- Frontend via traefik: http://localhost/
- Frontend direct: http://localhost:3000
- Backend health: http://localhost:8081/api/healthcheck
- Worker health: http://localhost:8090/healthz
- Postgres:
psql postgresql://openmentor:password@localhost:5433/openmentor
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.dev.yml"
$COMPOSE ps # status
$COMPOSE logs -f backend # logs (any service)
$COMPOSE down # stop
$COMPOSE down && docker volume rm openmentor-postgres-data-dev # reset dev DB
./deploy-dev.sh backend # rebuild + roll just the backend/worker
./deploy-dev.sh all --dry-run # print the plan without executingSee ENVIRONMENT_VARIABLES.md for how the env files layer, and the two
committed templates for the full annotated variable list:
.env.example→.env(local development, read by composeenv_file).env.production.example→.env.production(deployment credentials, frontend build args, and runtime secrets;deploy.shuploads it to/opt/openmentor/infra/.envon the VM)
Highlights:
DATABASE_URL— Postgres for migrate/backend/workerS3_STORAGE_*/NEXT_PUBLIC_S3_STORAGE_*— AWS S3 profile images (D15)SES_*,MODERATORS_EMAIL,DEV_EMAIL_OVERRIDE— AWS SES email via the worker (D1)WORKER_AUTH_TOKEN,WORKER_CRON_ENABLED,*_TRIGGER_URL— API→worker wiringWORKER_PROFILE_PURGE_RETENTION_DAYS,WORKER_PROFILE_PURGE_CRON— how long a deleted profile stays restorable before the worker erases it, and when that sweep runs (D70)JWT_SECRET,INTERNAL_MENTORS_API/GO_API_INTERNAL_TOKEN— authGCLOUD_*,O11Y_*— Grafana Cloud observabilityANALYTICS_PROVIDER,POSTHOG_*,NEXT_PUBLIC_POSTHOG_*— product analytics
Generate secrets with openssl rand -base64 32 (or -hex 32 for the worker token).
Production is a single Hetzner Cloud VM (DECISIONS D2) with Docker, the
monorepo checked out at /opt/openmentor (compose runs from
/opt/openmentor/infra), and firewall open on 22/80/443.
cp .env.production.example .env.production # once; fill in everything
./deploy.sh # default targets: frontend backend
./deploy.sh frontend # roll only the frontend
./deploy.sh backend # roll backend + worker + migrate (one image)
./deploy.sh infra # sync infra/ config and converge compose changes
./deploy.sh all # frontend backend infra
./deploy.sh backend --tag abc123f # deploy an already-pushed tag
./deploy.sh all --yes --dry-run # print the plan / skip the prompt
./deploy.sh --staging # target the staging VM (VM_SSH_*_STAGING + DOMAIN_STAGING)deploy.sh:
- builds the targeted images tagged with the monorepo's short commit SHA
(see
DOCKER_TAG_POLICY.md— neverlatest) and pushes them to AWS ECR (D19;${ECR_REGISTRY},aws ecr get-login-passwordlogin with your local aws CLI identity), - fetches the currently deployed tags from the VM for any service not being deployed, so untouched services keep their tags,
- for the
infratarget: rsyncsinfra/to/opt/openmentor/infra(never.env*,logs/,alloy-secrets/; no--delete) with--checksum --itemize-changes, so it knows which files actually changed, - uploads
.env.productionto the VM as/opt/openmentor/infra/.env(mode 600) with the resolved image tags, and writes the Alloy DB-observability secret, - ensures the external
openmentor-postgres-datavolume exists (idempotentdocker volume create), then runsdocker-compose pull && up -don the VM (--remove-orphanswhen theinfratarget is included) — compose convergence recreates only the services whose image tag or definition changed, - handles the bind-mount trap: compose does not react to changes in
bind-mounted config files, so after
upthe script restarts/rebuilds exactly the affected services (see inventory below), - health-checks frontend (
/api/healthcheck), backend (/api/healthcheck), worker (/healthz) and postgres (pg_isready) inside the containers, plus the backup sidecar's compose healthcheck —.State.Health.Status, not just.State.Status, because docker keeps an unhealthy container in staterunningand a sidecar whose nightly dumps had silently stopped used to pass this gate.starting(itsstart_period) and a VM whose compose file predates the healthcheck both still pass, and - automatically rolls back to the previous
.env(previous image tags) if any health check fails, then verifies the rollback. One exception: a backup sidecar that is running butunhealthy(stale dumps) ends the deploy with exit 2 and no rollback — reverting working images cannot make apg_dumprun, and a deploy that aborts halfway is worse than the stale dump.deploy.shand the CI workflow report that separately from a failed deploy, and - probes
https://$DOMAIN/api/healthcheckfrom the workstation (12 × 10s) and fails the deploy if it never returns 200 — the only check that traverses DNS, TLS and Traefik, since step 7 is container-loopback only. With--stagingit probesDOMAIN_STAGING.
Steps 5–8 run on the VM as deploy-remote.sh — the single canonical
remote script, piped over ssh stdin from the local checkout (never executed
from the rsynced copy) and shared verbatim with the CI workflow. Edit the
remote logic once, both deploy paths pick it up.
deploy-dev.sh runs the identical CLI and flow against the local dev stack
(no registry, no SSH — see Quick Start).
| Service | Config source | On change |
|---|---|---|
alloy |
./alloy/config.alloy (bind-mounted file) |
docker-compose restart alloy |
alloy |
./alloy-secrets/ (runtime state written by deploy.sh) |
never synced |
postgres-backup |
built on the VM from ./postgres-backup/ |
docker-compose build postgres-backup + up |
traefik |
none — static config is command flags, dynamic config is docker labels | plain compose convergence |
Compose-level changes (bumped traefik/postgres/alloy image pins,
service definitions, env passthrough) converge through up -d itself.
Postgres pin bumps are safe: the container is recreated but the data
lives in the external openmentor-postgres-data volume. Minor/patch
versions only — major upgrades follow
../docs/runbooks/postgres-backup-restore.md.
./rollback.sh <previous-commit-sha> # both images
./rollback.sh --frontend <sha> # frontend only
./rollback.sh --backend <sha> # backend/worker/migrate onlySee DEPLOYMENT.md for the full guide and troubleshooting.
../.github/workflows/deploy.yml (repo root) builds/pushes both images and
deploys over SSH by piping the same infra/deploy-remote.sh that
deploy.sh uses (single source of the remote pull/converge/network-guard/
health-check/auto-rollback logic — no drift between CI and workstation
deploys). It is currently
manual-trigger only (workflow_dispatch); the push trigger is commented
out. Required repo secrets: ECR_REGISTRY, AWS_REGION, AWS_CI_ROLE_ARN
(GitHub OIDC role with ECR push, D19 — set by the private provisioning
repo's set-github-secrets.sh), VM_SSH_HOST, VM_SSH_USER, VM_SSH_KEY,
VM_SSH_HOST_KEY, DOMAIN, NEXT_PUBLIC_S3_STORAGE_ENDPOINT,
NEXT_PUBLIC_S3_STORAGE_BUCKET, TURNSTILE_SITE_KEY.
VM_SSH_HOST_KEY pins the VM's SSH host key so the deploy runner verifies it
is really the production VM (SECURITY H2), instead of trusting-on-first-use via
ssh-keyscan. It is set/refreshed by the provisioning repo's
set-github-secrets.sh (reads the key from the VM, cross-checks against an
independent ssh-keyscan, stores the secret). Re-run that after rebuilding
the VM — new host keys otherwise make deploys fail with a host-key-mismatch
(that refusal is the safeguard working).
Everything ships to Grafana Cloud through the alloy container
(alloy/config.alloy):
- Metrics: Prometheus scrapes of backend/worker/frontend/cadvisor/alloy
- Logs: JSON log files of all three apps tailed → Loki
- Traces: OTLP receiver on :4318/:4317 → Tempo (
O11Y_EXPORTER_ENDPOINT=alloy:4318) - Profiles: Pyroscope push receiver on :4040 (
O11Y_PROFILING_*) - DB observability:
database_observability.postgres+prometheus.exporter.postgresusingPOSTGRES_OBS_DSN
Grafana dashboards live as plain JSON in the repo-root grafana/
directory and sync to Grafana Cloud via Git Sync; alert rules are versioned in
grafana/alerting/alert-rules.yaml but Git Sync does not cover them — an
operator applies them by hand, and none are on the stack today
(see grafana/README.md). Product analytics
dashboards live in posthog/dashboards/ (node sync.mjs).
Postgres publishes no ports anywhere — access goes over SSH via ./db.sh:
./db.sh # interactive psql shell on production
./db.sh -c "SELECT ..." # one-off query
./db.sh < queries.sql # run a SQL file
./db.sh tunnel # SSH tunnel for GUI clients → localhost:5433The tunnel prints the DSN; the password is POSTGRES_PASSWORD in
.env.production. Uses your SSH agent (1Password-friendly).
Per DECISIONS D2, the database has three protection layers (restore
procedures + quarterly drill: ../docs/runbooks/postgres-backup-restore.md):
- Volume protection — Postgres data lives in the
openmentor-postgres-datavolume, declaredexternalindocker-compose.ymland created by the deploy scripts, sodocker compose down -vcan never delete it. - Hetzner VM auto-backups — enable them on the server (whole-VM snapshots, crash-consistent; Postgres WAL recovery makes them safe to restore from).
- Nightly logical dumps — the
postgres-backupsidecar runspg_dump -Fcdaily atBACKUP_TIME(default 03:30 UTC) and ships the dump tos3://$BACKUP_S3_BUCKET/$BACKUP_S3_PREFIX/, pruning objects older thanBACKUP_RETENTION_DAYS(default 30). With no bucket configured it falls back to the localopenmentor-postgres-backupsvolume and logs a loud warning. Manual/drill run:docker exec openmentor-postgres-backup backup.sh once. Every success refreshes/backups/.last_success; the container healthcheck turnsunhealthyonce that marker ages pastBACKUP_MAX_AGE_HOURS(default 26h, also published asopenmentor_db_backup_max_age_secondsso the alert and its dashboard panels compare against the configured window instead of a copy of it).deploy-remote.shandrollback.shread that verdict (.State.Health.Status) and end non-zero onunhealthy, so a deploy or a rollback can no longer report green over stale dumps — but that only reaches whoever is watching a deploy. Nothing pages off the VM until an operator appliesDatabaseBackupStaleandDatabaseBackupPipelineAbsent(the second catches one deployment's gauges disappearing while another keeps publishing, which the first cannot see). Neither is on the stack yet; see the operator step in../docs/runbooks/postgres-backup-restore.mdand../grafana/README.md§ Alert rules.
RPO ≤ 24 h, RTO ≈ 30 min with dumps. Documented next step (not implemented):
wal-g continuous WAL archiving to S3 for ~minutes RPO / PITR. Scale path:
managed Postgres (Neon/RDS) — a DATABASE_URL swap to
sslmode=verify-full with the provider CA passed via sslrootcert=<path>
in the DSN (standard pgx/libpq behavior).
Also back up:
- Traefik certificates —
traefik-letsencrypt-certificatesvolume (recreatable; Let's Encrypt will reissue). .env.production— keep a copy in a password manager/secrets vault; it is the only non-reproducible config artifact.
- Image copy (D15):
migration/yandex-to-s3-migration.jscopies profile images from getmentor's Yandex Object Storage to AWS S3; run before cutover if the data-carry decision (provisioning runbook step 1) says to carry getmentor data — retiremigration/(and the Yandex account) once that decision lands. - wal-g PITR: nightly
pg_dumpto S3 is implemented (see Backups); continuous WAL archiving with wal-g is the documented upgrade path if ~minutes RPO is ever needed.
- Only Traefik has public ports; backend/worker/alloy/cadvisor are internal.
- No container gets the Docker socket except cAdvisor. Traefik reads a
filtered, GET-only Docker API through
docker-socket-proxyon its owninternalnetwork, so an RCE at the edge cannot create a container (H7).docs/runbooks/container-hardening.mdhas the verification and rollback. - Every service runs
no-new-privileges+cap_drop: [ALL], with the few capabilities each image genuinely needs added back andread_onlywhere it has been exercised. The exceptions are listed with their reasons inline indocker-compose.yml; none of them is "we didn't get round to it". - Postgres identities are split per process (
om_migrateowns the schema,om_api/om_workerare DML-only,om_backupis read-only), so a SQL injection no longer reachesCOPY ... FROM PROGRAM. The roles ship unusable (NOLOGIN) and each service is switched over by an operator, one.envline at a time —../docs/runbooks/database-identities.md. - The worker requires
X-Worker-Token(WORKER_AUTH_TOKEN) on all/jobs/*calls. - Never commit
.env/.env.production; both are gitignored. Rotate tokens regularly and use different values per environment. - On the VM: key-only SSH, UFW allowing 22/80/443, fail2ban recommended.
- Issues: https://github.com/openmentor-io/openmentor/issues
- Docs:
DEPLOYMENT.md,ENVIRONMENT_VARIABLES.md,DOCKER_TAG_POLICY.md,docs/troubleshooting.md, and the migration plan in../docs/migration/