Deployment
Control Plane deploys as five containers on a single droplet, driven by
GitHub Actions. Every push to main builds two images, pushes them to
GHCR, and rolls the server forward over SSH.
┌──────────────────────────── droplet ┐
browser ──443──► │ web (Caddy) │
│ ├─ serves the React SPA (static) │
│ └─ /api/* ──► api (Spring Boot) │
│ ├─► postgres │
│ ├─► redis │
│ └─► rabbitmq │
└─────────────────────────────────────┘
Only Caddy binds host ports (80/443). It terminates TLS with an
automatic Let's Encrypt certificate, serves the built UI, and reverse
proxies /api/* to the API container. Postgres, Redis, RabbitMQ, and
the API itself are reachable only on the internal compose network —
actuator endpoints included.
Pieces
| Path | What it is |
|---|---|
services/api/Dockerfile |
Multi-stage build: JDK 25 builder → JRE runtime, non-root, healthcheck on /actuator/health |
services/ui/Dockerfile |
Node 22 builds the SPA → Caddy image serves it + proxies the API |
deploy/Caddyfile |
Caddy config baked into the web image (DOMAIN env at runtime) |
deploy/compose.prod.yml |
The five-service stack; fails fast if required env vars are unset |
deploy/.env.prod.example |
Template for the droplet's .env |
.github/workflows/deploy.yml |
Build → push to GHCR → SSH deploy |
One-time server setup
A 2 GB droplet is the practical minimum (Java + Postgres + RabbitMQ + Redis + Caddy; 1 GB swaps itself to death).
-
Create the droplet — Ubuntu 24.04 LTS, SSH key auth. Note the IP.
-
DNS — add an A record for your domain pointing at the droplet IP before first boot of the stack; Caddy needs it resolvable to pass the ACME challenge and issue the certificate.
-
Install Docker (as root):
curl -fsSL https://get.docker.com | sh
- Create a deploy user with docker rights and its own SSH key — the CI pipeline logs in as this user, so it shouldn't be root:
adduser --disabled-password --gecos "" deploy
usermod -aG docker deploy
su - deploy -c 'mkdir -p ~/.ssh && chmod 700 ~/.ssh'
# paste a public key generated for CI:
# ssh-keygen -t ed25519 -f ci-deploy -N "" (run locally)
su - deploy -c 'cat >> ~/.ssh/authorized_keys' # paste ci-deploy.pub
- Stage the stack:
mkdir -p /opt/control-plane && chown deploy:deploy /opt/control-plane
# as deploy:
cd /opt/control-plane
# copy deploy/compose.prod.yml here as compose.yml
# copy deploy/.env.prod.example here as .env and fill it in
Generate the JWT secret with openssl rand -base64 48. Compose
refuses to start if any required value is missing (:? expansion),
so a half-filled .env fails loudly instead of booting broken.
-
Firewall (optional but sensible):
ufw allow 22,80,443/tcp && ufw enable. -
First start:
docker compose pull && docker compose up -d
docker compose logs -f api # watch Flyway migrate, bootstrap admin created
The API creates the bootstrap admin on first boot (idempotent), and
the executor is enabled in compose.prod.yml — the demo runs jobs
out of the box.
GitHub configuration
Repository → Settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
DEPLOY_HOST |
droplet IP or hostname |
DEPLOY_USER |
deploy |
DEPLOY_SSH_KEY |
contents of the ci-deploy private key |
Until these exist, the deploy job's SSH step no-ops (images still build and push), so the pipeline is safe to merge before the server exists.
The two GHCR packages (control-plane-api, control-plane-web) must be
public (package → settings → visibility) — the droplet pulls
anonymously. Keep them private instead by adding a
docker login ghcr.io with a read-only PAT on the droplet.
The demo accounts
compose.prod.yml bootstraps two shared demo personas whose
credentials are baked into the UI's login page: the primary demo user
(demo@control-plane.dev, OPERATOR — the "Explore the demo" button)
and a restricted viewer (viewer@control-plane.dev, USER — the
"view as a restricted user" link).
To run a private deployment with no demo access, two steps:
- Set
BOOTSTRAP_DEMO_EMAIL=andBOOTSTRAP_DEMO_PASSWORD=(both empty) in.env. This stops the personas from being created — the viewer is conditional on the primary pair in the bootstrapper, so the one switch covers both. On a fresh database this is sufficient. - If the deployment has already bootstrapped the personas, they
persist as ordinary accounts — blanking the vars only skips
re-creation. Sign in as the admin, open Users, and Lock
the demo identities that were bootstrapped: whatever addresses the
BOOTSTRAP_DEMO*_EMAILvars held before you blanked them (demo@control-plane.devandviewer@control-plane.devunless you customized them — the Users page lists every account, so lock what you see, not what this doc names). Locking also revokes their refresh tokens, so any live sessions die at the next rotation.
(BOOTSTRAP_DEMO_USER_EMAIL/_PASSWORD exist for customizing the
viewer's credentials, not for enabling it independently.)
Day-2 operations
- Deploy: push to
main. Or Actions → Deploy → Run workflow. The workflow also re-syncscompose.prod.ymlto the droplet, so compose changes ship with the code — only.envis managed by hand. - Roll back: on the droplet, set
IMAGE_TAG=<old sha>in.env,docker compose up -d. Every deploy also pushes a:<git sha>tag precisely so this is possible. - Logs:
docker compose logs -f api(orweb). - DB backup:
docker compose exec postgres pg_dump -U controlplane control_plane | gzip > backup.sql.gz - Local smoke test of the prod images (no DNS needed): set
DOMAIN=:80in.env, thendocker compose upand browsehttp://localhost.