build
GitHub builds the app images
API and UI release jobs publish GHCR images from the monorepo 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 monorepo, 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 with changes under apps/api or apps/ui. Path-filtered release workflows build Docker images and push them to ghcr.io/<your-owner>/<repo>-api:latest and ghcr.io/<your-owner>/<repo>-ui:latest. WUD on the VPS detects the new tags 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 release jobs publish GHCR images from the monorepo 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 monorepo fork URL.
The API and UI release workflows run on push to main when their app paths change. They use ${{ github.event.repository.name }} plus an app suffix, so a fork named acme-stack publishes ghcr.io/<your-owner>/acme-stack-api:latest and ghcr.io/<your-owner>/acme-stack-ui: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:
<repo>-api and <repo>-ui.Without this, downstream consumers (your VPS) need a pull credential.
In infra/compose/compose/.env:
IMAGE_OWNER=acmeAPI_IMAGE_NAME=acme-stack-apiUI_IMAGE_NAME=acme-stack-uiKept the canonical upstream repo name? Set IMAGE_OWNER; the defaults are boringstack-api and boringstack-ui.
If using the OpenTofu path, terraform.tfvars under infra/bootstrap:
monorepo_repo = "https://github.com/acme/acme-stack"domain = "acme.com"# ... other vars per terraform.tfvars.exampleThe module derives IMAGE_OWNER, API_IMAGE_NAME=<repo>-api, and UI_IMAGE_NAME=<repo>-ui from that URL 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 monorepo, drops a rendered compose/.env, and pulls the GHCR images.
Manual path if you’d rather:
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 apps/api or apps/ui 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 monorepo 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 app 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.