Skip to content

Backend deployment

The API is a Docker Compose stack on one DigitalOcean droplet, reached through a Cloudflare Tunnel. The authoritative, ground-truth version of this page is app/chemical-safety-assistant-api/deploy/RUNBOOK.md, inside the API's own repo — it's what CI actually runs and what has the real secret names. This page is the narrative walkthrough for someone setting the droplet up the first time.

What lives where (in the API repo)

Dockerfile                    multi-stage build, ships the whole tree (Prisma needs it at
                               runtime) — see the API's own CLAUDE.md "Docker" section
deploy/docker-compose.prod.yml   postgres + redis + api + nginx + cloudflared
deploy/nginx.conf             reverse proxy, no CORS headers (Elysia owns CORS)
deploy/cloud-init.sh          DigitalOcean droplet first-boot script
deploy/backup.sh              nightly pg_dump, cron'd on the droplet
deploy/RUNBOOK.md             the ground-truth setup + secrets + rollback doc
.github/workflows/deploy.yml  check → build → deploy, on push to main

One-time droplet setup

  1. Create a DigitalOcean droplet. Paste deploy/cloud-init.sh into the "User data" field — it creates a non-root csa deploy user, installs Docker, and adds a swapfile.
  2. In the DO control panel, add a Cloud Firewall allowing inbound TCP 22 only. Don't rely on ufw on the box itself — Docker writes its own iptables rules that bypass it.
  3. Generate a dedicated SSH keypair for CI (not a personal key) and add its public half to the csa user's authorized_keys.
  4. Hand-write /opt/csa/.env on the droplet (never committed, never written by CI) — full template in the RUNBOOK. Covers Postgres/Redis credentials, CORS_ORIGIN, and TUNNEL_TOKEN. HOST stays unset.
  5. Create a Cloudflare Tunnel (Zero Trust → Networks → Tunnels), copy its token into TUNNEL_TOKEN, and add a Public Hostname api.<domain>http://nginx:80. See Cloudflare Tunnel.
  6. First bring-up: docker compose -f docker-compose.prod.yml up -d --wait as csa.
  7. Cron deploy/backup.sh nightly.

CI/CD

.github/workflows/deploy.yml, three jobs, push:main (PRs run check only):

  • check — real Postgres 16 + Redis 7 service containers, prisma migrate deploy, lint, tsc --noEmit, bun test.
  • builddocker/build-push-action, linux/amd64 (droplet is x86_64, no cross-arch needed), pushes ghcr.io/<owner>/chem-assistant-api:<sha> and :latest.
  • deploy — SCPs docker-compose.prod.yml / nginx.conf / backup.sh to /opt/csa (.env is never touched by CI), then over SSH: docker compose pull apidocker compose up -d --wait api nginx. --wait fails the job if the healthcheck (GET /) doesn't pass, and dumps the last 100 log lines on failure.

GitHub Actions secrets

Set in the API repo's Settings → Secrets and variables → Actions:

SecretWhat
VM_HOSTdroplet public IPv4
VM_USERcsa
VM_SSH_KEYprivate half of the CI-only deploy keypair
GHCR_TOKENPAT, read:packages — the droplet uses this to pull the image

Rollback

bash
ssh csa@<droplet> 'cd /opt/csa && export IMAGE_TAG=<previous-sha> && docker compose -f docker-compose.prod.yml up -d --wait api'

Code-only. Migrations are forward-only and are never undone.