build
GitHub builds the app images
API and UI repos publish GHCR images from release workflows instead of building on the VPS.
Production deploy
BoringStack runs on a VPS you control: Docker Compose, GHCR images, Traefik TLS, and Cloudflare at the edge. Fork the templates, fill compose/.env, then boot the production profile manually or let OpenTofu do first provisioning.
GHCR
immutable app images
Traefik
same-origin TLS routing
Cloudflare
edge + origin perimeter
Push to main on api-template or ui-template. The release workflow builds a Docker image and pushes it to ghcr.io/<your-owner>/<repo>:latest. WUD on the VPS detects the new tag and auto-deploys app containers (api, ui). Base images remain notify-only and operator-applied. Traefik handles TLS via Let’s Encrypt; Cloudflare proxies all traffic to your single apex domain via same-origin path routing.
build
API and UI repos publish GHCR images from release workflows instead of building on the VPS.
wud
App images auto-roll on new tags; Postgres/Valkey/Traefik stay notify-only for manual review.
route
The SPA lives on / and the API on /api/* behind one apex, one cert, and one browser security model.
One VPS carries real traffic for years; scale out when you need it.
Reproducible deploys; the VPS pulls images instead of running npm install on box.
api/ui auto-deploy from GHCR tags; base services stay notify-only and
human-reviewed.
TLS on the origin without a separate load-balancer bill.
One cert for / and /api/*; no api. subdomain.
Only Cloudflare reaches the origin; port scans hit a closed firewall.
Small runtime image without a shell in prod.
Three things need configuring before the first deploy. The release workflows handle everything else from your renamed fork URLs.
The release.yml workflows on api-template and ui-template run on push to main and on v*.*.* tags. They use ${{ github.event.repository.name }}, so they publish to ghcr.io/<your-owner>/<your-renamed-repo>:latest automatically. The default GITHUB_TOKEN has packages: write via the workflow grant, so the first push from a freshly-forked repo works without secret setup.
After the first publish, make the GHCR package public so the VPS can pull without credentials:
https://github.com/<your-owner>/<your-renamed-repo>/pkgs/container/<your-renamed-repo>.Without this, downstream consumers (your VPS) need a pull credential.
In your infra-docker-compose-template fork’s compose/.env:
IMAGE_OWNER=acmeAPI_IMAGE_NAME=acme-api # only if you renamed the api repoUI_IMAGE_NAME=acme-ui # only if you renamed the ui repoKept canonical names? Only IMAGE_OWNER needs to change.
If using the OpenTofu path, terraform.tfvars in your infra-bootstrap-tofu-template fork:
api_repo = "https://github.com/acme/acme-api"ui_repo = "https://github.com/acme/acme-ui"infra_repo = "https://github.com/acme/acme-infra"domain = "acme.com"# ... other vars per terraform.tfvars.exampleThe module derives IMAGE_OWNER, API_IMAGE_NAME, and UI_IMAGE_NAME from those URLs and renders them into compose/.env on the VPS. You don’t set them twice.
Use this once, after Quickstart and before the first production boot:
main, then watch each Actions tab).compose/.env has IMAGE_OWNER and any renamed API_IMAGE_NAME / UI_IMAGE_NAME.JWT_SECRET regenerated for production (openssl rand -base64 48).FRONTEND_URL, PUBLIC_API_URL, PUBLIC_UI_HOST, and ACME_EMAIL match the production origin.SUPERUSER_PASSWORD rotated through the password-reset flow after first login.terraform.tfvars has fork URLs, real domain, and production secrets.The preferred path is Provisioning with OpenTofu: one tofu apply provisions the VPS, configures Cloudflare DNS + zone settings, and runs cloud-init which clones the infra repo, drops a rendered compose/.env, and pulls the GHCR images.
Manual path if you’d rather:
infra-docker-compose-template fork onto the VPS.compose/.env with PUBLIC_UI_HOST, ACME_EMAIL, IMAGE_OWNER, and the rest from .env.example.STACK=prod ./scripts/compose-up.sh pull && STACK=prod ./scripts/compose-up.sh up -d.You do not clone api-template or ui-template on the VPS. Their built images come from GHCR.
For changes to the api or the ui, you don’t touch the VPS:
$ git push origin main
ok release.yml builds ghcr.io/.../...:latest
ok release.yml also publishes :sha-<7> and semver tags when presentWUD watches latest. App containers (api, ui) are updated automatically. For base-image updates and infra changes, apply manually:
$ ssh <vps>
$ cd /opt/boringstack/infra
$ docker compose pull
$ docker compose up -dFor changes to the infra YAML or env vars, git pull the infra repo on the VPS then re-run compose up -d.
WITH_WUD=1 in prod: app images auto-pull + auto-recreate; base images send notifications only.
Disable app auto-deploy labels if you want full manual rollouts.
Set API_IMAGE_TAG=sha-abc1234 or :0.3.0 in compose/.env and update deliberately.
The default is hybrid because app-image rollouts are low-risk and frequent, while base-image rollouts carry higher migration risk. See Image updates.
# On the VPS:echo "API_IMAGE_TAG=sha-<previous-7>" >> compose/.env # or a previous :<semver>docker compose pulldocker compose up -dPostgres schema is the only thing this doesn’t roll back. Destructive migrations are forward-only by convention. The api-template’s discipline is “additive changes are normal, destructive changes are deliberate,” so most rollbacks just work.
For high-stakes deploys, snapshot Postgres before applying a destructive migration. See Backups.
When single-host runs out of room, Postgres is almost always the bottleneck. In rough order:
POSTGRES_LIMITS_*.EU-friendly pricing and network. New accounts may sit in fraud review for a day or two.
Low cost with IPv4 included. Prefer the API over the web console for automation.
Polished UX and docs. Compare specs against Hetzner/OVH for your workload.
Lowest cost at scale; you operate the hardware and networking.
Cloudflare in front is standard regardless of provider; that’s what makes the firewall + IP allowlist work.