OctoWiki

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.112octopus.service (:8081) + octopus-client.service (:3001). SANDBOX + telemetry 94.130.137.222octopus-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 pulldocker exec local_postgres pg_isready -U octopus -d octopus_sandboxsudo ./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 pullpg_isready …-d octopussudo ./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 servicemigrate 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

FilePurpose
init.shone-time host bootstrap — Docker, Go (from go.dev + sha256), nginx, certbot
setup-octopus-service.sh / update-octopus.shprod install / redeploy (:8081)
setup-octopus-sandbox-service.sh / update-octopus-sandbox.shsandbox install / redeploy (:8082, DB octopus_sandbox, prod-DB guard)
octopus.service / octopus-sandbox.servicebackend systemd units (run-all)
setup/update-octopus-client*.sh + octopus-client.serviceclient portal (Bun build → Node runtime, :3001/:3002)
setup/update-octopus-docs.shFumadocs static export → nginx (/opt/octopus-docs)
nginx-octopus*.conf, nginx/{signoz,octopus-docs,mypopupgiftcards}.confvhosts
octopus.logrotatedaily rotate, keep 14, copytruncate
uninstall-octopus-sandbox.shdestructive teardown (guarded by --yes-i-am-sure + prod denylist)
replica/docker-compose.yamlPostgres 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 by init.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 trap cleanup, then cp to /opt/octopus/octopus only 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 deploy cps 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.

UnitExecStartPortEnvFile
octopus.serviceoctopus run-all8081/etc/octopus/octopus.env
octopus-sandbox.serviceoctopus run-all8082/etc/octopus-sandbox/octopus.env
octopus-client.servicenode /opt/octopus-client/server.js3001/etc/octopus/octopus-client.env
octopus-sandbox-client.servicenode …server.js3002/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 sets JOB_LOGS_DIR because ProtectSystem=strict makes the prod log dir read-only to it.
  • Client units run Node, not Bun at runtime — a Bun getPrototypeOf shim bug crashes Next.js SSR; Bun is used only for bun install / bun run build. See use-bun-not-npm.
  • PROD vs SANDBOX: ports 8081↔8082 / 3001↔3002; DB octopusoctopus_sandbox; cache prefix none↔sandbox:; queue exchange default↔app.sandbox; distinct JWT_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.

HostAllow-listUpstream
api.octopuscards.ioonly ^/(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-wardensame structure:8082
app.octopuscards.io / sandbox-apppass-through, buffering off (SSR streaming):3001 / :3002
telemetry.octopuscards.ioWebSocket 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 migrate subcommand; migrations are embedded in the binary. up uses WithAllowMissing() to tolerate out-of-order timestamps from parallel branches.
  • The update scripts run migrate up after installing the new binary, before starting the service (as octopus with 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 (mode 640, 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 .env in the working tree carries live-looking third-party keys — Twilio auth token, a SendGrid SG.… key, an OpenAI sk-proj-… key, plus JWT_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 though APP_ENV=local).
  • frontend/grasshopper/wrangler.jsonc commits OTEL_INGEST_TOKEN and a whole sandbox env.sandbox.vars block (TURNSTILE_SECRET_KEY, ENCRYPTION_KEY, ADMIN_TOKEN, CLAIM_NONCE_SECRET) — the file's own comments admit "⚠ COMMITTED TO GIT."
  • docker-compose.yaml hardcodes RABBITMQ_DEFAULT_PASS: octopus, MYSQL_ROOT_PASSWORD: rootpassword, default PG_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.

ServiceImagePorts
postgrespostgres:165432 (WAL tuned: wal_level=replica, 10 senders, wal_keep_size=512MB)
valkeyvalkey/valkey:latest6379 (--save 60 1)
rabbitmqrabbitmq:3-management5672, 15672 (mgmt)
otel-collectorotel/…-contrib:latest4327→4317 gRPC, 4328→4318 HTTP (host ports +10 to avoid SigNoz clash), 13133, 55679
mysqlmysql:8.43306

Data persisted to ./.data/*. The replica (deploy/replica/) streams from the primary on a separate machine.

Frontend deploys (contrast)

AppMechanismTarget
grasshopperGH Actions → wrangler deploy (path-filtered, prod/sandbox independent; runs D1 migrations first)Worker claim.octopuscards.io
mocky-balboaGH Actions → wrangler deploy --minifyWorker (D1 mocky)
octopus-docsGH Actions → wrangler pages deploy outPages octopus-docs
octopus-websiteGH Actions → wrangler pages deploy outPages octopus-website
client portalmanual update-octopus-client.sh on the boxsystemd 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 /statusHealthCheck (routes.go:47), given a no-log fast path in every vhost. Update scripts poll localhost:<PORT>/status for 30s + check systemctl 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=always covers crashes, not deploys.

Risk register

RiskNote
Committed secretsroot .env, wrangler.jsonc, compose defaults — rotate
No zero-downtimestop→migrate→start window every deploy
No DB rollback on binary rollbackforward-only schema risk
No version stampingcan't tell which commit is running
Manual, un-CI'd backend + client deployshuman SSH + bash, no deploy record/approval gate
Seeder JSON relative-path dependencymust cp data + run from specific cwd
Two docs deploy pathsPages CI vs nginx-static
latest image tags (valkey/otel)non-reproducible pulls
migrate fresh drops DBguarded on sandbox, not prod

Key files

  • deploy/{init,setup-octopus-service,update-octopus,setup-octopus-sandbox-service,update-octopus-sandbox}.sh
  • deploy/{octopus,octopus-sandbox,octopus-client,octopus-sandbox-client}.service, deploy/octopus.logrotate
  • deploy/{nginx-octopus,nginx-octopus-sandbox,nginx-octopus-client}.conf, deploy/nginx/{signoz,octopus-docs,mypopupgiftcards}.conf
  • docker-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

On this page