This guide deploys Breeze with TLS, hardened container settings, monitoring, and logging using:
deploy/docker-compose.prod.ymlscripts/prod/deploy.sh
Breeze ships two Compose configurations:
| Path | Files | When to use |
|---|---|---|
| Simple self-host | docker-compose.yml + .env.example (repo root) |
Single-host self-hosted deploys behind your own TLS reverse proxy. Tag-pinned images by default (override with digests for higher assurance). Uses the *_IMAGE_REF variable schema. |
| Strict production (this doc) | deploy/docker-compose.prod.yml + deploy/.env.example |
Production rollouts with Cloudflare Tunnel, hardened ACLs, monitoring/logging, and mandatory digest-pinned images. Uses the *_IMAGE_DIGEST variable schema (Breeze images) and *_IMAGE_REF (third-party). The hardening check (scripts/security/check-supply-chain-hardening.sh) refuses to ship a release with mutable tags in this path. |
The two paths use different variable names intentionally — they are not interchangeable. If you copied .env from one path, do not point it at the other Compose file.
- Linux host with Docker Engine + Docker Compose plugin
- Node.js 20+ and
pnpm(for running DB migrations from source) - DNS
A/AAAArecord for your domain pointing to the host - Ports
80and443open to the internet (for ACME + HTTPS)
cp deploy/.env.example .env.prodSet at least these values in .env.prod:
BREEZE_DOMAINACME_EMAILBREEZE_VERSIONBREEZE_API_IMAGE_DIGESTBREEZE_WEB_IMAGE_DIGESTBREEZE_BINARIES_IMAGE_DIGESTCADDY_IMAGE_REFCLOUDFLARED_IMAGE_REFREDIS_IMAGE_REFCOTURN_IMAGE_REFBILLING_IMAGE_REFDATABASE_URLREDIS_PASSWORDJWT_SECRETAGENT_ENROLLMENT_SECRETAPP_ENCRYPTION_KEYMFA_ENCRYPTION_KEYENROLLMENT_KEY_PEPPERMFA_RECOVERY_CODE_PEPPERRELEASE_ARTIFACT_MANIFEST_PUBLIC_KEYSBREEZE_BOOTSTRAP_ADMIN_EMAIL(first boot only, when the users table is empty)BREEZE_BOOTSTRAP_ADMIN_PASSWORD(first boot only; generate a one-time value withopenssl rand -base64 32)METRICS_SCRAPE_TOKENPUBLIC_API_URL(example:https://app.example.com/api/v1)GRAFANA_ADMIN_PASSWORD
BREEZE_*_IMAGE_DIGEST values are sha256:<64hex> strings — not full image refs. The Compose file prepends ghcr.io/lanternops/breeze/<name>@ automatically.
# Replace 0.67.1 with the release you intend to deploy.
TAG=0.67.1
for img in api web portal binaries; do
digest=$(docker buildx imagetools inspect "ghcr.io/lanternops/breeze/$img:$TAG" \
--format '{{json .Manifest}}' | jq -r .digest)
echo "BREEZE_${img^^}_IMAGE_DIGEST=$digest"
doneThird-party *_IMAGE_REF values are full digest-pinned refs (name@sha256:<64hex>):
docker buildx imagetools inspect caddy:2-alpine \
--format 'caddy@{{json .Manifest | fromjson | .digest}}' | tr -d '"'Browse current releases at https://github.com/orgs/LanternOps/packages?repo_name=breeze.
The bootstrap admin password is not logged by the API. If these values are missing on first boot against an empty production database, the API refuses to seed a default admin. After the initial admin signs in and completes setup, remove BREEZE_BOOTSTRAP_ADMIN_EMAIL and BREEZE_BOOTSTRAP_ADMIN_PASSWORD from the production environment.
Production compose intentionally does not run Watchtower or mount the Docker socket. Rollouts should be done by updating the digest-pinned image values above and running the deploy script through the normal release process.
./scripts/prod/deploy.sh .env.prodWhat the script does:
- Validates required env vars and digest-pinned image refs.
- Validates the production Compose configuration.
- Starts Redis and waits for readiness.
- Runs
pnpm db:migrateagainstDATABASE_URL. - Starts the full stack (edge, app, billing, monitoring, Loki/Promtail).
- Runs smoke checks.
- App:
https://<BREEZE_DOMAIN>/health - API through edge:
https://<BREEZE_DOMAIN>/api/v1/alerts(auth required) - Customer portal:
https://<BREEZE_DOMAIN>/portal/login(renders the portal login) - Grafana (local bind):
http://127.0.0.1:${GRAFANA_PORT:-3000} - Prometheus (local bind):
http://127.0.0.1:${PROMETHEUS_PORT:-9090}
You can also run:
./scripts/ops/verify-monitoring.sh .env.prodredisis not host-published.prometheus,grafana,alertmanager,loki, andpromtailbind to127.0.0.1only.- Public ingress is only through Caddy on
80/443. - In Cloudflare Tunnel mode, Caddy trusts client-IP headers only from the configured
BREEZE_CLOUDFLARED_IP, and the API trusts forwarded headers only fromBREEZE_CADDY_IP. KeepCADDY_TRUSTED_PROXIESandTRUSTED_PROXY_CIDRSpinned to exact proxy hops, not broad private ranges. - An exact-host
TRUSTED_PROXY_CIDRSpin REQUIRES the proxy container to have a static IP. Docker bridge IPs are not stable across container recreates, so a/32pin is only safe when the proxy service is given a fixedipv4_addresson the compose network —deploy/docker-compose.prod.ymlalready does this for Caddy (ipv4_address: ${BREEZE_CADDY_IP:-172.30.0.11}, matching the defaultTRUSTED_PROXY_CIDRSpin). If you recreate containers without a static proxy IP, the pin goes stale and client-IP attribution breaks silently: the API (correctly) stops trusting forwarded headers, every per-IP rate limit pools all clients onto the proxy's IP (mass 429s under aggregate load), and audit logs record the proxy IP as every client's source address. The API now detects this at runtime — watch for[proxy-trust] MISCONFIGURATIONwarnings in the API logs and thebreeze_proxy_trust_untrusted_peer_totalPrometheus counter; both should stay at zero in a healthy deployment. - Container resource limits, restart policies, and no-new-privileges are configured in prod compose.
- Customer portal (
apps/portal) runs as its ownportalservice and is served under the/portalpath prefix on the main domain — no dedicated hostname, DNS record, or TLS cert is required. Caddy routes/portal/*toportal:4322(ahead of the web catch-all); the portal calls the API same-origin via/api/*, so there is no CORS surface. The base path is baked into the portal image at build time (PORTAL_BASE_PATH, default/portal) — changing it requires an image rebuild, and the Caddyfile carve-out +PUBLIC_PORTAL_URLmust stay in sync.PUBLIC_PORTAL_URL(defaulthttps://<BREEZE_DOMAIN>/portal) is what the API uses to mint customer-facing links (e.g. quote acceptance emails). Per-org custom portal domains are not served yet. - Manual droplet rollout note: a
BREEZE_VERSIONbump only swaps theapi/webimages. To light up the portal on an existing droplet you must also: addBREEZE_PORTAL_IMAGE_REF(orBREEZE_PORTAL_IMAGE_DIGESTfor the digest-pinned prod compose) andPUBLIC_PORTAL_URLto/opt/breeze/.env, ensure theportalservice + the/portalcarve-out are present in the deployeddocker-compose.yml/Caddyfile.prod, thendocker compose up -d portal && docker compose restart caddy.