Skip to content

Production host

What the BreezyCorp app runs on, how to reach it, and what is deliberately not on it.

The box

The app runs as a single Docker Compose stack at /opt/breezycorp on one Hetzner server in the BreezyCorp project, Helsinki (hel1).

Stackdocker-compose.prod.yml, compose project breezycorp
Servicespostgres, api, worker, web, docs, caddy (+ migrate, profile-gated)
Public ingressCaddy, ports 80/443 only
Hostnamesconsole.breezycorp.app, api.breezycorp.app, docs.breezycorp.app
SSHWireGuard only — via the vpn01 jump host
Object storageOff-box, Hetzner Object Storage (hel1)
Databasepostgres:17 container, pgdata named volume

What is NOT here

hermes01 is a different server and runs the Hermes AI agent. It is in the Spade project, not this one. It has no BreezyCorp app stack and must never be deployed to. If you find yourself SSHed into a box with a hermes-gateway.service or a screen -S hermes session, you are on the wrong machine.

Reaching it

SSH is not publicly reachable on any server in the estate except the VPN hub. Bring up WireGuard first, then connect through the vpn01 jump host — the same ProxyJump shape CI uses.

Host vpn01
  HostName <vpn01 public IP>
  User root
  IdentityFile ~/.ssh/id_ed25519

Host breezy01
  HostName <WireGuard IP>
  User root
  IdentityFile ~/.ssh/id_ed25519
  ProxyJump vpn01

Ask the infra owner for a personal WireGuard profile and to register your SSH key. Nobody gets shared credentials or public SSH.

Deploying

Deploys are automatic: a successful CI run on main triggers the Deploy workflow, which SSHes in through vpn01 and runs /opt/breezycorp/deploy/deploy.sh. workflow_dispatch on that workflow is the manual redeploy.

deploy.sh does, in order: git reset --hard origin/maindocker compose build (images are built on the box, not pulled from a registry) → a one-shot migrate service running prisma migrate deployup -d. set -e means a failed migration aborts the deploy rather than crash-looping the API.

Two consequences worth internalising:

  • Anything merged to main lands here on the next deploy, including changes to the compose files themselves. A compose change is a production change.
  • There is no rollback and no post-deploy health gate. deploy.sh ends at docker compose ps. A deploy that boots a crash-looping API reports success. Watch the run.

Host-local files

Two files live only on the server and are gitignored — they do not survive a rebuild unless you copied them off first:

  • .env.prod — every secret the stack runs on
  • deploy/deploy.conf — optional; its absence is meaningful. Without it, deploy.sh uses the default COMPOSE_FILES=docker-compose.prod.yml, which is the self-contained stack where Caddy owns TLS. Creating one that layers an override changes the ingress model.

Routine operations

TaskWhere
Object storage, credentials, CORSObject storage
Backup + restoreRestore drill, deploy/backup.sh
Rolling back a migrationDB migration rollback
Rotating secretsSecret rotation
Outbound Chatwoot calls failingChatwoot unreachable

Backups

deploy/backup.sh runs nightly from root's crontab at 0 3 * * *. The cron is manual host state — nothing in the repo recreates it. After any host rebuild, reinstall it and run it once by hand to confirm both artifacts land.

It writes a custom-format pg_dump (db-<ts>.dump) plus a tarball of the S3 bucket into /opt/breezycorp/backups/, keeping 14 days. Set BACKUP_REMOTE_BUCKET to also push the dump to a separate bucket; leave it unset and the backups live only on the host they protect.

Gotchas

  • NEXT_PUBLIC_API_URL is baked into the web bundle at image build time. Changing it needs docker compose build web, not a restart.
  • MIGRATE_DATABASE_URL is required. schema.prisma references it as directUrl, and Prisma errors on a missing env var, so migrate deploy — and the whole deploy — fails without it. The pg-boss connection also reads it, but falls back to DATABASE_URL, so a missing value degrades silently there.
  • The egress IP matters. chat.breezycorp.app sits behind Cloudflare and its WAF allowlist is keyed on this host's outbound address. A host rebuild or a floating-IP move silently 403s every outbound Chatwoot call. Prefer routing to Chatwoot over the private LAN so Cloudflare is never in the path.
  • Bucket names are global per Hetzner location, so S3_BUCKET is not a name you can freely reassign.

Internal use only — BreezyCorp