Self-hosted instances no longer inherit the trails.cool flagship Sentry DSNs. Code paths now read DSN strictly from env — unset = no Sentry init, no events sent. Flagship continues to report unchanged: cd-apps.yml and cd-staging.yml inject the public DSNs as workflow env vars, which feed into: - runtime via \`infrastructure/app.env\` / \`staging.env\` (\`SENTRY_DSN_JOURNAL\` / \`SENTRY_DSN_PLANNER\` → compose env per service) - build time via docker build-arg \`VITE_SENTRY_DSN\` (journal only; planner has no client Sentry init). Sentry DSNs are public-by-design (transmitted unencrypted from the client JS bundle), so embedding them as plaintext workflow env vars is no worse than the runtime exposure. Forks should replace these with their own DSNs or remove the workflow env lines to ship Sentry-free. Files changed: - apps/journal/server.ts: \`process.env.SENTRY_DSN\` only, no fallback - apps/planner/server.ts: same - apps/journal/app/lib/sentry.client.ts: \`import.meta.env.VITE_SENTRY_DSN\` only; falsy = skip init - apps/journal/Dockerfile: new \`ARG VITE_SENTRY_DSN\` baked into build - infrastructure/docker-compose.yml: pass \`SENTRY_DSN\` per service - infrastructure/docker-compose.staging.yml: same - .github/workflows/cd-apps.yml: workflow env + build-arg + app.env echo - .github/workflows/cd-staging.yml: same Full repo: pnpm typecheck, pnpm lint, pnpm test, journal build all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
267 lines
10 KiB
YAML
267 lines
10 KiB
YAML
services:
|
|
caddy:
|
|
image: caddy:2
|
|
restart: unless-stopped
|
|
ports:
|
|
- "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:
|
|
- journal
|
|
- planner
|
|
|
|
journal:
|
|
image: ghcr.io/trails-cool/journal:latest
|
|
restart: unless-stopped
|
|
environment:
|
|
DOMAIN: ${DOMAIN:-trails.cool}
|
|
ORIGIN: https://${DOMAIN:-trails.cool}
|
|
PLANNER_URL: https://planner.${DOMAIN:-trails.cool}
|
|
# "true" only on the flagship trails.cool instance. The Journal
|
|
# home renders the project marketing block when set, and the
|
|
# "Powered by trails.cool" footer link when not. Default empty =
|
|
# self-host, which is the safer default.
|
|
IS_FLAGSHIP: ${IS_FLAGSHIP:-}
|
|
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@postgres:5432/trails
|
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set in SOPS secrets.app.env}
|
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set in SOPS secrets.app.env}
|
|
NODE_ENV: production
|
|
PORT: 3000
|
|
# SENTRY_DSN per service (journal + planner have separate Sentry
|
|
# projects on the same org). Empty = no Sentry init (correct
|
|
# default for self-hosted instances). See cd-apps.yml for how the
|
|
# flagship populates this.
|
|
SENTRY_DSN: ${SENTRY_DSN_JOURNAL:-}
|
|
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
|
|
SMTP_URL: ${SMTP_URL:-}
|
|
SMTP_FROM: ${SMTP_FROM:-trails.cool <noreply@trails.cool>}
|
|
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
|
|
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
|
|
WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-}
|
|
INTEGRATION_SECRET: ${INTEGRATION_SECRET:?INTEGRATION_SECRET must be set in SOPS secrets.app.env}
|
|
# Demo-activity-bot. Disabled by default everywhere; flip to "true"
|
|
# only in prod. When unset, DEMO_BOT_PERSONA uses the built-in
|
|
# Bruno/Berlin persona. See docs/demo-persona.md for the schema.
|
|
DEMO_BOT_ENABLED: ${DEMO_BOT_ENABLED:-}
|
|
DEMO_BOT_RETENTION_DAYS: ${DEMO_BOT_RETENTION_DAYS:-}
|
|
DEMO_BOT_REGION: ${DEMO_BOT_REGION:-}
|
|
DEMO_BOT_PERSONA: ${DEMO_BOT_PERSONA:-}
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 3
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
|
|
planner:
|
|
image: ghcr.io/trails-cool/planner:latest
|
|
restart: unless-stopped
|
|
environment:
|
|
# Required: points the Planner at the dedicated BRouter host over
|
|
# vSwitch. Set in SOPS `secrets.app.env` (BROUTER_URL line). Using
|
|
# `:?` so a missing value fails the compose up loudly rather than
|
|
# silently pointing at a nonexistent `brouter:17777` service.
|
|
BROUTER_URL: ${BROUTER_URL:?BROUTER_URL must be set in SOPS secrets.app.env}
|
|
# Shared secret the Planner attaches as X-BRouter-Auth on every
|
|
# BRouter request. The Caddy sidecar on the dedicated host
|
|
# enforces this header. Set in SOPS secrets.app.env.
|
|
BROUTER_AUTH_TOKEN: ${BROUTER_AUTH_TOKEN:?BROUTER_AUTH_TOKEN must be set in SOPS secrets.app.env}
|
|
# Ordered failover list for the Overpass proxy. The code defaults
|
|
# to the same pair if unset; declaring it here makes the prod
|
|
# upstream explicit and easy to reshuffle via env when a
|
|
# community instance misbehaves. Override per-env with
|
|
# OVERPASS_URLS in the SOPS file.
|
|
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/trails
|
|
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
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
|
|
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:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}
|
|
POSTGRES_DB: trails
|
|
volumes:
|
|
- pgdata:/var/lib/postgresql/data
|
|
- ./postgres/init-grafana-user.sql:/docker-entrypoint-initdb.d/init-grafana-user.sql:ro
|
|
command:
|
|
- "postgres"
|
|
- "-c"
|
|
- "shared_preload_libraries=pg_stat_statements"
|
|
- "-c"
|
|
- "pg_stat_statements.track=all"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U trails"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
postgres-exporter:
|
|
image: prometheuscommunity/postgres-exporter:latest
|
|
restart: unless-stopped
|
|
environment:
|
|
DATA_SOURCE_NAME: postgresql://trails:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@postgres:5432/trails?sslmode=disable
|
|
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/postgres-exporter/queries.yml
|
|
volumes:
|
|
- ./postgres/queries.yml:/etc/postgres-exporter/queries.yml:ro
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
|
|
node-exporter:
|
|
image: prom/node-exporter:latest
|
|
restart: unless-stopped
|
|
pid: host
|
|
volumes:
|
|
- /proc:/host/proc:ro
|
|
- /sys:/host/sys:ro
|
|
- /:/rootfs:ro
|
|
command:
|
|
- "--path.procfs=/host/proc"
|
|
- "--path.sysfs=/host/sys"
|
|
- "--path.rootfs=/rootfs"
|
|
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
|
|
|
|
cadvisor:
|
|
image: gcr.io/cadvisor/cadvisor:latest
|
|
restart: unless-stopped
|
|
privileged: true
|
|
volumes:
|
|
- /:/rootfs:ro
|
|
- /var/run:/var/run:ro
|
|
- /sys:/sys:ro
|
|
- /var/lib/docker/:/var/lib/docker:ro
|
|
- /dev/disk/:/dev/disk:ro
|
|
|
|
promtail:
|
|
image: grafana/promtail:latest
|
|
restart: unless-stopped
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
|
- ./promtail/promtail-config.yml:/etc/promtail/config.yml:ro
|
|
command: ["-config.file=/etc/promtail/config.yml"]
|
|
depends_on:
|
|
- loki
|
|
|
|
prometheus:
|
|
image: prom/prometheus:latest
|
|
restart: unless-stopped
|
|
volumes:
|
|
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
|
- prometheus_data:/prometheus
|
|
command:
|
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
|
- "--storage.tsdb.retention.time=15d"
|
|
- "--storage.tsdb.retention.size=1GB"
|
|
|
|
loki:
|
|
image: grafana/loki:latest
|
|
restart: unless-stopped
|
|
volumes:
|
|
- ./loki/loki-config.yml:/etc/loki/local-config.yaml:ro
|
|
- loki_data:/loki
|
|
command: ["-config.file=/etc/loki/local-config.yaml"]
|
|
# Publish only on the vSwitch IP so Promtail running on the
|
|
# dedicated BRouter host can push logs in. Hetzner Cloud firewall
|
|
# still blocks 3100 from the public internet. Internal services on
|
|
# this host reach Loki via the docker network as before.
|
|
ports:
|
|
- "10.0.0.2:3100:3100"
|
|
|
|
grafana:
|
|
image: grafana/grafana:latest
|
|
restart: unless-stopped
|
|
environment:
|
|
GF_SERVER_ROOT_URL: https://grafana.internal.${DOMAIN:-trails.cool}
|
|
GF_AUTH_GITHUB_ENABLED: "true"
|
|
GF_AUTH_GITHUB_CLIENT_ID: ${GF_AUTH_GITHUB_CLIENT_ID:-}
|
|
GF_AUTH_GITHUB_CLIENT_SECRET: ${GF_AUTH_GITHUB_CLIENT_SECRET:-}
|
|
GF_AUTH_GITHUB_ALLOWED_ORGANIZATIONS: trails-cool
|
|
GF_AUTH_GITHUB_SCOPES: user:email,read:org
|
|
GF_AUTH_OAUTH_ALLOW_INSECURE_EMAIL_LOOKUP: "true"
|
|
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
|
# New GitHub-authenticated users land as Editor, not the default
|
|
# Viewer, so they can use Explore (ad-hoc Loki/Prometheus queries)
|
|
# without an admin promotion step. Access is already gated by the
|
|
# GF_AUTH_GITHUB_ALLOWED_ORGANIZATIONS allowlist above.
|
|
GF_USERS_AUTO_ASSIGN_ORG_ROLE: Editor
|
|
GF_SMTP_ENABLED: "true"
|
|
GF_SMTP_HOST: ${GF_SMTP_HOST:-}
|
|
GF_SMTP_USER: ${GF_SMTP_USER:-}
|
|
GF_SMTP_PASSWORD: ${GF_SMTP_PASSWORD:-}
|
|
GF_SMTP_FROM_ADDRESS: noreply@trails.cool
|
|
GF_SMTP_FROM_NAME: trails.cool Grafana
|
|
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD:-}
|
|
# Pushover app + user keys consumed by alerts.yml provisioning.
|
|
# App token identifies the trails.cool Pushover application; per-user
|
|
# keys receive the notifications. Keys (un)set in SOPS secrets.infra.env.
|
|
PUSHOVER_API_TOKEN: ${PUSHOVER_API_TOKEN:-}
|
|
PUSHOVER_USER_KEY_ULLRICH: ${PUSHOVER_USER_KEY_ULLRICH:-}
|
|
volumes:
|
|
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
|
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
|
- grafana_data:/var/lib/grafana
|
|
depends_on:
|
|
- prometheus
|
|
- loki
|
|
|
|
# garage:
|
|
# image: dxflrs/garage:v1.0
|
|
# restart: unless-stopped
|
|
# volumes:
|
|
# - 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:
|
|
pgdata:
|
|
prometheus_data:
|
|
loki_data:
|
|
grafana_data:
|