Infrastructure & Deployment
The servers, systemd services, nginx topology, Docker data plane, deploy/update scripts, and the PROD vs SANDBOX split.
Octopus runs on two Hetzner servers, both fronted by Cloudflare. Each server runs an independent Go backend + Next.js client under systemd, and both share the Docker data containers (Postgres/Valkey/RabbitMQ). The static sites and the claim worker live on Cloudflare (see Cloudflare).
The two servers
| IP | Role | Hosts |
|---|---|---|
65.109.119.112 | PROD | api.octopuscards.io, warden.octopuscards.io, app.octopuscards.io, and the mypopupgiftcards.com white-label |
94.130.137.222 | SANDBOX + telemetry | sandbox-api.*, sandbox-warden.*, sandbox-app.*, plus SigNoz at telemetry.* (UI) and otel.* (ingest) |
Bootstrap a fresh host
deploy/init.sh provisions a clean Ubuntu box: installs Docker, Go (latest, checksum-verified), nginx, and certbot. It does not install Node/Bun — those come from the client setup scripts.
Master reference
Everything, per domain:
Domain (server_name) | Target | systemd service | /opt dir | env file | app log |
|---|---|---|---|---|---|
api.octopuscards.io (API + /auth /webhooks /client /g2a) | 127.0.0.1:8081 | octopus.service | /opt/octopus | /etc/octopus/octopus.env | /var/log/octopus/octopus.log |
warden.octopuscards.io (admin /admin /assets) | 127.0.0.1:8081 (same backend) | octopus.service | /opt/octopus | /etc/octopus/octopus.env | /var/log/octopus/octopus.log |
app.octopuscards.io (client portal) | 127.0.0.1:3001 | octopus-client.service | /opt/octopus-client | /etc/octopus/octopus-client.env | /var/log/octopus/octopus-client.log |
sandbox-api.octopuscards.io | 127.0.0.1:8082 | octopus-sandbox.service | /opt/octopus-sandbox | /etc/octopus-sandbox/octopus.env | /var/log/octopus-sandbox/octopus.log |
sandbox-warden.octopuscards.io | 127.0.0.1:8082 | octopus-sandbox.service | /opt/octopus-sandbox | /etc/octopus-sandbox/octopus.env | /var/log/octopus-sandbox/octopus.log |
sandbox-app.octopuscards.io | 127.0.0.1:3002 | octopus-sandbox-client.service | /opt/octopus-sandbox-client | /etc/octopus-sandbox/octopus-client.env | /var/log/octopus-sandbox/octopus-client.log |
telemetry.octopuscards.io (SigNoz UI) | 127.0.0.1:8080 | external (SigNoz) | — | — | — |
otel.octopuscards.io (OTLP ingest) | 127.0.0.1:4318 | external (OTel collector) | — | — | — |
api.mypopupgiftcards.com | 127.0.0.1:8081 | octopus.service (reuses prod backend) | /opt/octopus | — | — |
admin.mypopupgiftcards.com | 127.0.0.1:8081 | octopus.service | /opt/octopus | — | — |
client.mypopupgiftcards.com | 127.0.0.1:3001 | octopus-client.service | /opt/octopus-client | — | — |
mypopupgiftcards.com is a white-label alias
It is not a separate deployment — the mypopupgiftcards.com domains point at the same prod backend and client on 65.109.119.112. Multi-tenancy/branding is handled inside the app (CORS + NEXT_PUBLIC_BRAND). Its DNS is managed outside the octopuscards.io Cloudflare zone.
PROD vs SANDBOX namespacing
Both environments run on the same host family and share the Postgres/Valkey/RabbitMQ containers. Isolation is by name/prefix only:
| Aspect | PROD | SANDBOX |
|---|---|---|
| Backend port | :8081 | :8082 |
| Client port | 3001 | 3002 |
/opt dir | /opt/octopus, /opt/octopus-client | /opt/octopus-sandbox, /opt/octopus-sandbox-client |
| Config dir | /etc/octopus | /etc/octopus-sandbox |
| Log dir | /var/log/octopus | /var/log/octopus-sandbox |
| Postgres DB | octopus | octopus_sandbox (same local_postgres container) |
| Cache | Valkey, no prefix | Valkey, CACHE_PREFIX=sandbox: |
| Queue | default exchange | RabbitMQ exchange app.sandbox |
APP_ENV | production | sandbox |
| WebAuthn RP | the api domain | octopuscards.io (parent), COOKIE_DOMAIN=.octopuscards.io |
All services run as octopus:octopus with Restart=always, RestartSec=5, LimitNOFILE=65535, LimitNPROC=4096, and hardening (NoNewPrivileges, PrivateTmp, ProtectSystem=strict, ProtectHome). The backend units run ExecStart=… octopus run-all (server + workers + crons). Sandbox is deliberately not After=octopus.service so a prod restart doesn't cascade.
systemd services
Files in deploy/:
octopus.service— PROD backend.WorkingDirectory=/opt/octopus, env/etc/octopus/octopus.env,ReadWritePaths=/var/log/octopus /opt/octopus/uploads. Port from envPORT=:8081.octopus-sandbox.service— SANDBOX backend on:8082. AddsEnvironment=JOB_LOGS_DIR=/var/log/octopus-sandbox/job_executions(becauseProtectSystem=strictmakes/var/log/octopusread-only here).octopus-client.service— PROD Next.js.ExecStart=/usr/bin/node /opt/octopus-client/server.json:3001,After=octopus.service.octopus-sandbox-client.service— SANDBOX Next.js on:3002.
nginx topology
Configs ship HTTP-only; certbot --nginx adds the 443 listener, cert paths, and HTTP→HTTPS redirect.
nginx-octopus.conf— upstream→127.0.0.1:8081.api.*:default_type application/json,client_max_body_size 50M. Allow-list^/(api|auth|webhooks|client|g2a)(/|$);/statusfast path; blocks dotfiles &/debug/pprof; catch-all returns JSON 404 (no HTML surfaces). ForwardsX-Real-IP(app trusts only127.0.0.1).proxy_buffering off.warden.*:client_max_body_size 100M, 600s admin timeouts./ → 302 /admin/login. Allow-list^/(admin|assets)(/|$); catch-all 404.
nginx-octopus-sandbox.conf— same two blocks→127.0.0.1:8082forsandbox-api/sandbox-warden.nginx-octopus-client.conf/-sandbox-client.conf— pure pass-through to:3001/:3002(no path allow-list, no nginx caching — Cloudflare handles the edge).proxy_buffering offfor SSR streaming.nginx/octopus-docs.conf— static file serving from/opt/octopus-docs(templated__DOCS_DOMAIN__); immutable caching for/_next/static/. (Self-hosted docs path; the live docs are on CF Pages.)nginx/signoz.conf—telemetry.* → :8080(SigNoz UI, WebSocket upgrade) andotel.* → :4318(OTLP/v1/traces|metrics|logs,client_max_body_size 50M).nginx/mypopupgiftcards.conf— white-label aliases:api./admin. → :8081,client. → :3001.
Certbot + Cloudflare
The prod backend setup issues certs automatically; sandbox, client, and docs need manual certbot --nginx after DNS is pointed. Because Cloudflare proxies the DNS record (orange cloud), temporarily set it to DNS-only (grey cloud) so the HTTP-01 challenge on port 80 can reach the origin, then flip it back to proxied.
Docker data plane
Root docker-compose.yaml — single host, containers bind 0.0.0.0, data under ./.data/*:
| Service | Container | Image | Ports |
|---|---|---|---|
| postgres | local_postgres | postgres:16 | 5432 — WAL-tuned for replication; runs 02-create-replication-user.sh |
| valkey | local_valkey | valkey/valkey:latest | 6379 — --save 60 1 |
| rabbitmq | local_rabbitmq | rabbitmq:3-management | 5672 AMQP, 15672 mgmt UI (octopus/octopus) |
| otel-collector | local_otel_collector | otel/opentelemetry-collector-contrib | 4327→gRPC, 4328→HTTP, 13133 health, 55679 zPages → SigNoz |
| mysql | local_mysql | mysql:8.4 | 3306 |
Postgres/Valkey/RabbitMQ are exposed on public host ports — security relies on the host firewall. The Postgres pg_hba.conf allows replication from 0.0.0.0/0 (md5). Keep the firewall tight; this is the main data-plane exposure.
Read-replica: deploy/replica/docker-compose.yaml runs pg_replica on a separate machine (pg_basebackup off slot replica_slot). See Database → replication.
Deploy & update scripts
Setup scripts (deploy/setup-*.sh) run on a fresh host; update scripts (deploy/update-*.sh) do safe in-place swaps with rollback. General flow: build → copy to /opt → install unit + nginx → migrate/seed → start.
setup-octopus-service.sh(PROD, the primary bootstrap) — creates theoctopususer + dirs, builds the Go binary, interactively generates/etc/octopus/octopus.env(openssl-randomJWT_SECRET/APP_KEY/PG_PASS; prompts for feature flags, domain, OTel, LE email), brings up docker, runs migrations + 7 reference seeders, installs the unit + logrotate + nginx, and optionally runs certbot forapi/warden.setup-octopus-sandbox-service.sh— reads the Postgres password from the running container, createsoctopus_sandbox, writes/etc/octopus-sandbox/octopus.env(PORT=:8082,CACHE_PREFIX=sandbox:,QUEUE_EXCHANGE=app.sandbox,WEBAUTHN_RP_ID=octopuscards.io), installs the sandbox unit + nginx. No TLS (run certbot manually).setup-octopus-client-service.sh/-sandbox-client-— install Bun (build) + Node 20 (runtime), write theNEXT_PUBLIC_*env, build, install the standalone build + unit.update-octopus.sh— pre-flight checks → env drift-fix (appends missing keys) → verify Postgres reachable (never touches docker compose — restarting containers mid-update caused outages) →go buildto tmp → backup running binary → stop → install →migrate up(rollback on failure) → refresh unit/logrotate → start → health check against:8081/status(rollback on failure) → prune backups (keep 5).update-octopus-sandbox.sh— same, health at:8082/status, and refuses to run ifPG_DB=octopus(guard against migrating the prod DB).update-octopus-client.sh/-sandbox-client-— bun build → tar old build → install new standalone → restart → verify.uninstall-octopus-sandbox.sh— destructive, requires--yes-i-am-sure(supports--dry-run); removes sandbox units/dirs/nginx, dropsoctopus_sandbox, deletessandbox:*Valkey keys and theapp.sandboxexchange. Multiple guards cross-check every path against a prod denylist and refuse to drop a DB namedoctopus.
Log rotation: octopus.logrotate → /var/log/octopus/*.log daily, keep 14, compressed, copytruncate.
Gotchas the next operator must know
- Next.js runs on Node, only built with Bun (hardcoded in the units).
- Update scripts never
docker compose up— if Postgres is down they fail loudly; bring containers up manually first. - Sandbox has no logrotate config in the repo —
/var/log/octopus-sandboxcan grow unbounded. NEXT_PUBLIC_*are build-time inlined — env changes need a full client rebuild.
CI
.github/workflows/: test-backend-api.yml (Go 1.25 — lint → build → parallel test jobs incl. contract/orchestration tags against mocky-balboa.octopuscards.io), plus the frontend deploy workflows (grasshopper, grasshopper-sandbox, mocky-balboa, octopus-website, octopus-docs — all bun + wrangler). There is no Makefile; .githooks/pre-push handles git-side checks.
Admin Order Actions
A step-by-step trace of every admin order action — cancel, refund, retry, reset, resend-email, create, and the eSIM/recharge equivalents. The RESET-vs-RETRY distinction, the cancel→refund→inventory chain, and the inventory-leak gap.
Metrics & Tracing
The OpenTelemetry → SigNoz observability catalog — every custom metric with its type/unit/attributes, the tracing conventions, log fields, the export pipeline, and the dimension-compliance gaps and dead metrics.