# 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 # Published on 0.0.0.0 (not 127.0.0.1) so the production Caddy container # can reach it via host.docker.internal — Docker's host-gateway resolves # to the bridge IP, not loopback. The Hetzner Cloud firewall blocks all # inbound on ports 3000+ from the public internet, so this is effectively # internal-only despite the bind address. ports: - "${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:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@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_DSN: ${SENTRY_DSN_JOURNAL:-} SENTRY_RELEASE: ${SENTRY_RELEASE:-} SMTP_URL: "" SMTP_FROM: trails.cool staging WAHOO_CLIENT_ID: "" WAHOO_CLIENT_SECRET: "" WAHOO_WEBHOOK_TOKEN: "" GARMIN_CLIENT_ID: ${GARMIN_CLIENT_ID:-} GARMIN_CLIENT_SECRET: ${GARMIN_CLIENT_SECRET:-} DEMO_BOT_ENABLED: "" # Federation (social-federation rollout). Enabled by cd-staging.yml # for persistent staging (12.2) and for PR previews (12.4 — every # preview is a second live instance staging can follow across). FEDERATION_ENABLED: ${FEDERATION_ENABLED:-} FEDERATION_KEY_ENCRYPTION_KEY: ${FEDERATION_KEY_ENCRYPTION_KEY:-} FEDERATION_LOG_LEVEL: ${FEDERATION_LOG_LEVEL:-} 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 # Same rationale as the journal port — see the comment there. ports: - "${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:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@postgres:5432/${STAGING_DATABASE} NODE_ENV: production PORT: 3001 SENTRY_DSN: ${SENTRY_DSN_PLANNER:-} 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: # Staging/preview services attach only to trails-shared — created (and # IPv6-enabled) by the production compose project. There is no per-project # default network here, so v6 egress comes from trails-shared itself. trails-shared: external: true name: trails-shared