Add staging + PR-preview environments on the flagship

Implements the staging-environments OpenSpec change. Persistent staging at
staging.trails.cool / planner.staging.trails.cool deploys from main; PR
opens get a journal-only preview at pr-<N>.staging.trails.cool that shares
the persistent planner. cd-staging.yml builds tagged images, manages
per-PR Postgres databases and Caddyfile snippets, evicts the oldest
preview at the cap of 3, and tears everything down on PR close.
staging-cleanup.yml runs weekly to sweep orphaned previews.

DNS records (staging + *.staging A/AAAA) already applied to production
via tofu.

Caddy approach: per-PR Caddyfile snippets imported from /etc/caddy/sites/
and reloaded on each PR event — no wildcard / on-demand TLS, no router
service. Production compose gains a trails-shared network for the staging
project to reach Postgres, and host.docker.internal on Caddy so it can
reverse-proxy staging containers published on the host loopback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-03 22:03:38 +02:00
parent 795ec2215c
commit c8a7a0b253
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
11 changed files with 818 additions and 29 deletions

View file

@ -69,3 +69,52 @@ planner.{$DOMAIN:trails.cool} {
lb_try_interval 250ms
}
}
# ── Staging ──────────────────────────────────────────────────────────────
# Persistent staging instance. CSP allow-lists hardcode `staging.trails.cool`
# rather than `{$DOMAIN}` because the Caddy container runs with the
# production DOMAIN env (`trails.cool`); staging blocks need their own domain
# baked in. Upstreams are reached via `host.docker.internal` because the
# staging compose project publishes its containers on the host's loopback
# (127.0.0.1:3100/3101) rather than joining the production Caddy network.
staging.{$DOMAIN:trails.cool} {
import security_headers
import block_scanners
log {
output stdout
format json
}
reverse_proxy host.docker.internal:3100 {
lb_try_duration 30s
lb_try_interval 250ms
}
}
planner.staging.{$DOMAIN:trails.cool} {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
# connect-src includes wss + https://*.staging so PR-preview journals
# (pr-N.staging.trails.cool) can use this shared planner. PR previews
# are journal-only; this is the planner they all talk to.
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' wss://*.staging.{$DOMAIN:trails.cool} https://*.staging.{$DOMAIN:trails.cool} https://staging.{$DOMAIN:trails.cool} https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';"
}
import block_scanners
log {
output stdout
format json
}
reverse_proxy host.docker.internal:3101 {
lb_try_duration 30s
lb_try_interval 250ms
}
}
# Per-PR preview snippets are written by cd-staging.yml into
# /etc/caddy/sites/pr-<N>.caddyfile and picked up here on Caddy reload. The
# glob is allowed to match nothing — Caddy treats an empty match as a no-op.
import /etc/caddy/sites/*.caddyfile

View file

@ -0,0 +1,100 @@
# Staging / PR-preview compose file.
#
# Used for both the persistent staging stack and ephemeral PR previews.
# The cd-staging.yml workflow picks the project name and fills in the host
# ports + DOMAIN + STAGING_DATABASE from the PR number (or "staging" for the
# persistent stack):
#
# # Persistent staging
# PROJECT=trails-staging
# JOURNAL_HOST_PORT=3100 PLANNER_HOST_PORT=3101 \
# DOMAIN=staging.trails.cool STAGING_DATABASE=trails_staging \
# docker compose -f docker-compose.staging.yml -p $PROJECT up -d
#
# # PR 123 preview (port = 3200 + 2N for journal, 3201 + 2N for planner)
# PROJECT=trails-pr-123
# JOURNAL_HOST_PORT=3446 PLANNER_HOST_PORT=3447 \
# DOMAIN=pr-123.staging.trails.cool STAGING_DATABASE=trails_pr_123 \
# JOURNAL_IMAGE_TAG=pr-123 PLANNER_IMAGE_TAG=pr-123 \
# docker compose -f docker-compose.staging.yml -p $PROJECT up -d
#
# `trails-shared` is created by the production compose file (see
# docker-compose.yml) so staging/preview containers can reach the production
# `postgres` host. BRouter lives on a separate host and is reached over
# vSwitch using the production-shared BROUTER_URL / BROUTER_AUTH_TOKEN.
#
# Container names are not pinned — Docker Compose generates them from the
# project name (e.g. `trails-pr-123-journal-1`), which keeps each preview
# isolated without manual naming.
services:
journal:
image: ghcr.io/trails-cool/journal:${JOURNAL_IMAGE_TAG:-latest}
restart: unless-stopped
ports:
- "127.0.0.1:${JOURNAL_HOST_PORT:?JOURNAL_HOST_PORT must be set}:3000"
networks:
- trails-shared
environment:
DOMAIN: ${DOMAIN:?DOMAIN must be set}
ORIGIN: https://${DOMAIN}
# PR previews override this to point at the persistent staging
# planner (planner.staging.trails.cool). Persistent staging defaults
# to its own planner subdomain.
PLANNER_URL: ${PLANNER_URL:-https://planner.${DOMAIN}}
IS_FLAGSHIP: ""
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/${STAGING_DATABASE:?STAGING_DATABASE must be set}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set}
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
NODE_ENV: production
PORT: 3000
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
SMTP_URL: ""
SMTP_FROM: trails.cool staging <noreply@staging.trails.cool>
WAHOO_CLIENT_ID: ""
WAHOO_CLIENT_SECRET: ""
WAHOO_WEBHOOK_TOKEN: ""
DEMO_BOT_ENABLED: ""
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
deploy:
resources:
limits:
memory: 256M
planner:
image: ghcr.io/trails-cool/planner:${PLANNER_IMAGE_TAG:-latest}
# Only the persistent staging stack runs a planner. PR previews are
# journal-only and point their PLANNER_URL at the persistent
# planner.staging.trails.cool. Saves ~256MB per active preview.
profiles: ["persistent"]
restart: unless-stopped
ports:
- "127.0.0.1:${PLANNER_HOST_PORT:-3101}:3001"
networks:
- trails-shared
environment:
BROUTER_URL: ${BROUTER_URL:?BROUTER_URL must be set}
BROUTER_AUTH_TOKEN: ${BROUTER_AUTH_TOKEN:?BROUTER_AUTH_TOKEN must be set}
OVERPASS_URLS: ${OVERPASS_URLS:-https://lz4.overpass-api.de/api/interpreter,https://overpass-api.de/api/interpreter}
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/${STAGING_DATABASE}
NODE_ENV: production
PORT: 3001
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
deploy:
resources:
limits:
memory: 256M
networks:
trails-shared:
external: true
name: trails-shared

View file

@ -6,10 +6,19 @@ services:
- "80:80"
- "443:443"
- "443:443/udp"
# host.docker.internal lets Caddy reverse-proxy to staging / PR-preview
# containers, which publish on the host's loopback (e.g. 127.0.0.1:3100).
# Linux requires `host-gateway` to make this name resolve.
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
DOMAIN: ${DOMAIN:-trails.cool}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# Per-PR staging snippets dropped in by cd-staging.yml. Bind mount
# auto-creates the host directory if it doesn't exist, so a fresh
# server doesn't need pre-provisioning.
- ./sites:/etc/caddy/sites:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
@ -90,6 +99,12 @@ services:
postgres:
image: postgis/postgis:16-3.4
restart: unless-stopped
# Joined to the shared network so the staging compose project can reach
# this instance by hostname `postgres`. Production services keep using
# the default network as before.
networks:
- default
- trails-shared
environment:
POSTGRES_USER: trails
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-trails}
@ -226,6 +241,16 @@ services:
# - garage_data:/var/lib/garage
# - ./garage.toml:/etc/garage.toml:ro
networks:
# Default project network — kept implicit for production services.
default:
# Created by this compose project with a fixed (un-namespaced) name so the
# staging compose project can attach to it via `external: true`. Only
# postgres is joined here on the production side; staging containers join
# to reach postgres by hostname.
trails-shared:
name: trails-shared
volumes:
caddy_data:
caddy_config:

View file

@ -0,0 +1,29 @@
# Staging environment variables. The cd-staging.yml workflow assembles
# these from SOPS secrets + workflow-injected values and ships them to the
# server as `infrastructure/staging.env`. Keep this file in sync with the
# `environment:` blocks in `docker-compose.staging.yml`.
#
# For PR previews the same template is reused, with DOMAIN, STAGING_DATABASE,
# and image tags overridden per-PR by the workflow.
# Domain the staging stack serves at. Persistent staging uses
# staging.trails.cool; PR previews use pr-<n>.staging.trails.cool.
DOMAIN=staging.trails.cool
# Database name on the shared production Postgres. Persistent staging uses
# trails_staging; PR previews use trails_pr_<n>.
STAGING_DATABASE=trails_staging
# Inherited from SOPS secrets.app.env (decrypted in the workflow):
POSTGRES_PASSWORD=
JWT_SECRET=
SESSION_SECRET=
BROUTER_URL=
BROUTER_AUTH_TOKEN=
# Image tag to deploy. `latest` for staging, `pr-<n>` for PR previews.
JOURNAL_IMAGE_TAG=latest
PLANNER_IMAGE_TAG=latest
# Sentry release SHA, set by the workflow.
SENTRY_RELEASE=

View file

@ -191,6 +191,42 @@ resource "hcloud_zone_rrset" "planner_aaaa" {
records = [{ value = hcloud_server.trails.ipv6_address }]
}
# Staging persistent journal at staging.trails.cool, persistent planner
# at planner.staging.trails.cool, and PR previews at pr-<N>.staging.trails.cool
# (covered by the wildcard). All resolve to the flagship; Caddy routes per-host.
resource "hcloud_zone_rrset" "staging_a" {
zone = "trails.cool"
name = "staging"
type = "A"
ttl = 300
records = [{ value = hcloud_server.trails.ipv4_address }]
}
resource "hcloud_zone_rrset" "staging_aaaa" {
zone = "trails.cool"
name = "staging"
type = "AAAA"
ttl = 300
records = [{ value = hcloud_server.trails.ipv6_address }]
}
resource "hcloud_zone_rrset" "staging_wildcard_a" {
zone = "trails.cool"
name = "*.staging"
type = "A"
ttl = 300
records = [{ value = hcloud_server.trails.ipv4_address }]
}
resource "hcloud_zone_rrset" "staging_wildcard_aaaa" {
zone = "trails.cool"
name = "*.staging"
type = "AAAA"
ttl = 300
records = [{ value = hcloud_server.trails.ipv6_address }]
}
# Internal wildcard all *.internal.trails.cool resolves to the server.
# Caddy handles routing per-subdomain. No individual DNS entries needed
# for internal services (Grafana, Prometheus, etc.)