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).
| Stack | docker-compose.prod.yml, compose project breezycorp |
| Services | postgres, api, worker, web, docs, caddy (+ migrate, profile-gated) |
| Public ingress | Caddy, ports 80/443 only |
| Hostnames | console.breezycorp.app, api.breezycorp.app, docs.breezycorp.app |
| SSH | WireGuard only — via the vpn01 jump host |
| Object storage | Off-box, Hetzner Object Storage (hel1) |
| Database | postgres: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 vpn01Ask 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/main → docker compose build (images are built on the box, not pulled from a registry) → a one-shot migrate service running prisma migrate deploy → up -d. set -e means a failed migration aborts the deploy rather than crash-looping the API.
Two consequences worth internalising:
- Anything merged to
mainlands 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.shends atdocker 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 ondeploy/deploy.conf— optional; its absence is meaningful. Without it,deploy.shuses the defaultCOMPOSE_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
| Task | Where |
|---|---|
| Object storage, credentials, CORS | Object storage |
| Backup + restore | Restore drill, deploy/backup.sh |
| Rolling back a migration | DB migration rollback |
| Rotating secrets | Secret rotation |
| Outbound Chatwoot calls failing | Chatwoot 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_URLis baked into the web bundle at image build time. Changing it needsdocker compose build web, not a restart.MIGRATE_DATABASE_URLis required.schema.prismareferences it asdirectUrl, and Prisma errors on a missing env var, somigrate deploy— and the whole deploy — fails without it. The pg-boss connection also reads it, but falls back toDATABASE_URL, so a missing value degrades silently there.- The egress IP matters.
chat.breezycorp.appsits 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_BUCKETis not a name you can freely reassign.