Deploy & Release
How to ship a change — the deploy/ scripts, systemd units, nginx vhosts, on-server Go build, Goose migrations in deploy, rollback, and a step-by-step sandbox→prod runbook.
The backend is a single Go binary deployed to two Hetzner boxes via systemd + bash-over-SSH — no orchestrator, no CI push-to-server, no blue/green. Frontends deploy separately via GitHub Actions → Cloudflare. Docker runs only the data plane, never the app.
Server topology (hardcoded in scripts)
PROD 65.109.119.112 — octopus.service (:8081) + octopus-client.service (:3001).
SANDBOX + telemetry 94.130.137.222 — octopus-sandbox.service (:8082) + octopus-sandbox-client.service (:3002), plus SigNoz UI (:8080) and the OTel collector (:4318). See Infrastructure.
The release runbook (backend change → sandbox → prod)
Pre-flight (local): merge to master, CI must be green; if schema changed, go run main.go migrate create <name>, write a real Down and a backward-compatible Up (there is no automatic DB rollback).
Sandbox (94.130.137.222): git pull → docker exec local_postgres pg_isready -U octopus -d octopus_sandbox → sudo ./deploy/update-octopus-sandbox.sh → smoke-test https://sandbox-api.octopuscards.io/status. If the client changed, sudo ./deploy/update-octopus-sandbox-client.sh.
Prod (65.109.119.112): git pull → pg_isready …-d octopus → sudo ./deploy/update-octopus.sh → verify api/status + warden (302→/admin/login) + journalctl -u octopus -f. Client change → sudo ./deploy/update-octopus-client.sh.
Frontends deploy themselves on the master push (path-filtered GitHub Actions) — except the client portal, which is the one frontend deployed by hand over SSH (steps above).
What the update scripts do (and don't)
update-octopus.sh is a safe flow: build to /tmp/octopus-build-$$ → back up the current binary → stop service → migrate up → install binary → start → poll /status for 30s → auto-rollback the binary on migrate or health failure. But it is not zero-downtime (stop→migrate→start window) and it does not roll back the DB — a binary rollback leaves the schema forward.
The deploy/ directory
| File | Purpose |
|---|---|
init.sh | one-time host bootstrap — Docker, Go (from go.dev + sha256), nginx, certbot |
setup-octopus-service.sh / update-octopus.sh | prod install / redeploy (:8081) |
setup-octopus-sandbox-service.sh / update-octopus-sandbox.sh | sandbox install / redeploy (:8082, DB octopus_sandbox, prod-DB guard) |
octopus.service / octopus-sandbox.service | backend systemd units (run-all) |
setup/update-octopus-client*.sh + octopus-client.service | client portal (Bun build → Node runtime, :3001/:3002) |
setup/update-octopus-docs.sh | Fumadocs static export → nginx (/opt/octopus-docs) |
nginx-octopus*.conf, nginx/{signoz,octopus-docs,mypopupgiftcards}.conf | vhosts |
octopus.logrotate | daily rotate, keep 14, copytruncate |
uninstall-octopus-sandbox.sh | destructive teardown (guarded by --yes-i-am-sure + prod denylist) |
replica/docker-compose.yaml | Postgres streaming read-replica (separate machine) |
Root-level (not in deploy/): docker-compose.yaml (data plane), otel-collector-config.yaml.
Build & artifact
- On-server native build — every script runs plain
go build -o <target>on the box (Go installed byinit.sh). No cross-compile, no Docker image, no CI artifact shipped. - No build metadata: bare
go build, no-ldflags, no version/commit embedding. You cannot tell which commit is running from the binary. - Update scripts build to a temp path with a
trapcleanup, thencpto/opt/octopus/octopusonly after a successful build + migration — the running binary is never clobbered mid-build. - Embedded (
//go:embed,main.go:58): SQL migrations, Jet views, email templates,assets/**. - NOT embedded (fragile): seeder JSON (
database/seeder/data/*.json) is read by relative path at runtime, so every deploycps it to/opt/octopus/database/seeder/data/and runs with cwd/opt/octopus. Flagged in-code as tech debt pending//go:embed.
systemd services
Five units, all User=octopus, Type=simple, Restart=always, RestartSec=5, LimitNOFILE=65535.
| Unit | ExecStart | Port | EnvFile |
|---|---|---|---|
octopus.service | octopus run-all | 8081 | /etc/octopus/octopus.env |
octopus-sandbox.service | octopus run-all | 8082 | /etc/octopus-sandbox/octopus.env |
octopus-client.service | node /opt/octopus-client/server.js | 3001 | /etc/octopus/octopus-client.env |
octopus-sandbox-client.service | node …server.js | 3002 | /etc/octopus-sandbox/octopus-client.env |
One process runs everything
The Kong subcommand is run-all — a single supervised process starts the HTTP server + all workers + all crons together (main.go:284). There is no separate worker/cron service in production, even though the CLI supports server/worker/cron. See Jobs & Observability.
- Backend units are hardened:
NoNewPrivileges,PrivateTmp,ProtectSystem=strict,ProtectHome,ReadWritePaths=/var/log/octopus /opt/octopus/uploads. - Sandbox is deliberately not
After=octopus.service(a prod restart won't cascade) and setsJOB_LOGS_DIRbecauseProtectSystem=strictmakes the prod log dir read-only to it. - Client units run Node, not Bun at runtime — a Bun
getPrototypeOfshim bug crashes Next.js SSR; Bun is used only forbun install/bun run build. See use-bun-not-npm. - PROD vs SANDBOX: ports 8081↔8082 / 3001↔3002; DB
octopus↔octopus_sandbox; cache prefix none↔sandbox:; queue exchange default↔app.sandbox; distinctJWT_SECRET/APP_KEY.
nginx
HTTP-only in the repo; certbot injects :443 at install. Both prod vhosts proxy 127.0.0.1:8081 (octopus_backend, keepalive 32); sandbox → :8082.
| Host | Allow-list | Upstream |
|---|---|---|
api.octopuscards.io | only ^/(api|auth|webhooks|client|g2a); else JSON 404; blocks /.git, /.env, /debug/pprof | :8081 |
warden.octopuscards.io | ^/(admin|assets); bare host 302→/admin/login; 600s timeouts | :8081 |
sandbox-api / sandbox-warden | same structure | :8082 |
app.octopuscards.io / sandbox-app | pass-through, buffering off (SSR streaming) | :3001 / :3002 |
telemetry.octopuscards.io | WebSocket upgrade | :8080 (SigNoz) |
otel.octopuscards.io | /v1/{traces,metrics,logs}, buffering off | :4318 (OTLP) |
Only setup-octopus-service.sh auto-runs certbot (for api. + warden.). All sandbox, client, docs, and telemetry vhosts need a manual certbot --nginx — the scripts don't issue their TLS. certbot.timer handles renewal.
Migrations in deploy
- Goose v3 via the binary's
migratesubcommand; migrations are embedded in the binary.upusesWithAllowMissing()to tolerate out-of-order timestamps from parallel branches. - The update scripts run
migrate upafter installing the new binary, before starting the service (asoctopuswith env sourced); a migration failure triggers automatic binary rollback. Standalone:sudo -u octopus /opt/octopus/octopus migrate up. - Sandbox update refuses to migrate if
PG_DB=octopus(prod-DB guard). - D1 (grasshopper) migrations are a separate world — Drizzle SQL applied by Wrangler in CI (
wrangler d1 migrations apply grasshopper --remote). See Cloudflare.
migrate fresh drops the database
The fresh subcommand drops and recreates the whole DB. It is guarded only by the sandbox PG_DB=octopus refusal — there is no such guard on prod. Per the standing rule, a human applies migrations; never run fresh against prod. Also remember the AI-agent rule: never run migrations yourself — write them, the user applies them.
Env & secrets on the servers
- Backend env files (systemd
EnvironmentFile, single source of truth): PROD/etc/octopus/octopus.env, SANDBOX/etc/octopus-sandbox/octopus.env(mode640,root:octopus). - Generated once by the setup scripts:
JWT_SECRET=openssl rand -base64 64,APP_KEY=openssl rand -base64 32,PG_PASS=openssl rand -base64 24(sandbox reads the PG password from the running container). Prod/sandbox secrets are deliberately distinct so tokens/ciphertext don't cross environments. - Env drift repair: update scripts append any missing keys with defaults (
ensure_env_key) and validate a required set; never overwrite existing values. - There is no committed env template — the "template" is the heredoc inside each setup script.
See Secrets & Config for the full store map. New leaks found in this pass:
Committed secrets to rotate
- Root
.envin the working tree carries live-looking third-party keys — Twilio auth token, a SendGridSG.…key, an OpenAIsk-proj-…key, plusJWT_SECRET/APP_KEY(.env:16-23). It's.gitignored now, but the file is present and these should be treated as leaked and rotated (they're real service creds even thoughAPP_ENV=local). frontend/grasshopper/wrangler.jsonccommitsOTEL_INGEST_TOKENand a whole sandboxenv.sandbox.varsblock (TURNSTILE_SECRET_KEY,ENCRYPTION_KEY,ADMIN_TOKEN,CLAIM_NONCE_SECRET) — the file's own comments admit "⚠ COMMITTED TO GIT."docker-compose.yamlhardcodesRABBITMQ_DEFAULT_PASS: octopus,MYSQL_ROOT_PASSWORD: rootpassword, defaultPG_PASS:-octopus.
Docker data plane
Root docker-compose.yaml, brought up by setup-octopus-service.sh. Update scripts never touch compose — restarting containers mid-update has caused outages, so they only pg_isready-check and fail loudly.
| Service | Image | Ports |
|---|---|---|
| postgres | postgres:16 | 5432 (WAL tuned: wal_level=replica, 10 senders, wal_keep_size=512MB) |
| valkey | valkey/valkey:latest | 6379 (--save 60 1) |
| rabbitmq | rabbitmq:3-management | 5672, 15672 (mgmt) |
| otel-collector | otel/…-contrib:latest | 4327→4317 gRPC, 4328→4318 HTTP (host ports +10 to avoid SigNoz clash), 13133, 55679 |
| mysql | mysql:8.4 | 3306 |
Data persisted to ./.data/*. The replica (deploy/replica/) streams from the primary on a separate machine.
Frontend deploys (contrast)
| App | Mechanism | Target |
|---|---|---|
| grasshopper | GH Actions → wrangler deploy (path-filtered, prod/sandbox independent; runs D1 migrations first) | Worker claim.octopuscards.io |
| mocky-balboa | GH Actions → wrangler deploy --minify | Worker (D1 mocky) |
| octopus-docs | GH Actions → wrangler pages deploy out | Pages octopus-docs |
| octopus-website | GH Actions → wrangler pages deploy out | Pages octopus-website |
| client portal | manual update-octopus-client.sh on the box | systemd Node :3001/:3002 |
The Go backend and the client portal are the only things not deployed by CI. Note two competing docs deploy paths (Cloudflare Pages CI vs nginx-static on box) — ambiguous source of truth.
Rollback & health
- Health:
GET /status→HealthCheck(routes.go:47), given a no-log fast path in every vhost. Update scripts polllocalhost:<PORT>/statusfor 30s + checksystemctl is-active. - Automated rollback: current binary backed up to
/opt/octopus/octopus.backup.<ts>(last 5 kept). On migrate or health failure,rollback()stops, restores the backup, restarts, re-checks. - Manual:
systemctl stop octopus && cp /opt/octopus/octopus.backup.<ts> /opt/octopus/octopus && systemctl start octopus; down-migrate only if the schema is incompatible. - Logs:
journalctl -u octopus -f+/var/log/octopus/octopus.log; nginx/var/log/nginx/octopus-{api,warden}.*. - Zero-downtime: none — stop→migrate→start on every deploy;
Restart=alwayscovers crashes, not deploys.
Risk register
| Risk | Note |
|---|---|
| Committed secrets | root .env, wrangler.jsonc, compose defaults — rotate |
| No zero-downtime | stop→migrate→start window every deploy |
| No DB rollback on binary rollback | forward-only schema risk |
| No version stamping | can't tell which commit is running |
| Manual, un-CI'd backend + client deploys | human SSH + bash, no deploy record/approval gate |
| Seeder JSON relative-path dependency | must cp data + run from specific cwd |
| Two docs deploy paths | Pages CI vs nginx-static |
latest image tags (valkey/otel) | non-reproducible pulls |
migrate fresh drops DB | guarded on sandbox, not prod |
Key files
deploy/{init,setup-octopus-service,update-octopus,setup-octopus-sandbox-service,update-octopus-sandbox}.shdeploy/{octopus,octopus-sandbox,octopus-client,octopus-sandbox-client}.service,deploy/octopus.logrotatedeploy/{nginx-octopus,nginx-octopus-sandbox,nginx-octopus-client}.conf,deploy/nginx/{signoz,octopus-docs,mypopupgiftcards}.confdocker-compose.yaml,otel-collector-config.yaml,deploy/replica/docker-compose.yaml- CLI/migrations:
main.go(Kong subcommands,//go:embed),database/migration/migration.go - CI:
.github/workflows/{test-backend-api,deploy-grasshopper,deploy-octopus-docs,deploy-octopus-website,deploy-mocky-balboa}.yml
Environment Variables
Every environment variable the backend reads — what it controls, its default/fallback, where it's read, and PROD vs SANDBOX differences. Plus the insecure fallbacks, drift risks, and dead vars.
Operations & Runbooks
Day-to-day operating procedures — deploying, rolling back, reading logs, checking health, and safe database investigation.