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>
120 lines
4.5 KiB
Caddyfile
120 lines
4.5 KiB
Caddyfile
{
|
||
servers {
|
||
metrics
|
||
}
|
||
admin 0.0.0.0:2019
|
||
}
|
||
|
||
(security_headers) {
|
||
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=()"
|
||
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: https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';"
|
||
}
|
||
}
|
||
|
||
(block_scanners) {
|
||
@scanners path /.env* /.git* /wp-* /wp-admin* /admin* /config.* /backup* /.aws* /.docker* /composer* /vendor* *.php *.asp
|
||
respond @scanners 403
|
||
}
|
||
|
||
{$DOMAIN:trails.cool} {
|
||
import security_headers
|
||
import block_scanners
|
||
log {
|
||
output stdout
|
||
format json
|
||
}
|
||
reverse_proxy journal:3000 {
|
||
# During an `apps` deploy the journal container is briefly down
|
||
# (~10–30s) while compose swaps containers. Without these,
|
||
# Caddy returns 502 immediately and the `caddy-502-rate` alert
|
||
# trips on every deploy. With them, Caddy holds and retries
|
||
# against the upstream for up to 30s — restart becomes
|
||
# invisible to clients. A real outage longer than 30s still
|
||
# 502s and correctly trips the alert.
|
||
lb_try_duration 30s
|
||
lb_try_interval 250ms
|
||
}
|
||
}
|
||
|
||
www.{$DOMAIN:trails.cool} {
|
||
redir https://{$DOMAIN:trails.cool}{uri} permanent
|
||
}
|
||
|
||
grafana.internal.{$DOMAIN:trails.cool} {
|
||
reverse_proxy grafana:3000
|
||
}
|
||
|
||
planner.{$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=()"
|
||
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: https://{$DOMAIN:trails.cool} https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';"
|
||
}
|
||
import block_scanners
|
||
log {
|
||
output stdout
|
||
format json
|
||
}
|
||
reverse_proxy planner:3001 {
|
||
# Same rationale as the journal block — see the comment there.
|
||
lb_try_duration 30s
|
||
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
|