Skip to content
BoringStack
GitHub

Deployment

7 min read

Production deploy

BoringStack runs on a VPS you control: Docker Compose, GHCR images, Traefik TLS, and Cloudflare at the edge. Use the GitHub template to create your repo, 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

GitHub builds the app images

API and UI release jobs publish GHCR images from the monorepo instead of building on the VPS.

wud

WUD runs hybrid updates

App images auto-roll on new tags; Postgres/Valkey/Traefik stay notify-only for manual review.

route

Traefik keeps one origin

The SPA lives on / and the API on /api/* behind one apex, one cert, and one browser security model.

Single-host first

One VPS carries real traffic for years; scale out when you need it.

Pre-built images from GHCR

Reproducible deploys; the VPS pulls images instead of running npm install on box.

WUD hybrid policy

api/ui auto-deploy from GHCR tags; base services stay notify-only and human-reviewed.

Traefik + ACME

TLS on the origin without a separate load-balancer bill.

Same-origin path routing on the apex

One cert for / and /api/*; no api. subdomain.

Cloudflare-only ingress on 80/443

Only Cloudflare reaches the origin; port scans hit a closed firewall.

Multi-stage non-root `Dockerfile.prod`

Small runtime image without a shell in prod.

Three things need configuring before the first deploy. The release workflows handle everything else from your repo 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 repo 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 fresh template clone works without secret setup.

After the first publish, make the GHCR package public so the VPS can pull without credentials:

  1. Go to your repo’s Packages tab and open both containers: <repo>-api and <repo>-ui.
  2. Package settings → Change visibility → Public.

Without this, downstream consumers (your VPS) need a pull credential.

2. Point the prod compose stack at your images

Section titled “2. Point the prod compose stack at your images”

In infra/compose/compose/.env:

Terminal window
IMAGE_OWNER=acme
API_IMAGE_NAME=acme-stack-api
UI_IMAGE_NAME=acme-stack-ui

Kept 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.example

The 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:

  • Repo created from the BoringStack template (Use this template on boringstack-xyz/boringstack) under your org/user.
  • First API and UI images published to GHCR (push to main, then watch each Actions tab).
  • GHCR packages set to Public, or the VPS has a pull credential.
  • 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.
  • Firewall & TLS verified: Cloudflare can reach the origin, direct requests cannot.
  • Backups configured and at least one restore drill run.
  • Cloudflare Email setup completed if Cloudflare is your outbound mail provider.
  • Optional OpenTofu path: terraform.tfvars has your repo URL, 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:

  1. Provision a Ubuntu VPS with Docker installed.
  2. Clone the repo you created from the template onto the VPS.
  3. Write compose/.env with PUBLIC_UI_HOST, ACME_EMAIL, IMAGE_OWNER, and the rest from .env.example.
  4. STACK=prod ./scripts/compose-up.sh pull && STACK=prod ./scripts/compose-up.sh up -d.
  5. Run the Firewall & TLS runbook to verify Cloudflare-only ingress.

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:

Publish an app image
$ git push origin main

ok  release.yml builds ghcr.io/.../...:latest
ok  release.yml also publishes :sha-<7> and semver tags when present

WUD watches latest. App containers (api, ui) are updated automatically. For base-image updates and infra changes, apply manually:

Apply on the VPS
$ ssh <vps>
$ cd /opt/boringstack/infra
$ docker compose pull
$ docker compose up -d

For changes to the infra YAML or env vars, git pull the monorepo on the VPS then re-run compose up -d.

WUD hybrid mode (default)

WITH_WUD=1 in prod: app images auto-pull + auto-recreate; base images send notifications only.

Notify-only for everything

Disable app auto-deploy labels if you want full manual rollouts.

Pin to a specific tag

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.

Terminal window
# On the VPS:
echo "API_IMAGE_TAG=sha-<previous-7>" >> compose/.env # or a previous :<semver>
docker compose pull
docker compose up -d

Postgres 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:

  1. Vertical: bigger VPS, bump POSTGRES_LIMITS_*.
  2. Managed Postgres: Neon, Supabase, Crunchy, RDS. App stays on the VPS.
  3. Replicate the API horizontally behind a real load balancer.
  4. Kubernetes. Separate template planned; not in this repo because mixing Compose + cluster YAML is exactly the confusion BoringStack avoids.
Hetzner Cloud

EU-friendly pricing and network. New accounts may sit in fraud review for a day or two.

OVH

Low cost with IPv4 included. Prefer the API over the web console for automation.

DigitalOcean

Polished UX and docs. Compare specs against Hetzner/OVH for your workload.

Your own metal

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.