Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
# infra/authentik/.env.example
#
# Copy to `.env` and fill before deploying via Coolify. Never commit the real
# .env — it is git-ignored (see .gitignore). Use Coolify's secret vault to
# manage production values; this file is only the template.
#
# Generate the secrets ONCE with the commands listed inline. Losing
# AUTHENTIK_SECRET_KEY destroys the encrypted-fields + active sessions of
# the entire instance — back it up to an offline password vault.
# -----------------------------------------------------------------------------
# Authentik image pin (mirrors UPSTREAM.md)
# -----------------------------------------------------------------------------
AUTHENTIK_TAG=2026.2.3
# -----------------------------------------------------------------------------
# Public URL — must match the Traefik-routed hostname (see traefik.labels.yml)
# -----------------------------------------------------------------------------
AUTHENTIK_BASE_URL=https://auth.boiler.saas-ctrl.com
# Optional: cookie domain for cross-subdomain SSO (only set if you have
# multiple boiler.saas-ctrl.com subdomains that should share the IdP cookie).
AUTHENTIK_COOKIE_DOMAIN=
# -----------------------------------------------------------------------------
# Authentik secret key — THE MOST IMPORTANT SECRET
# -----------------------------------------------------------------------------
# Generate ONCE with: openssl rand -base64 60
# Losing this key invalidates all session cookies AND makes encrypted fields
# (OIDC provider client_secrets, password-stage encrypted user data, etc.)
# unreadable. Store it in Coolify-Vault AND in an offline password vault
# (1Password / Bitwarden / Yubikey). Cannot be rotated without full reinstall.
AUTHENTIK_SECRET_KEY=replace-me-with-60-bytes-of-base64-entropy
# -----------------------------------------------------------------------------
# Postgres (Authentik-internal, NOT shared with the boilerplate app)
# -----------------------------------------------------------------------------
AUTHENTIK_POSTGRESQL__USER=authentik
AUTHENTIK_POSTGRESQL__NAME=authentik
# Generate with: openssl rand -base64 32
AUTHENTIK_POSTGRESQL__PASSWORD=replace-me-strong-postgres-password
# -----------------------------------------------------------------------------
# Initial admin bootstrap — forced password change on first login
# -----------------------------------------------------------------------------
AUTHENTIK_BOOTSTRAP_EMAIL=ops@example.com
# Generate with: openssl rand -base64 24
# Operator MUST change this on first login via the Authentik UI.
AUTHENTIK_BOOTSTRAP_PASSWORD=replace-me-strong-bootstrap-password
# API-token for bootstrap-time blueprint loading. Generate with:
# openssl rand -hex 32
# After bootstrap.sh succeeds, rotate or delete this token via the Authentik
# admin UI (Directory → Tokens). It grants full admin-API access.
AUTHENTIK_BOOTSTRAP_TOKEN=replace-me-64-hex-chars
# -----------------------------------------------------------------------------
# Telemetry / privacy
# -----------------------------------------------------------------------------
# Disable error-reporting to goauthentik.io — we already have our own Sentry
# (see AUTHENTIK_SENTRY__DSN below) and don't want to leak prod-stack info to
# upstream.
AUTHENTIK_ERROR_REPORTING__ENABLED=false
AUTHENTIK_DISABLE_UPDATE_CHECK=true
AUTHENTIK_DISABLE_STARTUP_ANALYTICS=true
AUTHENTIK_LOG_LEVEL=info
# -----------------------------------------------------------------------------
# Sentry self-hosted DSN (Phase 02 #29). Optional — leave blank to disable.
# DSN format: https://<key>@sentry.boiler.saas-ctrl.com/<project-id>
# Create a "boilerplate-authentik" project in the Sentry UI and paste its DSN.
# -----------------------------------------------------------------------------
AUTHENTIK_SENTRY__DSN=
AUTHENTIK_SENTRY__ENVIRONMENT=staging
# -----------------------------------------------------------------------------
# Email — required for password recovery / email-stage flows
# Leave HOST blank to disable email features; the bootstrap admin can still
# log in via the bootstrap password.
# -----------------------------------------------------------------------------
AUTHENTIK_EMAIL__HOST=
AUTHENTIK_EMAIL__PORT=587
AUTHENTIK_EMAIL__USERNAME=
AUTHENTIK_EMAIL__PASSWORD=
AUTHENTIK_EMAIL__USE_TLS=true
AUTHENTIK_EMAIL__USE_SSL=false
AUTHENTIK_EMAIL__TIMEOUT=10
AUTHENTIK_EMAIL__FROM=authentik@boiler.saas-ctrl.com
# -----------------------------------------------------------------------------
# OIDC client secret for the Toolctrl admin-panel provider
# (loaded by bootstrap/blueprints/03-application-toolctrl-admin.yaml)
# Generate with: openssl rand -hex 32
# After bootstrap, copy this secret into the admin-panel app's .env as
# AUTHENTIK_OIDC_CLIENT_SECRET (consumed in Phase 09 #82).
# -----------------------------------------------------------------------------
TOOLCTRL_ADMIN_OIDC_CLIENT_ID=toolctrl-admin-panel
TOOLCTRL_ADMIN_OIDC_CLIENT_SECRET=replace-me-64-hex-chars
TOOLCTRL_ADMIN_OIDC_REDIRECT_URI=https://admin.boiler.saas-ctrl.com/auth/callback
# -----------------------------------------------------------------------------
# S3 backup target (consumed by infra/authentik/backup/)
# Endpoint format examples:
# Hetzner: https://nbg1.your-objectstorage.com
# AWS: https://s3.eu-central-1.amazonaws.com
# MinIO local: https://minio.boiler.saas-ctrl.com
# -----------------------------------------------------------------------------
AUTHENTIK_S3_BACKUP_ENDPOINT=https://nbg1.your-objectstorage.com
AUTHENTIK_S3_BACKUP_BUCKET=boilerplate-authentik-backups
AUTHENTIK_S3_BACKUP_ACCESS_KEY=replace-me
AUTHENTIK_S3_BACKUP_SECRET_KEY=replace-me
# Optional: GPG recipient fingerprint to encrypt the backups with before
# upload. If left empty, backup.sh falls back to AES256-symmetric with
# AUTHENTIK_SECRET_KEY as passphrase (better than plaintext, but rotate
# carefully — losing the key destroys the backup readability).
AUTHENTIK_BACKUP_GPG_RECIPIENT=
+16
View File
@@ -0,0 +1,16 @@
# Runtime data — never commit
data/
postgres-data/
redis-data/
media/
custom-templates/
certs/
# Secrets
*.env
!.env.example
secrets/
# Local helper artifacts
*.log
.DS_Store
+95
View File
@@ -0,0 +1,95 @@
# `infra/authentik` — Self-Hosted Authentik IdP Stack
This directory contains the artifacts to deploy a **self-hosted Authentik**
identity-provider instance on the staging Hetzner host (`boilerplate-staging`,
see `docs/improvement-plan/archive/STAGING-ACCESS.md`) via the existing
Coolify-managed Docker-Compose runtime.
## Purpose
- Centralise **admin authentication** behind an external IdP (Phase 09 #82)
instead of trusting the application's own user table
- Enforce **MFA-TOTP for super-admins** at the IdP layer (group-policy)
- Provide a **group-based access model** (`toolctrl-admin`, `toolctrl-staff`,
`toolctrl-users`) that the admin panel + future tenant-SSO consume
- Provide a **federation target** for the Backlog #66 customer-tenant-SSO
(second OIDC provider definition is shipped as a disabled placeholder)
- Aggregate **auth-audit events** (login / logout / mfa-enroll /
group-change) so they can be ingested by the Phase 10 audit-log (#67)
The companion ultra-lite alternative (GLAuth, LDAP-only, ~50 MB RAM) is
documented in `docs/operations/authentik-alternatives.md` and is **not**
the default — it lacks OIDC, MFA-enforcement, brand-flows, and blueprints.
## Prerequisites
- Hetzner host with **≥ 2 GB RAM free for the Authentik stack**
(server: ~600 MB, worker: ~400 MB, postgres: ~300 MB, redis: ~150 MB,
plus headroom). The staging host has 30 GiB RAM with plenty of headroom.
- Docker Engine ≥ 24 + Docker Compose v2 (provided by Coolify)
- Traefik v3.6 reverse-proxy from `coolify-proxy` (Let's Encrypt ACME via
the `letsencrypt` cert-resolver)
- DNS record `auth.boiler.saas-ctrl.com` → `46.225.5.93`
(Cloudflare-managed; see `docs/operations/authentik-setup.md` for the
`cf-cli` snippet that creates it)
- Sentry self-hosted stack from Phase 02 #29 reachable at
`sentry.boiler.saas-ctrl.com` (Authentik containers report their own
errors to it via `AUTHENTIK_SENTRY__DSN`)
## Layout
```
infra/authentik/
├── README.md # this file
├── .gitignore # excludes runtime volumes + secrets
├── UPSTREAM.md # pinned Authentik tag + image pin rationale
├── docker-compose.yml # 4-service compose: server, worker, postgresql, redis
├── traefik.labels.yml # canonical Traefik label-set for auth.<domain>
├── .env.example # template for AUTHENTIK_* secrets
├── bootstrap/
│ ├── bootstrap.sh # waits for /-/health/ready/ then loads blueprints
│ └── blueprints/
│ ├── 01-groups.yaml # toolctrl-admin / -staff / -users groups + attrs
│ ├── 02-policies.yaml # password policy + MFA-enforce + brute-force
│ └── 03-application-toolctrl-admin.yaml # OIDC provider for admin panel
├── backup/ # daily backup container
│ ├── Dockerfile
│ ├── backup.sh
│ └── crontab
└── scripts/
└── smoke.sh # post-deploy smoke checks (curl-only)
```
## Deploy
See `docs/operations/authentik-setup.md` for the full Coolify-deploy + DNS +
backup + restore runbook. **Deployment is operator-owned** — this directory
ships the artifacts; the operator runs the Coolify side.
High-level:
1. Operator creates the Coolify "Docker Compose" service against this repo
pointing at `infra/authentik/docker-compose.yml`.
2. Operator copies `.env.example` → Coolify-Vault and fills in secrets
(`AUTHENTIK_SECRET_KEY` from `openssl rand -base64 60`, etc.).
3. Coolify deploy boots postgres, redis, then `server` + `worker`.
4. Operator runs `bootstrap/bootstrap.sh` once against the running stack
to apply blueprints (groups + policies + admin-panel OIDC provider).
5. Operator runs `scripts/smoke.sh` to verify TLS, health, OIDC discovery.
## Versioning
- Authentik pin: `ghcr.io/goauthentik/server:2026.2.3` and
`ghcr.io/goauthentik/proxy:2026.2.3` (see `UPSTREAM.md` for rationale).
- Postgres pin: `postgres:16-alpine`.
- Redis pin: `redis:7-alpine`.
## Scope
- Authentik **server** + **worker** + dedicated **postgresql** + dedicated
**redis** — these databases MUST NOT be shared with the boilerplate
application's own Postgres/Redis (separate compromise blast-radius +
separate backup/restore cycle).
- **No Outposts** — pure OIDC use-case; the reverse-proxy outpost concept
is intentionally out of scope for this phase.
- **No public self-registration** — admin-only invitation flow.
@@ -0,0 +1,52 @@
# Upstream Pin: `goauthentik/authentik`
## Pinned Version
| Field | Value |
| --- | --- |
| Upstream repo | <https://github.com/goauthentik/authentik> |
| Pinned image tag | **`2026.2.3`** |
| Image registry | `ghcr.io/goauthentik/server:2026.2.3` (web/api) and `ghcr.io/goauthentik/proxy:2026.2.3` (optional reverse-proxy outpost, NOT used in this phase) |
| Release line | `2026.2.x` (the latest stable line as of 2026-05-17 per `gh release list --repo goauthentik/authentik`) |
| Why not `2026.5.0-rc2` | RC; we wait for the GA promotion of the `2026.5.x` line before upgrading. |
| Why not `2025.12.5` | Older minor; `2026.2.3` is the latest stable and contains the security + flow-stage fixes we care about. |
| Postgres image | `postgres:16-alpine` (Authentik supports 14/15/16; we standardise on 16 to match the rest of the boilerplate) |
| Redis image | `redis:7-alpine` |
## Why we DO vendor the compose
Unlike the Sentry stack (which has ~70 upstream services and a non-trivial
install script), Authentik's compose surface is small: **4 services**
(server, worker, postgresql, redis). We author our own `docker-compose.yml`
based on the upstream reference at
<https://goauthentik.io/docs/install-config/install/docker-compose> so that:
- Our network topology (Coolify `coolify` external network + internal
`authentik-net`) is explicit, not merged at runtime.
- Volume paths are pinned onto Coolify's `/data/coolify/services/authentik/`
disk plane so backups + log-rotation use the same path as every other
Coolify stack.
- The Postgres + Redis instances stay strictly **Authentik-internal** —
we never share them with the boilerplate app's databases.
- No `ports:` mappings for Postgres/Redis → only reachable on the internal
Docker network.
## Update procedure
To upgrade to a newer `2026.2.x` patch:
1. Update the tag in this file and in `docker-compose.yml`
(`image: ghcr.io/goauthentik/server:<new-tag>` on both `server` and
`worker`).
2. Read upstream release notes
(<https://github.com/goauthentik/authentik/releases>) for any blueprint
schema changes; sync our blueprints in `bootstrap/blueprints/` if any
field was renamed/removed.
3. On staging, trigger a Coolify redeploy of the `authentik` stack. Volumes
are preserved; the `server` container runs schema migrations on boot.
4. Smoke-test via `scripts/smoke.sh` (TLS, health, OIDC discovery).
5. Commit the bumped tag + blueprint deltas as one commit.
For a major version jump (e.g. `2026.2.x → 2026.5.x`) see the runbook in
`docs/operations/authentik-setup.md` — that one is a manual operation
(pre-update backup mandatory; blueprint schema may have shifted).
@@ -0,0 +1,58 @@
# infra/authentik/backup/Dockerfile
#
# Sidecar container that backs up the self-hosted Authentik stack daily:
# - pg_dump --format=custom of the Authentik Postgres
# - tar of the `authentik-media` volume (uploaded user-avatars, branding
# assets, exported blueprints)
# - SECRETS-MANIFEST.txt listing the names of secrets that MUST be backed
# up separately (AUTHENTIK_SECRET_KEY, POSTGRES password, BOOTSTRAP token)
# since the Postgres dump alone is useless without them
# - all artifacts GPG-encrypted (or AES256-symmetric fallback) before
# upload to S3
#
# Triggered by an internal cron (see crontab) so the container stays running
# in the background. Re-uses postgres16-client (matches the postgres:16-alpine
# pin in docker-compose.yml).
FROM alpine:3.20
# --- Runtime deps ------------------------------------------------------------
# postgresql16-client -> pg_dump
# aws-cli -> S3 upload (works with Hetzner Object Storage + MinIO via --endpoint-url)
# rclone -> alternative S3 backend (handy for MinIO-compat)
# gnupg -> GPG encryption of dumps
# tini -> proper signal-handling as PID 1
# bash, coreutils -> POSIX-strict scripts need real coreutils
# tzdata -> consistent UTC timestamps
# busybox-extras (crond)
# curl -> health probe
RUN apk add --no-cache \
postgresql16-client \
aws-cli \
rclone \
gnupg \
tini \
bash \
coreutils \
tar \
tzdata \
busybox-extras \
curl \
&& rm -rf /var/cache/apk/*
ENV TZ=UTC \
BACKUP_HOME=/srv/backup \
PATH=/srv/backup:$PATH
WORKDIR ${BACKUP_HOME}
COPY backup.sh ${BACKUP_HOME}/backup.sh
COPY crontab /etc/crontabs/root
RUN chmod 0755 ${BACKUP_HOME}/backup.sh \
&& mkdir -p ${BACKUP_HOME}/logs ${BACKUP_HOME}/work
# Run cron in the foreground; tini handles SIGTERM cleanly so `docker compose
# down` doesn't strand a half-finished dump.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["crond", "-f", "-L", "/dev/stdout", "-l", "2"]
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# infra/authentik/backup/backup.sh
#
# Daily backup of the self-hosted Authentik stack. Triggered by crond at 03:30
# UTC (see crontab). Idempotent — re-running on the same day overwrites the
# same S3 prefix.
#
# Steps:
# 1. pg_dump --format=custom of the Authentik Postgres
# 2. tar of /media (Authentik media volume, mounted into this container)
# 3. SECRETS-MANIFEST.txt listing the env-var names that MUST be backed up
# out-of-band (Coolify-Vault export)
# 4. GPG-encrypt all three artifacts (recipient OR AES256 fallback)
# 5. Upload to S3 under s3://<bucket>/authentik/YYYY-MM-DD/
#
# Env (sourced from the Compose service `environment` block, which itself
# reads from infra/authentik/.env):
# AUTHENTIK_S3_BACKUP_ENDPOINT S3 endpoint URL
# AUTHENTIK_S3_BACKUP_BUCKET target bucket name
# AUTHENTIK_S3_BACKUP_ACCESS_KEY S3 access key
# AUTHENTIK_S3_BACKUP_SECRET_KEY S3 secret key
# AUTHENTIK_SECRET_KEY fallback symmetric passphrase for GPG
# AUTHENTIK_BACKUP_GPG_RECIPIENT optional GPG recipient fingerprint
#
# AUTHENTIK_PG_HOST default "postgresql" (compose service name)
# AUTHENTIK_PG_PORT default 5432
# AUTHENTIK_POSTGRESQL__USER default "authentik"
# AUTHENTIK_POSTGRESQL__NAME default "authentik"
# AUTHENTIK_POSTGRESQL__PASSWORD required (read via PGPASSWORD env)
set -Eeuo pipefail
shopt -s inherit_errexit 2>/dev/null || true
# -----------------------------------------------------------------------------
# Defaults + required-env guard
# -----------------------------------------------------------------------------
: "${AUTHENTIK_PG_HOST:=postgresql}"
: "${AUTHENTIK_PG_PORT:=5432}"
: "${AUTHENTIK_POSTGRESQL__USER:=authentik}"
: "${AUTHENTIK_POSTGRESQL__NAME:=authentik}"
: "${AUTHENTIK_S3_BACKUP_ENDPOINT:?AUTHENTIK_S3_BACKUP_ENDPOINT is required}"
: "${AUTHENTIK_S3_BACKUP_BUCKET:?AUTHENTIK_S3_BACKUP_BUCKET is required}"
: "${AUTHENTIK_S3_BACKUP_ACCESS_KEY:?AUTHENTIK_S3_BACKUP_ACCESS_KEY is required}"
: "${AUTHENTIK_S3_BACKUP_SECRET_KEY:?AUTHENTIK_S3_BACKUP_SECRET_KEY is required}"
: "${AUTHENTIK_POSTGRESQL__PASSWORD:?AUTHENTIK_POSTGRESQL__PASSWORD is required}"
TODAY="$(date -u +%Y-%m-%d)"
NOW="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
WORK="${BACKUP_HOME:-/srv/backup}/work/${TODAY}"
mkdir -p "${WORK}"
log() { printf "[authentik-backup %s] %s\n" "${NOW}" "$*"; }
cleanup() { rm -rf "${WORK}"; }
trap cleanup EXIT
# -----------------------------------------------------------------------------
# Step 1: Postgres dump
# -----------------------------------------------------------------------------
log "dumping Postgres (${AUTHENTIK_PG_HOST}/${AUTHENTIK_POSTGRESQL__NAME})"
PG_DUMP_FILE="${WORK}/authentik-postgres-${NOW}.dump"
export PGPASSWORD="${AUTHENTIK_POSTGRESQL__PASSWORD}"
pg_dump \
--host "${AUTHENTIK_PG_HOST}" \
--port "${AUTHENTIK_PG_PORT}" \
--username "${AUTHENTIK_POSTGRESQL__USER}" \
--dbname "${AUTHENTIK_POSTGRESQL__NAME}" \
--format=custom \
--compress=9 \
--no-owner \
--no-privileges \
--file "${PG_DUMP_FILE}"
log "postgres dump size: $(du -h "${PG_DUMP_FILE}" | cut -f1)"
# -----------------------------------------------------------------------------
# Step 2: media volume tar
# -----------------------------------------------------------------------------
MEDIA_TAR="${WORK}/authentik-media-${NOW}.tar.gz"
if [[ -d /media ]]; then
log "archiving /media"
tar -czf "${MEDIA_TAR}" -C / media || log "media tar exited non-zero (continuing)"
log "media archive size: $(du -h "${MEDIA_TAR}" | cut -f1)"
else
log "/media not mounted in backup container — skipping media tar"
MEDIA_TAR=""
fi
# -----------------------------------------------------------------------------
# Step 3: SECRETS-MANIFEST.txt
# -----------------------------------------------------------------------------
# CRITICAL: without these secrets the Postgres dump is useless — Authentik
# encrypts OIDC provider client_secrets, password-stage data, etc. with
# AUTHENTIK_SECRET_KEY. Restoring a dump against a fresh instance with a
# DIFFERENT key will leave you with unreadable encrypted fields.
SECRETS_MANIFEST="${WORK}/SECRETS-MANIFEST-${NOW}.txt"
cat > "${SECRETS_MANIFEST}" <<EOF
Authentik Backup — SECRETS MANIFEST (${NOW})
The following secrets are REQUIRED to restore this backup. They are NOT
included in the Postgres dump. Back them up separately from Coolify-Vault
(or your equivalent secret-store) to an offline password vault.
REQUIRED for restore:
- AUTHENTIK_SECRET_KEY
Decrypts all Authentik-encrypted fields. CANNOT BE ROTATED without
destroying all encrypted-field data (sessions, OIDC client secrets,
password-stage data). Loss = full re-onboarding of all admin users.
- AUTHENTIK_POSTGRESQL__PASSWORD
Used by the new Authentik instance to connect to the restored Postgres.
- AUTHENTIK_BOOTSTRAP_TOKEN
Optional but useful for re-bootstrapping blueprints via API.
OPTIONAL but recommended:
- TOOLCTRL_ADMIN_OIDC_CLIENT_SECRET
The OIDC provider's client_secret is stored encrypted in Postgres;
the plaintext lives in Coolify-Vault and in the boilerplate app's
.env (consumed by Phase 09 #82).
- AUTHENTIK_SENTRY__DSN
If you ship error-reporting to the self-hosted Sentry.
- AUTHENTIK_EMAIL__PASSWORD
SMTP credentials for recovery mail.
Postgres dump artifact: $(basename "${PG_DUMP_FILE}")
Media archive: $([ -n "${MEDIA_TAR}" ] && basename "${MEDIA_TAR}" || echo "n/a")
EOF
# -----------------------------------------------------------------------------
# Step 4: encrypt
# -----------------------------------------------------------------------------
encrypt() {
local src="$1"
local dst="${src}.gpg"
if [[ -n "${AUTHENTIK_BACKUP_GPG_RECIPIENT:-}" ]]; then
log "encrypting ${src##*/} to GPG recipient ${AUTHENTIK_BACKUP_GPG_RECIPIENT}"
gpg --batch --yes --trust-model always --recipient "${AUTHENTIK_BACKUP_GPG_RECIPIENT}" \
--output "${dst}" --encrypt "${src}"
else
log "encrypting ${src##*/} symmetrically (AES256) with AUTHENTIK_SECRET_KEY"
gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase "${AUTHENTIK_SECRET_KEY:?AUTHENTIK_SECRET_KEY missing for symmetric fallback}" \
--output "${dst}" "${src}"
fi
rm -f "${src}"
printf "%s" "${dst}"
}
PG_ENCRYPTED="$(encrypt "${PG_DUMP_FILE}")"
SECRETS_ENCRYPTED="$(encrypt "${SECRETS_MANIFEST}")"
if [[ -n "${MEDIA_TAR:-}" && -f "${MEDIA_TAR}" ]]; then
MEDIA_ENCRYPTED="$(encrypt "${MEDIA_TAR}")"
else
MEDIA_ENCRYPTED=""
fi
# -----------------------------------------------------------------------------
# Step 5: upload to S3
# -----------------------------------------------------------------------------
export AWS_ACCESS_KEY_ID="${AUTHENTIK_S3_BACKUP_ACCESS_KEY}"
export AWS_SECRET_ACCESS_KEY="${AUTHENTIK_S3_BACKUP_SECRET_KEY}"
export AWS_EC2_METADATA_DISABLED=true
S3_PREFIX="s3://${AUTHENTIK_S3_BACKUP_BUCKET}/authentik/${TODAY}"
log "uploading to ${S3_PREFIX}/"
aws s3 cp --endpoint-url "${AUTHENTIK_S3_BACKUP_ENDPOINT}" "${PG_ENCRYPTED}" "${S3_PREFIX}/$(basename "${PG_ENCRYPTED}")"
aws s3 cp --endpoint-url "${AUTHENTIK_S3_BACKUP_ENDPOINT}" "${SECRETS_ENCRYPTED}" "${S3_PREFIX}/$(basename "${SECRETS_ENCRYPTED}")"
if [[ -n "${MEDIA_ENCRYPTED}" ]]; then
aws s3 cp --endpoint-url "${AUTHENTIK_S3_BACKUP_ENDPOINT}" "${MEDIA_ENCRYPTED}" "${S3_PREFIX}/$(basename "${MEDIA_ENCRYPTED}")"
fi
log "authentik backup complete for ${TODAY}"
@@ -0,0 +1,15 @@
# infra/authentik/backup/crontab
#
# Daily Authentik backup. Times are UTC (TZ set in the Dockerfile).
# Format: m h dom mon dow command
#
# 03:30 UTC = quiet hour for both EU/US active windows. Authentik's worker
# doesn't have a heavy nightly job; we co-locate with the Sentry backup time
# (which also runs at 03:30 in infra/sentry/backup/crontab) for consistent
# operator runbooks.
30 3 * * * /srv/backup/backup.sh >> /srv/backup/logs/backup-$(date -u +\%Y\%m\%d).log 2>&1
# Heartbeat — write an "alive" marker hourly so operators can confirm crond
# is actually running inside the container.
0 * * * * date -u +%Y-%m-%dT%H:%M:%SZ > /srv/backup/logs/heartbeat.txt
@@ -0,0 +1,55 @@
# infra/authentik/bootstrap/blueprints/01-groups.yaml
#
# Authentik Blueprint — declarative group setup.
#
# Loaded by the worker on boot (mounted at /blueprints/toolctrl/01-groups.yaml
# via docker-compose.yml). Blueprints in /blueprints/* are evaluated by
# Authentik at startup and applied idempotently — re-applying the same
# blueprint is a no-op if entities already exist with the same `identifiers`.
#
# Spec: https://goauthentik.io/docs/customize/blueprints
#
# Groups created:
# - toolctrl-admin (attribute role=super_admin) — full Toolctrl super-admin
# - toolctrl-staff (attribute role=staff) — restricted admin (read +
# limited write per app rules)
# - toolctrl-users (attribute role=user) — placeholder for future
# customer-tenant-SSO (#66);
# no app uses this today
version: 1
metadata:
labels:
blueprints.goauthentik.io/instantiate: "true"
name: "Toolctrl Groups"
entries:
- identifiers:
name: toolctrl-admin
model: authentik_core.group
state: present
attrs:
is_superuser: false # we use the group attr, NOT Authentik's built-in superuser flag
attributes:
role: super_admin
toolctrl_managed: true
- identifiers:
name: toolctrl-staff
model: authentik_core.group
state: present
attrs:
is_superuser: false
attributes:
role: staff
toolctrl_managed: true
- identifiers:
name: toolctrl-users
model: authentik_core.group
state: present
attrs:
is_superuser: false
attributes:
role: user
toolctrl_managed: true
@@ -0,0 +1,111 @@
# infra/authentik/bootstrap/blueprints/02-policies.yaml
#
# Authentik Blueprint — security policies.
#
# Policies created:
# 1. toolctrl-password-policy
# - min 12 chars, mixed case + digit + symbol, no common-password dict
# - applied to the default password stage so every password set / reset
# goes through this gate
# 2. toolctrl-require-mfa-totp-for-admin (expression policy)
# - returns ak_message + denies the auth flow if a user in
# toolctrl-admin tries to authenticate without a verified TOTP factor
# - attached to the admin OIDC application access binding
# 3. toolctrl-bruteforce-lockout (reputation policy)
# - 5 failed authentication attempts in 1 minute from the same IP
# drops the reputation score below threshold and triggers a
# 15-minute IP lockout via the default reputation policy chain
#
# Note: the bind objects (PolicyBinding) are created against the default
# password stage and the admin application, but the admin-application binding
# only takes effect AFTER 03-application-toolctrl-admin.yaml has been
# applied (Authentik resolves cross-blueprint references on each apply pass;
# you may see a single retry on the first boot, which is normal).
version: 1
metadata:
labels:
blueprints.goauthentik.io/instantiate: "true"
name: "Toolctrl Policies"
entries:
# ---------- 1. Password policy ----------------------------------------
- identifiers:
name: toolctrl-password-policy
model: authentik_policies_password.passwordpolicy
state: present
attrs:
execution_logging: true
password_field: password
amount_uppercase: 1
amount_lowercase: 1
amount_digits: 1
amount_symbols: 1
length_min: 12
symbol_charset: "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
error_message: "Password must be at least 12 characters and contain upper, lower, digit, and symbol."
check_static_rules: true
check_have_i_been_pwned: true
check_zxcvbn: true
zxcvbn_score_threshold: 3
hibp_allowed_count: 0
# Bind the password policy to the default password-change Flow so every
# new password / reset goes through it. Authentik 2026.x rejects binding
# a PolicyBinding.target to a Stage directly (Stage is not a
# PolicyBindingModel subclass); we target the Flow instead which IS a PBM
# and applies to all stages inside it. See B-007.
- identifiers:
target: !Find [authentik_flows.flow, [slug, default-password-change]]
policy: !Find [authentik_policies_password.passwordpolicy, [name, toolctrl-password-policy]]
order: 0
model: authentik_policies.policybinding
state: present
attrs:
enabled: true
# ---------- 2. Require MFA-TOTP for toolctrl-admin --------------------
- identifiers:
name: toolctrl-require-mfa-totp-for-admin
model: authentik_policies_expression.expressionpolicy
state: present
attrs:
execution_logging: true
expression: |
# Returns True (allow) only if the user is NOT in toolctrl-admin,
# OR the user has a verified TOTP factor attached.
# If they are in toolctrl-admin and have no TOTP, deny + message.
from authentik.stages.authenticator_totp.models import TOTPDevice
is_admin = ak_is_group_member(request.user, name="toolctrl-admin")
if not is_admin:
return True
has_totp = TOTPDevice.objects.filter(user=request.user, confirmed=True).exists()
if not has_totp:
ak_message("MFA-TOTP is required for super-admin accounts. Enroll a TOTP device first.")
return False
return True
# ---------- 3. Brute-force lockout (reputation policy) ----------------
- identifiers:
name: toolctrl-bruteforce-lockout
model: authentik_policies_reputation.reputationpolicy
state: present
attrs:
execution_logging: true
check_ip: true
check_username: true
# Reputation drops by 1 per failed login; -5 in 1min triggers the
# default flow-level deny. Authentik's built-in reputation policy chain
# applies a 15min cool-down before reputation recovers.
threshold: -5
# Bind the brute-force policy to the default authentication flow so it
# runs on every login attempt.
- identifiers:
target: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
policy: !Find [authentik_policies_reputation.reputationpolicy, [name, toolctrl-bruteforce-lockout]]
order: 10
model: authentik_policies.policybinding
state: present
attrs:
enabled: true
@@ -0,0 +1,141 @@
# infra/authentik/bootstrap/blueprints/03-application-toolctrl-admin.yaml
#
# Authentik Blueprint — OIDC provider + application for the Toolctrl
# admin panel. Consumed by Phase 09 task #82 (admin-panel OIDC login).
#
# Creates:
# - OIDC Provider `toolctrl-admin-panel`
# * client_type=confidential
# * client_id from env (TOOLCTRL_ADMIN_OIDC_CLIENT_ID)
# * client_secret from env (TOOLCTRL_ADMIN_OIDC_CLIENT_SECRET)
# * scope_mappings: openid, email, profile, groups
# * subject_mode=hashed_user_id (avoids leaking the Authentik integer PK)
# * token TTLs: code 1m, access 10m, refresh 7d, id-token 10m
# * include_claims_in_id_token=true
# - Application `Toolctrl Admin Panel`
# * launchURL points at https://admin.boiler.saas-ctrl.com/
# * Access binding: only members of toolctrl-admin OR toolctrl-staff
#
# Also creates a DISABLED placeholder provider `toolctrl-tenant-sso` reserved
# for Backlog #66. It is created with enabled=false so it occupies the slug
# without affecting routing. When #66 is picked up, just flip `enabled` and
# add the per-tenant redirect_uri templates.
version: 1
metadata:
labels:
blueprints.goauthentik.io/instantiate: "true"
name: "Toolctrl Admin Panel OIDC"
context:
client_id: "%(env:TOOLCTRL_ADMIN_OIDC_CLIENT_ID)s"
client_secret: "%(env:TOOLCTRL_ADMIN_OIDC_CLIENT_SECRET)s"
redirect_uri: "%(env:TOOLCTRL_ADMIN_OIDC_REDIRECT_URI)s"
entries:
# ---------- Signing key (use the bundled self-signed by default) -----
# Authentik ships a `authentik Self-signed Certificate` after install; we
# reference it by name so the blueprint applies cleanly on a fresh boot.
# ---------- OIDC Provider: toolctrl-admin-panel ----------------------
- identifiers:
name: toolctrl-admin-panel
model: authentik_providers_oauth2.oauth2provider
state: present
attrs:
client_type: confidential
client_id: !Context client_id
client_secret: !Context client_secret
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
property_mappings_group:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
sub_mode: hashed_user_id
include_claims_in_id_token: true
issuer_mode: per_provider
# Authentik 2026.x changed redirect_uris from a string to a list of
# {matching_mode, url} dicts (RedirectURI dataclass). See B-007.
redirect_uris:
- matching_mode: strict
url: !Context redirect_uri
# Short-lived auth code + access token; longer refresh window for
# admin-panel sessions (operator can revoke in Authentik UI).
access_code_validity: minutes=1
access_token_validity: minutes=10
refresh_token_validity: days=7
# ---------- Application: Toolctrl Admin Panel -------------------------
- identifiers:
slug: toolctrl-admin-panel
model: authentik_core.application
state: present
attrs:
name: "Toolctrl Admin Panel"
provider: !Find [authentik_providers_oauth2.oauth2provider, [name, toolctrl-admin-panel]]
meta_launch_url: "https://admin.boiler.saas-ctrl.com/"
meta_description: "Toolctrl admin panel — super-admin + staff console"
policy_engine_mode: any
open_in_new_tab: false
# ---------- Access bindings: admin OR staff group --------------------
- identifiers:
target: !Find [authentik_core.application, [slug, toolctrl-admin-panel]]
group: !Find [authentik_core.group, [name, toolctrl-admin]]
order: 0
model: authentik_policies.policybinding
state: present
attrs:
enabled: true
- identifiers:
target: !Find [authentik_core.application, [slug, toolctrl-admin-panel]]
group: !Find [authentik_core.group, [name, toolctrl-staff]]
order: 10
model: authentik_policies.policybinding
state: present
attrs:
enabled: true
# ---------- MFA-TOTP-for-admin binding on the application -----------
- identifiers:
target: !Find [authentik_core.application, [slug, toolctrl-admin-panel]]
policy: !Find [authentik_policies_expression.expressionpolicy, [name, toolctrl-require-mfa-totp-for-admin]]
order: 20
model: authentik_policies.policybinding
state: present
attrs:
enabled: true
# ---------- DISABLED placeholder: toolctrl-tenant-sso (#66) ---------
- identifiers:
name: toolctrl-tenant-sso
model: authentik_providers_oauth2.oauth2provider
state: present
attrs:
client_type: confidential
# Sentinel values; #66 will rewrite these.
client_id: "toolctrl-tenant-sso-placeholder"
client_secret: "placeholder-do-not-use"
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
sub_mode: hashed_user_id
include_claims_in_id_token: true
issuer_mode: per_provider
# Authentik has no `enabled` flag at the provider level; we keep this
# provider un-attached to any Application so it has no live route.
# 2026.x requires redirect_uris even on unattached providers — use a
# non-routable placeholder. #66 will rewrite when tenant SSO lands.
redirect_uris:
- matching_mode: strict
url: "https://placeholder.invalid/oidc/callback"
access_code_validity: minutes=1
access_token_validity: minutes=10
refresh_token_validity: days=7
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# infra/authentik/bootstrap/bootstrap.sh
#
# Idempotent post-deploy bootstrap. Waits for the Authentik server to become
# ready, then ensures the project blueprints (./blueprints/*.yaml) are loaded
# and applied. The blueprint files themselves are also mounted into the
# server/worker containers via docker-compose.yml — the worker auto-applies
# them at startup. This script is a belt-and-braces re-trigger that:
# 1. polls /-/health/ready/ until it returns 200,
# 2. triggers /api/v3/managed/blueprints/<id>/apply/ for every blueprint
# so they re-apply immediately rather than waiting for the next worker
# tick,
# 3. prints the operator-actionable next steps (rotate bootstrap token,
# enable MFA on the admin account, etc.).
#
# Run this from the operator's machine OR inside a Coolify post-deploy hook:
#
# export AUTHENTIK_BASE_URL=https://auth.boiler.saas-ctrl.com
# export AUTHENTIK_BOOTSTRAP_TOKEN=$(cat ~/.cli-tokens/authentik-bootstrap)
# bash infra/authentik/bootstrap/bootstrap.sh
set -Eeuo pipefail
: "${AUTHENTIK_BASE_URL:?AUTHENTIK_BASE_URL must be set (e.g. https://auth.boiler.saas-ctrl.com)}"
: "${AUTHENTIK_BOOTSTRAP_TOKEN:?AUTHENTIK_BOOTSTRAP_TOKEN must be set (see .env.example)}"
WAIT_MAX_SECONDS="${WAIT_MAX_SECONDS:-300}"
WAIT_INTERVAL_SECONDS="${WAIT_INTERVAL_SECONDS:-5}"
log() { printf "\033[1;34m[authentik-bootstrap]\033[0m %s\n" "$*"; }
err() { printf "\033[1;31m[authentik-bootstrap:ERROR]\033[0m %s\n" "$*" >&2; }
die() { err "$*"; exit 1; }
# ----------------------------------------------------------------------------
# Step 1: wait for /-/health/ready/
# ----------------------------------------------------------------------------
log "waiting for ${AUTHENTIK_BASE_URL}/-/health/ready/ (timeout ${WAIT_MAX_SECONDS}s)"
deadline=$(( $(date +%s) + WAIT_MAX_SECONDS ))
while true; do
if curl -fsS -o /dev/null -w "%{http_code}" "${AUTHENTIK_BASE_URL}/-/health/ready/" | grep -q "^200$"; then
log "server ready"
break
fi
if [[ $(date +%s) -ge ${deadline} ]]; then
die "timed out waiting for /-/health/ready/"
fi
sleep "${WAIT_INTERVAL_SECONDS}"
done
# ----------------------------------------------------------------------------
# Step 2: list blueprints and trigger re-apply
# ----------------------------------------------------------------------------
auth_header="Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}"
log "listing managed blueprints"
blueprints_json="$(curl -fsS -H "${auth_header}" "${AUTHENTIK_BASE_URL}/api/v3/managed/blueprints/?page_size=200")" || die "blueprint listing failed (check AUTHENTIK_BOOTSTRAP_TOKEN)"
# Extract pk for any blueprint whose `path` starts with /blueprints/toolctrl/.
ids="$(printf "%s" "${blueprints_json}" \
| python3 -c 'import sys, json; data=json.load(sys.stdin); [print(b["pk"]) for b in data.get("results", []) if b.get("path","").startswith("/blueprints/toolctrl/")]')"
if [[ -z "${ids}" ]]; then
log "no /blueprints/toolctrl/* blueprints found yet — the worker has not picked them up"
log "this is expected on the very first boot; the worker auto-discovers them within ~30s"
log "re-run this script in 1 minute to apply explicitly"
exit 0
fi
# ----------------------------------------------------------------------------
# Step 3: apply each blueprint
# ----------------------------------------------------------------------------
for id in ${ids}; do
log "applying blueprint ${id}"
curl -fsS -X POST -H "${auth_header}" "${AUTHENTIK_BASE_URL}/api/v3/managed/blueprints/${id}/apply/" \
-o /dev/null \
|| err "apply failed for blueprint ${id} (will be retried by the worker)"
done
log "bootstrap complete"
cat <<'EONOTE'
----------------------------------------------------------------------
NEXT STEPS (operator)
----------------------------------------------------------------------
1. Log into https://auth.boiler.saas-ctrl.com using AUTHENTIK_BOOTSTRAP_EMAIL
+ AUTHENTIK_BOOTSTRAP_PASSWORD. The first login forces a password change.
2. In the Authentik UI, enroll TOTP for your account
(User Settings -> Authenticators -> Set up TOTP). Required because
the toolctrl-require-mfa-totp-for-admin policy denies access to the
admin OIDC application without a verified TOTP device.
3. Rotate AUTHENTIK_BOOTSTRAP_TOKEN — it grants full admin-API access
and is no longer needed after this bootstrap. Generate a fresh
personal admin token instead, and store it in Coolify-Vault.
4. Add real human admins by creating users in the Authentik UI and
adding them to the `toolctrl-admin` group. Force TOTP enrollment
on each.
5. Verify OIDC discovery from the admin-panel side once #82 is wired:
curl -fsS https://auth.boiler.saas-ctrl.com/application/o/toolctrl-admin-panel/.well-known/openid-configuration | jq .issuer
----------------------------------------------------------------------
EONOTE
@@ -0,0 +1,198 @@
# Self-hosted Authentik IdP stack pinned at 2026.2.3 (see UPSTREAM.md).
#
# Four services on an internal `authentik-net` network, with the `server`
# service additionally attached to the Coolify-managed `coolify` external
# network so Traefik can route inbound HTTPS to it (see traefik.labels.yml
# for the Traefik label-set — those labels are applied at the operator's
# Coolify-service level rather than inline here, so this file stays portable
# between staging and any future dedicated-host deployment).
#
# Postgres + Redis are strictly Authentik-internal — never share with the
# boilerplate app's databases. No `ports:` mappings anywhere; ingress is
# Traefik-only.
#
# Volume convention: named volumes are managed by Docker; on staging Coolify
# binds them under /var/lib/docker/volumes/authentik_*. The backup container
# (see infra/authentik/backup/) reads from the same network.
version: "3.8"
# ----------------------------------------------------------------------------
# Networks
# ----------------------------------------------------------------------------
networks:
authentik-net:
name: authentik-net
driver: bridge
coolify:
external: true
# ----------------------------------------------------------------------------
# Volumes
# ----------------------------------------------------------------------------
volumes:
authentik-pg-data:
name: authentik-pg-data
authentik-redis-data:
name: authentik-redis-data
authentik-media:
name: authentik-media
authentik-certs:
name: authentik-certs
authentik-templates:
name: authentik-templates
# ----------------------------------------------------------------------------
# Services
# ----------------------------------------------------------------------------
services:
# --- Postgres 16 ---------------------------------------------------------
postgresql:
image: postgres:16-alpine
restart: unless-stopped
networks:
- authentik-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${AUTHENTIK_POSTGRESQL__USER:-authentik} -d ${AUTHENTIK_POSTGRESQL__NAME:-authentik}"]
start_period: 20s
interval: 30s
retries: 5
timeout: 5s
volumes:
- authentik-pg-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${AUTHENTIK_POSTGRESQL__USER:-authentik}
POSTGRES_PASSWORD: ${AUTHENTIK_POSTGRESQL__PASSWORD:?must be set in .env}
POSTGRES_DB: ${AUTHENTIK_POSTGRESQL__NAME:-authentik}
# --- Redis 7 -------------------------------------------------------------
redis:
image: redis:7-alpine
command: --save 60 1 --loglevel warning
restart: unless-stopped
networks:
- authentik-net
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
start_period: 10s
interval: 30s
retries: 5
timeout: 3s
volumes:
- authentik-redis-data:/data
# --- Authentik server (web/api/HTTP) ------------------------------------
server:
image: ghcr.io/goauthentik/server:2026.2.3
restart: unless-stopped
command: server
networks:
- authentik-net
- coolify
healthcheck:
# Authentik exposes /-/health/live/ + /-/health/ready/ on port 9000.
# We use `wget` (image ships busybox-wget); fallback to curl if wget is
# missing in a future image.
test:
- "CMD-SHELL"
- "wget -qO- http://localhost:9000/-/health/live/ >/dev/null 2>&1 || curl -fsS http://localhost:9000/-/health/live/ >/dev/null 2>&1 || exit 1"
start_period: 60s
interval: 30s
retries: 5
timeout: 10s
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRESQL__USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRESQL__PASSWORD:?must be set in .env}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?must be set in .env}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
AUTHENTIK_LOG_LEVEL: ${AUTHENTIK_LOG_LEVEL:-info}
AUTHENTIK_DISABLE_UPDATE_CHECK: ${AUTHENTIK_DISABLE_UPDATE_CHECK:-true}
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: ${AUTHENTIK_DISABLE_STARTUP_ANALYTICS:-true}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-}
AUTHENTIK_COOKIE_DOMAIN: ${AUTHENTIK_COOKIE_DOMAIN:-}
# Internal SSL OFF — TLS is terminated by Traefik in front.
AUTHENTIK_LISTEN__HTTP: 0.0.0.0:9000
AUTHENTIK_LISTEN__HTTPS: 0.0.0.0:9443
# Optional Sentry integration (Phase 02 #29 DSN goes here).
AUTHENTIK_SENTRY__DSN: ${AUTHENTIK_SENTRY__DSN:-}
AUTHENTIK_SENTRY__ENVIRONMENT: ${AUTHENTIK_SENTRY__ENVIRONMENT:-staging}
# Optional SMTP for recovery mails.
AUTHENTIK_EMAIL__HOST: ${AUTHENTIK_EMAIL__HOST:-}
AUTHENTIK_EMAIL__PORT: ${AUTHENTIK_EMAIL__PORT:-587}
AUTHENTIK_EMAIL__USERNAME: ${AUTHENTIK_EMAIL__USERNAME:-}
AUTHENTIK_EMAIL__PASSWORD: ${AUTHENTIK_EMAIL__PASSWORD:-}
AUTHENTIK_EMAIL__USE_TLS: ${AUTHENTIK_EMAIL__USE_TLS:-true}
AUTHENTIK_EMAIL__USE_SSL: ${AUTHENTIK_EMAIL__USE_SSL:-false}
AUTHENTIK_EMAIL__TIMEOUT: ${AUTHENTIK_EMAIL__TIMEOUT:-10}
AUTHENTIK_EMAIL__FROM: ${AUTHENTIK_EMAIL__FROM:-authentik@boiler.saas-ctrl.com}
volumes:
- authentik-media:/media
- authentik-certs:/certs
- authentik-templates:/templates
# Blueprints baked into the image are loaded automatically. Operator
# mounts the project blueprints over the default path during bootstrap
# (see infra/authentik/bootstrap/bootstrap.sh).
- ./bootstrap/blueprints:/blueprints/toolctrl:ro
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
# NO `ports:` — Traefik routes inbound on the `coolify` network.
# --- Authentik worker (background jobs, blueprint apply, email) ---------
worker:
image: ghcr.io/goauthentik/server:2026.2.3
restart: unless-stopped
command: worker
networks:
- authentik-net
healthcheck:
test: ["CMD-SHELL", "ak healthcheck"]
start_period: 60s
interval: 30s
retries: 5
timeout: 30s
# Worker needs the docker socket only if you intend to manage Outposts
# via Docker. We don't ship Outposts in this phase, so the socket is
# intentionally NOT mounted. If a future phase enables Outposts, mount
# /var/run/docker.sock:/var/run/docker.sock:ro then.
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRESQL__USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRESQL__PASSWORD:?must be set in .env}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?must be set in .env}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
AUTHENTIK_LOG_LEVEL: ${AUTHENTIK_LOG_LEVEL:-info}
AUTHENTIK_DISABLE_UPDATE_CHECK: ${AUTHENTIK_DISABLE_UPDATE_CHECK:-true}
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: ${AUTHENTIK_DISABLE_STARTUP_ANALYTICS:-true}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-}
AUTHENTIK_SENTRY__DSN: ${AUTHENTIK_SENTRY__DSN:-}
AUTHENTIK_SENTRY__ENVIRONMENT: ${AUTHENTIK_SENTRY__ENVIRONMENT:-staging}
AUTHENTIK_EMAIL__HOST: ${AUTHENTIK_EMAIL__HOST:-}
AUTHENTIK_EMAIL__PORT: ${AUTHENTIK_EMAIL__PORT:-587}
AUTHENTIK_EMAIL__USERNAME: ${AUTHENTIK_EMAIL__USERNAME:-}
AUTHENTIK_EMAIL__PASSWORD: ${AUTHENTIK_EMAIL__PASSWORD:-}
AUTHENTIK_EMAIL__USE_TLS: ${AUTHENTIK_EMAIL__USE_TLS:-true}
AUTHENTIK_EMAIL__USE_SSL: ${AUTHENTIK_EMAIL__USE_SSL:-false}
AUTHENTIK_EMAIL__TIMEOUT: ${AUTHENTIK_EMAIL__TIMEOUT:-10}
AUTHENTIK_EMAIL__FROM: ${AUTHENTIK_EMAIL__FROM:-authentik@boiler.saas-ctrl.com}
volumes:
- authentik-media:/media
- authentik-certs:/certs
- authentik-templates:/templates
- ./bootstrap/blueprints:/blueprints/toolctrl:ro
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# infra/authentik/scripts/smoke.sh
#
# Post-deploy smoke checks for the self-hosted Authentik stack. Runs
# read-only HTTP probes from the operator's machine; safe to re-run.
#
# Probes:
# 1. https://auth.<host>/ -> 200 (login page)
# 2. https://auth.<host>/-/health/live/ -> 200
# 3. https://auth.<host>/-/health/ready/ -> 200
# 4. https://auth.<host>/application/o/<app-slug>/.well-known/openid-configuration -> 200 JSON
# with `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`
# 5. (optional) self-registration endpoint returns 404
#
# Usage:
# export AUTHENTIK_BASE_URL=https://auth.boiler.saas-ctrl.com
# export AUTHENTIK_APP_SLUG=toolctrl-admin-panel
# bash infra/authentik/scripts/smoke.sh
#
# Exit codes:
# 0 = all probes passed
# 1 = at least one probe failed
set -Eeuo pipefail
: "${AUTHENTIK_BASE_URL:?AUTHENTIK_BASE_URL must be set (e.g. https://auth.boiler.saas-ctrl.com)}"
: "${AUTHENTIK_APP_SLUG:=toolctrl-admin-panel}"
FAILED=0
PASSED=0
log_ok() { printf "\033[1;32m[smoke:OK]\033[0m %s\n" "$*"; PASSED=$((PASSED+1)); }
log_fail() { printf "\033[1;31m[smoke:FAIL]\033[0m %s\n" "$*" >&2; FAILED=$((FAILED+1)); }
log_info() { printf "\033[1;34m[smoke]\033[0m %s\n" "$*"; }
probe_status() {
local label="$1"
local url="$2"
local want="$3"
local got
got="$(curl -ksS -o /dev/null -w "%{http_code}" "${url}" || echo "000")"
if [[ "${got}" == "${want}" ]]; then
log_ok "${label} -> ${got}"
else
log_fail "${label} -> got ${got}, want ${want} (${url})"
fi
}
probe_oidc_discovery() {
local url="${AUTHENTIK_BASE_URL}/application/o/${AUTHENTIK_APP_SLUG}/.well-known/openid-configuration"
local body
body="$(curl -ksS -o - -w "\nHTTP_STATUS:%{http_code}" "${url}" || true)"
local status
status="$(printf "%s" "${body}" | grep -E "^HTTP_STATUS:" | tail -1 | cut -d: -f2 | tr -d '[:space:]')"
local payload
payload="$(printf "%s" "${body}" | sed -e '/^HTTP_STATUS:/d')"
if [[ "${status}" != "200" ]]; then
log_fail "oidc-discovery -> got ${status}, want 200 (${url})"
return
fi
for k in issuer authorization_endpoint token_endpoint jwks_uri; do
if printf "%s" "${payload}" | grep -q "\"${k}\""; then
log_ok "oidc-discovery field ${k} present"
else
log_fail "oidc-discovery field ${k} MISSING (${url})"
fi
done
}
log_info "smoke target: ${AUTHENTIK_BASE_URL}"
log_info "app slug: ${AUTHENTIK_APP_SLUG}"
# 1. Root login page reachable
probe_status "root (login page)" "${AUTHENTIK_BASE_URL}/" "200"
# 2. Health probes
probe_status "health-live" "${AUTHENTIK_BASE_URL}/-/health/live/" "200"
probe_status "health-ready" "${AUTHENTIK_BASE_URL}/-/health/ready/" "200"
# 3. OIDC discovery (depends on blueprint 03-application-toolctrl-admin.yaml
# having applied — fails until bootstrap is done)
probe_oidc_discovery
# 4. Self-registration disabled (Authentik default-source-enrollment flow)
log_info "verifying public self-registration is OFF"
self_enroll_status="$(curl -ksS -o /dev/null -w "%{http_code}" "${AUTHENTIK_BASE_URL}/if/flow/default-source-enrollment/" || echo "000")"
if [[ "${self_enroll_status}" == "404" || "${self_enroll_status}" == "302" ]]; then
log_ok "self-enrollment endpoint returns ${self_enroll_status} (registration disabled or redirect-to-deny)"
else
log_fail "self-enrollment endpoint returns ${self_enroll_status} (expected 404 or 302)"
fi
echo
log_info "summary: ${PASSED} passed, ${FAILED} failed"
[[ ${FAILED} -eq 0 ]] || exit 1
@@ -0,0 +1,66 @@
# Canonical Traefik label-set for the self-hosted Authentik IdP stack.
#
# This file is a YAML fragment (NOT a runnable compose by itself). The operator
# attaches the same labels to the `server` service via the Coolify-UI
# "Service Labels" panel when wiring the stack to Traefik; we ship them here
# in one place for review + traceability. Coolify-injected labels (project
# id, environment) are merged on top automatically.
#
# Coolify (v4) ships Traefik v3.6 with two entrypoints (`http`:80, `https`:443)
# and a default ACME cert-resolver named `letsencrypt`. The labels below match
# that convention; they exactly mirror the shape used by infra/sentry/.
#
# Host: auth.boiler.saas-ctrl.com -> DNS A record points at 46.225.5.93
# (created via Cloudflare API — see docs/operations/authentik-setup.md).
# ---- ROUTERS ----------------------------------------------------------------
# Primary HTTPS router — terminates TLS, forwards to authentik-server:9000.
traefik.enable: "true"
traefik.docker.network: "coolify"
traefik.http.routers.authentik.rule: "Host(`auth.boiler.saas-ctrl.com`)"
traefik.http.routers.authentik.entrypoints: "https"
traefik.http.routers.authentik.tls: "true"
traefik.http.routers.authentik.tls.certresolver: "letsencrypt"
traefik.http.services.authentik.loadbalancer.server.port: "9000"
# HTTP -> HTTPS redirect (port 80 catches plain-HTTP hits and 308s them to TLS).
traefik.http.routers.authentik-http.rule: "Host(`auth.boiler.saas-ctrl.com`)"
traefik.http.routers.authentik-http.entrypoints: "http"
traefik.http.routers.authentik-http.middlewares: "authentik-https-redirect"
traefik.http.middlewares.authentik-https-redirect.redirectscheme.scheme: "https"
# ---- SECURITY HEADERS (recommended) ----------------------------------------
#
# Attach `authentik-secure-headers` to the router's `middlewares` list:
# traefik.http.routers.authentik.middlewares: "authentik-secure-headers"
#
# Authentik itself sets a reasonable CSP for its own UI; the headers below
# tighten the response at the edge. HSTS-preload is intentionally OFF until
# the operator confirms all dependent subdomains are HTTPS-only (HSTS-preload
# is a one-way commitment).
traefik.http.middlewares.authentik-secure-headers.headers.stsSeconds: "63072000"
traefik.http.middlewares.authentik-secure-headers.headers.stsIncludeSubdomains: "true"
traefik.http.middlewares.authentik-secure-headers.headers.stsPreload: "false"
traefik.http.middlewares.authentik-secure-headers.headers.contentTypeNosniff: "true"
traefik.http.middlewares.authentik-secure-headers.headers.browserXssFilter: "true"
traefik.http.middlewares.authentik-secure-headers.headers.frameDeny: "true"
traefik.http.middlewares.authentik-secure-headers.headers.referrerPolicy: "no-referrer"
# Authentik renders no third-party iframes; deny embedding the IdP UI entirely.
traefik.http.middlewares.authentik-secure-headers.headers.customResponseHeaders.X-Robots-Tag: "noindex, nofollow"
# ---- RATE-LIMIT MIDDLEWARE (recommended for /api/v3/flows/executor/*) ------
#
# Authentik has an internal brute-force policy (see bootstrap/blueprints/
# 02-policies.yaml — 5 failed/min IP-lockout for 15min). The Traefik
# rate-limit is a defence-in-depth layer that catches credential-stuffing
# bursts BEFORE they reach Authentik's flow executor. Attach to the router by
# appending `authentik-ratelimit` to the router's `middlewares` list when
# applying both at once:
#
# traefik.http.routers.authentik.middlewares: "authentik-secure-headers,authentik-ratelimit"
traefik.http.middlewares.authentik-ratelimit.ratelimit.average: "60"
traefik.http.middlewares.authentik-ratelimit.ratelimit.burst: "120"
traefik.http.middlewares.authentik-ratelimit.ratelimit.period: "1m"
@@ -0,0 +1,53 @@
{
"uid": "boilerplate-ai-cost",
"title": "Boilerplate — AI Cost per Tenant",
"tags": ["boilerplate", "ai"],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "1m",
"time": { "from": "now-24h", "to": "now" },
"panels": [
{
"id": 1,
"type": "barchart",
"title": "Top tenants — tokens consumed (last 24h)",
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 0 },
"targets": [
{
"refId": "A",
"expr": "topk(20, sum by (tenant_bucket) (increase(ai_tokens_consumed_total[24h])))",
"legendFormat": "{{tenant_bucket}}",
"datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" }
}
]
},
{
"id": 2,
"type": "piechart",
"title": "Tokens by provider (24h)",
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 0 },
"targets": [
{ "refId": "A", "expr": "sum by (provider) (increase(ai_tokens_consumed_total[24h]))", "legendFormat": "{{provider}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 3,
"type": "timeseries",
"title": "Daily token-consumption trend (per model)",
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 10 },
"targets": [
{ "refId": "A", "expr": "sum by (model) (rate(ai_tokens_consumed_total[1h]))", "legendFormat": "{{model}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 4,
"type": "stat",
"title": "Cardinality of tenant_bucket (should stay ≤ 101)",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 20 },
"targets": [
{ "refId": "A", "expr": "count(count by (tenant_bucket) (ai_tokens_consumed_total))", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
],
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 80 }, { "color": "red", "value": 101 }] } } }
}
]
}
@@ -0,0 +1,41 @@
{
"uid": "boilerplate-api-latency",
"title": "Boilerplate — API Latency",
"tags": ["boilerplate", "api"],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"panels": [
{
"id": 1,
"type": "timeseries",
"title": "p50 / p95 / p99 (s) per route",
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 0 },
"targets": [
{ "refId": "A", "expr": "histogram_quantile(0.50, sum by (le, route) (rate(http_request_duration_seconds_bucket{service=\"api\"}[5m])))", "legendFormat": "p50 {{route}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } },
{ "refId": "B", "expr": "histogram_quantile(0.95, sum by (le, route) (rate(http_request_duration_seconds_bucket{service=\"api\"}[5m])))", "legendFormat": "p95 {{route}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } },
{ "refId": "C", "expr": "histogram_quantile(0.99, sum by (le, route) (rate(http_request_duration_seconds_bucket{service=\"api\"}[5m])))", "legendFormat": "p99 {{route}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
],
"fieldConfig": { "defaults": { "unit": "s" } }
},
{
"id": 2,
"type": "timeseries",
"title": "Request rate per route",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 10 },
"targets": [
{ "refId": "A", "expr": "sum by (route) (rate(http_requests_total{service=\"api\"}[1m]))", "legendFormat": "{{route}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 3,
"type": "timeseries",
"title": "Status-code distribution",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 10 },
"targets": [
{ "refId": "A", "expr": "sum by (status) (rate(http_requests_total{service=\"api\"}[1m]))", "legendFormat": "{{status}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
}
]
}
@@ -0,0 +1,65 @@
{
"uid": "boilerplate-business-kpi",
"title": "Boilerplate — Business KPIs",
"tags": ["boilerplate", "business"],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "1m",
"time": { "from": "now-24h", "to": "now" },
"panels": [
{
"id": 1,
"type": "stat",
"title": "Active tenants",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"targets": [
{ "refId": "A", "expr": "active_tenants", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 2,
"type": "stat",
"title": "Active users (24h)",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
"targets": [
{ "refId": "A", "expr": "active_users_24h", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 3,
"type": "timeseries",
"title": "Stripe webhook events / min by type",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
"targets": [
{ "refId": "A", "expr": "sum by (event_type) (rate(stripe_webhook_events_total[5m]))", "legendFormat": "{{event_type}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 4,
"type": "timeseries",
"title": "Mail delivery rate by status",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
"targets": [
{ "refId": "A", "expr": "sum by (status) (rate(mail_sent_total[5m]))", "legendFormat": "{{status}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 5,
"type": "timeseries",
"title": "Outbound webhook attempts by status",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
"targets": [
{ "refId": "A", "expr": "sum by (status) (rate(webhook_delivery_attempts_total[5m]))", "legendFormat": "{{status}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 6,
"type": "timeseries",
"title": "Active subscriptions by plan",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
"targets": [
{ "refId": "A", "expr": "subscription_active", "legendFormat": "{{plan_id}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
}
]
}
@@ -0,0 +1,60 @@
{
"uid": "boilerplate-overview",
"title": "Boilerplate — Overview",
"tags": ["boilerplate", "overview"],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "30s",
"time": { "from": "now-3h", "to": "now" },
"panels": [
{
"id": 1,
"type": "stat",
"title": "Services up",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"targets": [
{ "refId": "A", "expr": "count(up{job=~\"boilerplate-.*\"} == 1)", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
],
"fieldConfig": { "defaults": { "mappings": [], "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 3 }] } } }
},
{
"id": 2,
"type": "timeseries",
"title": "HTTP request rate (per service)",
"gridPos": { "h": 8, "w": 12, "x": 6, "y": 0 },
"targets": [
{ "refId": "A", "expr": "sum by (service) (rate(http_requests_total[1m]))", "legendFormat": "{{service}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 3,
"type": "timeseries",
"title": "HTTP error ratio (5xx / total)",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"targets": [
{
"refId": "A",
"expr": "sum by (service) (rate(http_requests_total{status=~\"5..\"}[5m])) / clamp_min(sum by (service) (rate(http_requests_total[5m])), 0.001)",
"legendFormat": "{{service}}",
"datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" }
}
],
"fieldConfig": { "defaults": { "unit": "percentunit", "max": 0.5 } }
},
{
"id": 4,
"type": "timeseries",
"title": "HTTP latency p95",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (le, service) (rate(http_request_duration_seconds_bucket[5m])))",
"legendFormat": "{{service}}",
"datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" }
}
],
"fieldConfig": { "defaults": { "unit": "s" } }
}
]
}
@@ -0,0 +1,56 @@
{
"uid": "boilerplate-queue-health",
"title": "Boilerplate — Queue Health",
"tags": ["boilerplate", "queue"],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "30s",
"time": { "from": "now-3h", "to": "now" },
"panels": [
{
"id": 1,
"type": "timeseries",
"title": "Active jobs (gauge) per queue",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"targets": [
{ "refId": "A", "expr": "queue_jobs_active", "legendFormat": "{{queue}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 2,
"type": "timeseries",
"title": "Waiting jobs (gauge) per queue",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"targets": [
{ "refId": "A", "expr": "queue_jobs_waiting", "legendFormat": "{{queue}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 3,
"type": "timeseries",
"title": "Completed rate per queue",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"targets": [
{ "refId": "A", "expr": "sum by (queue) (rate(queue_jobs_completed_total[5m]))", "legendFormat": "{{queue}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 4,
"type": "timeseries",
"title": "Failed rate per queue (by error_type)",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"targets": [
{ "refId": "A", "expr": "sum by (queue, error_type) (rate(queue_jobs_failed_total[5m]))", "legendFormat": "{{queue}} / {{error_type}}", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
},
{
"id": 5,
"type": "heatmap",
"title": "Job duration heatmap",
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 16 },
"targets": [
{ "refId": "A", "expr": "sum by (le) (rate(queue_job_duration_seconds_bucket[5m]))", "format": "heatmap", "datasource": { "uid": "boilerplate-prometheus", "type": "prometheus" } }
]
}
]
}
@@ -0,0 +1,50 @@
# Grafana stack for the boilerplate.
#
# Deploys alongside Prometheus on the Hetzner observability box. Joins the
# same docker network so Grafana can reach Prometheus via DNS
# `prometheus:9090`.
services:
grafana:
image: grafana/grafana-oss:11.4.0
container_name: boilerplate-grafana
restart: unless-stopped
user: "472:472"
environment:
# All sensitive values come from the operator's Coolify secrets.
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD must be set}
# Disable anonymous access — Business / per-tenant data lives in here.
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_AUTH_DISABLE_LOGIN_FORM: "false"
GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-https://grafana.boiler.saas-ctrl.com}
# Prometheus token used by the provisioned datasource. The yaml
# references it via env interpolation.
GRAFANA_PROMETHEUS_TOKEN: ${GRAFANA_PROMETHEUS_TOKEN:?GRAFANA_PROMETHEUS_TOKEN must be set}
volumes:
- ./provisioning:/etc/grafana/provisioning:ro
- ./dashboards:/var/lib/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
ports:
- "3000:3000"
networks:
- observability
depends_on:
- waitfornet
# Lightweight no-op service that fails compose-up early if the shared
# network is missing. Cheaper than letting grafana retry indefinitely.
waitfornet:
image: alpine:3
command: ["true"]
networks:
- observability
networks:
observability:
name: boilerplate-observability
external: true
volumes:
grafana-data:
name: boilerplate-grafana-data
@@ -0,0 +1,13 @@
apiVersion: 1
providers:
- name: boilerplate
orgId: 1
folder: Boilerplate
type: file
disableDeletion: true
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,16 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: boilerplate-prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
httpHeaderName1: "Authorization"
timeInterval: "15s"
manageAlerts: false
secureJsonData:
httpHeaderValue1: "Bearer ${GRAFANA_PROMETHEUS_TOKEN}"
@@ -0,0 +1,41 @@
# mCaptcha self-host env — copy to `.env` on node1 (/opt/mcaptcha/.env), chmod 600.
# Secrets (COOKIE_SECRET, SALT, PG password) MUST be random, MIN 32 chars: openssl rand -hex 32
# Env var names are verbatim from mCaptcha (note the single/double underscores — do NOT normalise).
# --- general -----------------------------------------------------------------
MCAPTCHA_debug=false
MCAPTCHA_commercial=false
MCAPTCHA_source_code=https://github.com/mCaptcha/mCaptcha
# Keep registration OFF in production (we provision the ops account + sitekeys once).
MCAPTCHA_allow_registration=false
MCAPTCHA_allow_demo=false
# --- database (password must equal MCAPTCHA_PG_PASSWORD) ----------------------
DATABASE_URL=postgres://postgres:CHANGE_ME_PG_PASSWORD@mcaptcha_postgres:5432/postgres
MCAPTCHA_database_POOL=4
MCAPTCHA_PG_PASSWORD=CHANGE_ME_PG_PASSWORD
# --- cache (mcaptcha/cache service) ------------------------------------------
MCAPTCHA_redis_URL=redis://mcaptcha_redis
MCAPTCHA_redis_POOL=4
# --- server (TLS terminated by host Caddy in front) --------------------------
PORT=7000
MCAPTCHA__server_IP=0.0.0.0
MCAPTCHA_server_DOMAIN=captcha.saas-ctrl.com
MCAPTCHA__server_PROXY_HAS_TLS=true
MCAPTCHA__server_COOKIE_SECRET=CHANGE_ME_RANDOM_MIN_32_CHARS
# --- captcha PoW -------------------------------------------------------------
MCAPTCHA_captcha_SALT=CHANGE_ME_RANDOM_MIN_32_CHARS
MCAPTCHA_captcha_GC=30
MCAPTCHA_captcha_RUNNERS=4
MCAPTCHA_captcha_QUEUE_LENGTH=2000
MCAPTCHA_captcha_ENABLE_STATS=true
MCAPTCHA_captcha_DEFAULT_DIFFICULTY_STRATEGY_avg_traffic_difficulty=50000
MCAPTCHA_captcha_DEFAULT_DIFFICULTY_STRATEGY_peak_sustainable_traffic_difficulty=3000000
MCAPTCHA_captcha_DEFAULT_DIFFICULTY_STRATEGY_broke_my_site_traffic_difficulty=5000000
MCAPTCHA_captcha_DEFAULT_DIFFICULTY_STRATEGY_duration=30
MCAPTCHA_captcha_DEFAULT_DIFFICULTY_STRATEGY_avg_traffic_time=1
MCAPTCHA_captcha_DEFAULT_DIFFICULTY_STRATEGY_peak_sustainable_traffic_time=3
MCAPTCHA_captcha_DEFAULT_DIFFICULTY_STRATEGY_broke_my_site_traffic_time=5
@@ -0,0 +1,7 @@
# Append to /etc/caddy/Caddyfile on node1, then `systemctl reload caddy`.
# Caddy auto-issues + renews the Let's Encrypt cert for the host.
# (DNS A record captcha.saas-ctrl.com -> 176.9.111.179 must exist, DNS-only.)
captcha.saas-ctrl.com {
reverse_proxy localhost:7000
}
+69
View File
@@ -0,0 +1,69 @@
# Self-hosted mCaptcha
Proof-of-work CAPTCHA for the whole boilerplate (login, public forms, booking).
One instance serves **all** services via per-surface sitekeys. EU-self-hosted,
no third-party processor — replaces Cloudflare Turnstile / reCAPTCHA / hCaptcha.
> Architecture decision: HiveGrid `docs/ARCHITECTURE-DECISIONS.md` AD-7.
> Stack: `mcaptcha/mcaptcha` (server, :7000) + `postgres:16-alpine` + `mcaptcha/cache`
> (Redis with mCaptcha's PoW module — **not** vanilla redis).
## Status: DEPLOYED ✅ (2026-06-05)
Live at **https://captcha.saas-ctrl.com** on **node1** (`176.9.111.179`), running as a
plain docker-compose stack at `/opt/mcaptcha` behind host-Caddy (same pattern as
`/opt/sentry`). Valid Let's Encrypt cert. Registration is closed.
- Ops account + account secret + the 3 sitekeys: `~/.cli-tokens/mcaptcha` (and
`/opt/mcaptcha/CREDENTIALS` on node1, chmod 600).
- Sitekeys (public): `login`, `forms`, `booking`.
## Deploy / re-deploy on node1 (the actual setup)
node1 is **not** Coolify-managed; services are direct docker-compose stacks behind
host-level Caddy (`/etc/caddy/Caddyfile`, systemd) which terminates TLS.
1. **DNS:** A record `captcha.saas-ctrl.com` → `176.9.111.179` (Cloudflare, **DNS-only**
so Caddy can issue the LE cert).
2. **Stack:** copy `docker-compose.yml` to `/opt/mcaptcha/`, `cp .env.example .env`,
set the three secrets (`openssl rand -hex 32` for COOKIE_SECRET, SALT, and the PG
password — also paste the PG password into `DATABASE_URL`). `chmod 600 .env`.
Then `docker compose up -d`.
3. **Caddy:** append `Caddyfile.snippet` to `/etc/caddy/Caddyfile`, `caddy validate`,
`systemctl reload caddy`.
4. **Account + sitekeys** (one-time): with `MCAPTCHA_allow_registration=true`, register
the ops account via `POST /api/v1/signup` (username must be alphanumeric — PRECIS
UsernameCaseMapped), sign in (`/api/v1/signin`, cookie auth over **https** — the
session cookie is Secure + host-bound, so it won't work over http://localhost),
read the account secret (`GET /api/v1/account/secret/get`), and create sitekeys
(`POST /api/v1/mcaptcha/create` with `{levels, duration, description, publish_benchmarks:false}`).
Then set `MCAPTCHA_allow_registration=false` and `docker compose up -d`.
## How the app consumes it
Set in the **app's** env (not this `.env`):
```
MCAPTCHA_INSTANCE_URL=https://captcha.saas-ctrl.com
MCAPTCHA_ACCOUNT_SECRET=<from ~/.cli-tokens/mcaptcha>
MCAPTCHA_SITEKEY_LOGIN=<sitekey_login>
MCAPTCHA_SITEKEY_FORMS=<sitekey_forms>
MCAPTCHA_SITEKEY_BOOKING=<sitekey_booking>
CAPTCHA_FAIL_OPEN=true
```
Verify flow: client solves the PoW widget → token → app server calls
`POST {MCAPTCHA_INSTANCE_URL}/api/v1/pow/siteverify` with `{ secret, token, key }`
(handled by `@toolctrl/captcha`).
## Fail policy
If the instance is unreachable, the app **fails open** (lets the submission through)
and logs a warning — login brute-force is still covered by `auth-rate-limit` +
`shared-store`. Flip to fail-closed via `CAPTCHA_FAIL_OPEN=false`.
## Ops notes
- Only `mcaptcha-pg-data` holds state (account + sitekeys + stats); the cache is ephemeral.
- mCaptcha terminates no TLS; Caddy must sit in front.
- Images are `:latest` — pin to a released tag before relying on it long-term.
@@ -0,0 +1,58 @@
# Self-hosted mCaptcha (proof-of-work CAPTCHA) — node1 deployment.
#
# node1 runs services as plain docker-compose stacks behind host-level Caddy
# (NOT Coolify, NOT Traefik) — same pattern as /opt/sentry. Caddy terminates
# TLS and reverse-proxies captcha.saas-ctrl.com -> 127.0.0.1:7000.
#
# Deployed at /opt/mcaptcha on node1 (176.9.111.179). See README.md.
#
# IMPORTANT:
# - The cache is `mcaptcha/cache` (Redis + mCaptcha's PoW module), NOT vanilla redis.
# - mCaptcha terminates NO TLS (PROXY_HAS_TLS=true in .env; Caddy does TLS).
# - Port is published on 127.0.0.1 only — Caddy reaches it locally, never public.
# - Password inside DATABASE_URL (.env) MUST equal MCAPTCHA_PG_PASSWORD.
name: mcaptcha
networks:
mcaptcha-net:
name: mcaptcha-net
volumes:
mcaptcha-pg-data:
name: mcaptcha-pg-data
services:
mcaptcha:
image: mcaptcha/mcaptcha:latest
restart: unless-stopped
networks: [mcaptcha-net]
env_file: [.env]
ports:
- "127.0.0.1:7000:7000"
depends_on:
mcaptcha_postgres:
condition: service_healthy
mcaptcha_redis:
condition: service_started
mcaptcha_postgres:
image: postgres:16-alpine
restart: unless-stopped
networks: [mcaptcha-net]
environment:
POSTGRES_USER: postgres
POSTGRES_DB: postgres
POSTGRES_PASSWORD: ${MCAPTCHA_PG_PASSWORD}
volumes:
- mcaptcha-pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 10
mcaptcha_redis:
image: mcaptcha/cache:latest
restart: unless-stopped
networks: [mcaptcha-net]
@@ -0,0 +1,34 @@
; PgBouncer config for boilerplate.
;
; Mounted at /etc/pgbouncer/pgbouncer.ini inside the edoburu/pgbouncer container.
; See docs/ops/pgbouncer.md for tuning rationale and rollback.
;
; Pool-mode rationale: `transaction` is the default for Prisma-style web workloads.
; Pair it with `pgbouncer=true&connection_limit=10` on DATABASE_URL so Prisma
; disables prepared-statement caching (transaction pooling breaks them).
; Prisma `migrate`, `db push`, `db pull` must use DIRECT_URL (port 5432, direct
; to Postgres) — see schema.prisma `directUrl`.
[databases]
boilerplate = host=postgres port=5432 dbname=boilerplate_validation
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
default_pool_size = 25
max_client_conn = 1000
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 600
server_lifetime = 3600
query_timeout = 0
query_wait_timeout = 30
server_reset_query = DISCARD ALL
server_check_query = SELECT 1
server_check_delay = 30
admin_users = pgb_admin
stats_users = pgb_admin
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
ignore_startup_parameters = extra_float_digits,search_path
@@ -0,0 +1,19 @@
; userlist.txt template for PgBouncer (SCRAM-SHA-256 entries).
;
; Real `userlist.txt` is generated at deploy time from Coolify secrets and
; mounted at /etc/pgbouncer/userlist.txt. Never commit a real password hash.
;
; Format: "username" "SCRAM-SHA-256$<iterations>:<salt>$<storedkey>:<serverkey>"
;
; Generate a SCRAM-SHA-256 line for a known plaintext password with psql:
;
; psql -h <postgres-host> -U postgres -tAc \
; "SELECT '\"'||rolname||'\" \"'||rolpassword||'\"' \
; FROM pg_authid WHERE rolname IN ('app_user', 'pgb_admin');"
;
; (Postgres must have been initialised with password_encryption=scram-sha-256
; for the stored hash to be SCRAM rather than md5.)
;
; Example entries — replace with real SCRAM hashes from Coolify secrets:
"app_user" "SCRAM-SHA-256$4096:REPLACE_ME_SALT$REPLACE_ME_STORED_KEY:REPLACE_ME_SERVER_KEY"
"pgb_admin" "SCRAM-SHA-256$4096:REPLACE_ME_SALT$REPLACE_ME_STORED_KEY:REPLACE_ME_SERVER_KEY"
+48
View File
@@ -0,0 +1,48 @@
# Prometheus — Boilerplate
Scrapes `/metrics` from the three boilerplate apps every 15s.
## Files
- `prometheus.yml` — scrape jobs (api / worker / web)
- `docker-compose.yml` — Coolify-friendly compose stack
- `secrets/metrics_token` — bearer token (chmod 600, do NOT commit)
## First-time deploy on Hetzner staging
```sh
# 1. Create the secret file with the same value as METRICS_TOKEN in Coolify
mkdir -p secrets
openssl rand -hex 32 > secrets/metrics_token
chmod 600 secrets/metrics_token
# 2. Create the shared docker network (also used by Grafana)
docker network create boilerplate-observability
# 3. Start the stack
docker compose up -d
```
Then expose `prometheus.boiler.saas-ctrl.com` via Coolify's proxy with
**basic-auth or Cloudflare-Access** in front — Prometheus has no
built-in auth and ships internal state.
## Verifying scrape targets
```sh
curl -s -H "Authorization: Bearer $(cat secrets/metrics_token)" \
https://api.boiler.saas-ctrl.com/metrics | head -20
```
The Prometheus UI at `https://prometheus.boiler.saas-ctrl.com/targets`
should list all three jobs as **UP**.
## Cardinality budget
- `ai_tokens_consumed_total` — capped at ~100 tenant buckets + 1 `other`
- `subscription_active` — capped by the plan-id table (single digits today)
- All other metrics have bounded label sets (HTTP routes, queue names)
If a panel suddenly shows millions of series, the most likely culprit
is a new histogram with an unbounded label — search the codebase for
`labels({` and verify.
@@ -0,0 +1,45 @@
# Prometheus stack for the boilerplate.
#
# Deploy as a Coolify "Docker Compose" service or with vanilla
# `docker compose up -d` on the Hetzner observability box. The bind mount
# expects the prometheus.yml + secrets file to live alongside this file.
#
# Required files alongside this compose:
# * prometheus.yml — committed
# * secrets/metrics_token — NOT committed; operator provisions
#
# Storage: a named volume keeps tsdb data so a `compose down && up` won't
# wipe history. Retention is set to 30d which is plenty for an 8-week
# staging cycle on 32 GB RAM.
services:
prometheus:
image: prom/prometheus:v3.6.0
container_name: boilerplate-prometheus
restart: unless-stopped
user: "65534:65534"
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=30d
- --web.console.libraries=/usr/share/prometheus/console_libraries
- --web.console.templates=/usr/share/prometheus/consoles
- --web.enable-lifecycle
- --web.external-url=https://prometheus.boiler.saas-ctrl.com
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./secrets:/etc/prometheus/secrets:ro
- prometheus-data:/prometheus
ports:
- "9090:9090"
networks:
- observability
networks:
observability:
name: boilerplate-observability
external: true
volumes:
prometheus-data:
name: boilerplate-prometheus-data
@@ -0,0 +1,81 @@
# Prometheus scrape configuration for the boilerplate.
#
# Scrapes the three application processes' /metrics endpoints with
# bearer-token authentication. METRICS_TOKEN is injected via the
# `metrics_token` file (Docker Secret) or the PROM_METRICS_TOKEN env var
# at compose-up time.
#
# Targets:
# * api — apps/api admin metrics surface (port 3201 default)
# * worker — apps/worker admin server (port 9101 default; 9100 is health-probe)
# * web — apps/web SSR resource route on /api/metrics
#
# Operator deploys this on the Hetzner staging server alongside Sentry.
global:
scrape_interval: 15s
scrape_timeout: 10s
evaluation_interval: 15s
external_labels:
cluster: boilerplate
environment: staging
scrape_configs:
- job_name: boilerplate-api
metrics_path: /metrics
scheme: https
authorization:
type: Bearer
credentials_file: /etc/prometheus/secrets/metrics_token
static_configs:
- targets: ["api.boiler.saas-ctrl.com"]
labels:
service: api
- job_name: boilerplate-worker
metrics_path: /metrics
scheme: http
authorization:
type: Bearer
credentials_file: /etc/prometheus/secrets/metrics_token
static_configs:
# Worker admin server runs inside the private network on
# WORKER_ADMIN_PORT (9101 default for the general queue group;
# 9102/9103/9104 for mail/import/webhook groups per #45).
#
# OPERATOR WIRING REQUIRED — pick one:
# (a) Attach Prometheus to the same Docker network as the worker
# and use the worker container's stable alias (in Coolify v4
# the UUID-based generated name changes on every redeploy
# so you must either add a docker-compose override that
# sets `networks.<net>.aliases: [boilerplate-worker]` or
# use Coolify's "Internal-only" service feature).
# (b) Expose the worker admin server through Traefik on a
# dedicated hostname (e.g. worker-admin.<domain>) with a
# Traefik IPAllowList middleware restricted to the
# Prometheus host, plus the standard Bearer-token auth.
#
# Until (a) or (b) is wired the worker job stays DOWN; api+web
# cover the HTTP-side metrics, the per-queue queue_jobs_*
# counters from the worker registry are not yet visible.
- targets: ["worker.boilerplate.internal:9101"]
labels:
service: worker
- job_name: boilerplate-web
metrics_path: /api/metrics
scheme: https
authorization:
type: Bearer
credentials_file: /etc/prometheus/secrets/metrics_token
static_configs:
# /api/metrics is admin-only — the resource route is mounted on
# the admin host. Scraping the tenant-facing host returns 404 by
# design. Use admin.<domain> for staging; in prod set to
# admin.<prod-domain>.
- targets: ["admin.boiler.saas-ctrl.com"]
labels:
service: web
# When promoting to production, also scrape the production targets behind
# their own bearer-token. Add scrape_configs entries with environment=production.
@@ -0,0 +1,2 @@
*
!.gitignore
+52
View File
@@ -0,0 +1,52 @@
# infra/sentry/.env.example
#
# Copy to `.env` and fill before running ./install.sh. Never commit the real
# .env — it is git-ignored (see .gitignore). Use Coolify's secret vault to
# manage the production values; this file is only the template.
# -----------------------------------------------------------------------------
# Public URL — must match the Traefik-routed hostname in docker-compose.override.yml
# -----------------------------------------------------------------------------
SENTRY_BASE_URL=https://sentry.boiler.saas-ctrl.com
# -----------------------------------------------------------------------------
# Admin / superuser bootstrap
# -----------------------------------------------------------------------------
# install.sh seeds this user on first run via `sentry createuser --superuser`.
# Enable 2FA in the UI immediately after first login (Hardening Checklist).
SENTRY_ADMIN_EMAIL=ops@example.com
SENTRY_ADMIN_PASSWORD=replace-me-strong-password
# -----------------------------------------------------------------------------
# Sentry secret key
# -----------------------------------------------------------------------------
# Generate ONCE with: python -c "import secrets; print(secrets.token_urlsafe(50))"
# Losing this key invalidates all session cookies AND makes encrypted backups
# unreadable. Store it both in Coolify secrets AND in an offline password vault.
SENTRY_SECRET_KEY=replace-me-with-50-chars-of-urlsafe-entropy
# -----------------------------------------------------------------------------
# Project DSNs (filled AFTER first deploy — read from the Sentry UI)
# -----------------------------------------------------------------------------
# Three projects are created post-install: API, Worker, Web. Their DSNs feed
# into the apps' own .env via Coolify (see Phase 02 tasks #30 / #31).
SENTRY_DSN_API=
SENTRY_DSN_WORKER=
SENTRY_DSN_WEB=
# -----------------------------------------------------------------------------
# S3 backup target (consumed by infra/sentry/backup/)
# -----------------------------------------------------------------------------
# Endpoint format examples:
# AWS: https://s3.eu-central-1.amazonaws.com
# Hetzner: https://nbg1.your-objectstorage.com
# MinIO local: https://minio.boiler.saas-ctrl.com
SENTRY_S3_BACKUP_ENDPOINT=https://nbg1.your-objectstorage.com
SENTRY_S3_BACKUP_BUCKET=boilerplate-sentry-backups
SENTRY_S3_BACKUP_ACCESS_KEY=replace-me
SENTRY_S3_BACKUP_SECRET_KEY=replace-me
# Optional: GPG recipient fingerprint to encrypt the tarballs with before upload.
# If left empty, backup.sh falls back to AES256 with the SENTRY_SECRET_KEY as
# symmetric passphrase (still better than plaintext, but rotate carefully).
SENTRY_BACKUP_GPG_RECIPIENT=
+21
View File
@@ -0,0 +1,21 @@
# Runtime data — never commit
data/
postgres-data/
clickhouse-data/
kafka-data/
zookeeper-data/
redis-data/
symbolicator-data/
relay-data/
# Secrets
*.env
!.env.example
secrets/
# Upstream snapshot is cloned at deploy time, never tracked
upstream/self-hosted/
# Local helper artifacts
*.log
.DS_Store
+68
View File
@@ -0,0 +1,68 @@
# `infra/sentry` — Self-Hosted Sentry Stack
This directory contains the artifacts to deploy a **self-hosted Sentry** instance
on the staging Hetzner host (`boilerplate-staging`, see
`docs/improvement-plan/archive/STAGING-ACCESS.md`) via the existing Coolify-managed
Docker-Compose runtime.
## Purpose
- Aggregate **server-side errors** from the API + Worker containers
- Aggregate **browser errors** (incl. Replay + Performance) from the Web app
- Ingest **OpenTelemetry traces** via the Relay OTLP-HTTP endpoint
(consumed in Phase 02 task #33)
- Provide a DSGVO-compliant, EU-hosted alternative to SaaS Sentry
The companion light-weight alternative (GlitchTip) is documented in
`docs/observability/sentry-glitchtip-alternative.md` and is **not** the default —
it lacks full Performance / Replay / OTLP support.
## Prerequisites
- Hetzner host with **≥ 16 GB RAM, ≥ 4 vCPU, ≥ 50 GB free disk**
(Clickhouse + Kafka + Symbolicator are RAM-hungry; the staging host now has
32 GB RAM after the recent upgrade)
- Docker Engine ≥ 24 + Docker Compose v2 (provided by Coolify)
- Traefik v3.6 reverse-proxy from `coolify-proxy` (Let's Encrypt ACME via
`letsencrypt` cert resolver)
- DNS-record `sentry.boiler.saas-ctrl.com` → `46.225.5.93`
## Layout
```
infra/sentry/
├── README.md # this file
├── .gitignore # excludes runtime volumes + secrets
├── UPSTREAM.md # pinned getsentry/self-hosted version + clone strategy
├── upstream/ # populated at deploy time by install.sh (NOT committed)
│ └── .gitkeep
├── docker-compose.override.yml # overlay merged on top of upstream compose
├── sentry/sentry.conf.py # python-config overlay (single-org, no-signup, OTLP)
├── relay/config.yml # Relay OTLP-ingest config
├── traefik.labels.yml # Compose-fragment with Traefik labels
├── install.sh # wrapper that clones upstream + applies overlays + installs
├── .env.example # template for SENTRY_* secrets
└── backup/ # daily backup container
├── Dockerfile
├── backup.sh
└── crontab
```
## Deploy
See `docs/observability/sentry-selfhost.md` (full deploy + upgrade + backup
runbook). High-level:
1. Copy `.env.example` → `.env`, fill secrets (Coolify-Vault).
2. Run `./install.sh` (clones upstream at pinned tag, applies overlays,
installs).
3. In Coolify, attach this stack to the `sentry.boiler.saas-ctrl.com` domain.
4. Smoke-test per `## Test Requirements` in
`docs/improvement-plan/02-observability/sentry-selfhost-setup.md`.
## Versioning
- Upstream pin: `getsentry/self-hosted` tag **`25.12.1`** (see `UPSTREAM.md` for
rationale — skipping the buggy `25.12.0` migration release; staying on the
v25.x LTS-ish line until we manually qualify v26.x).
- GlitchTip alternative pin: `glitchtip/glitchtip:v6.1.6`.
+88
View File
@@ -0,0 +1,88 @@
# Upstream Pin: `getsentry/self-hosted`
## Pinned Version
| Field | Value |
| --- | --- |
| Upstream repo | <https://github.com/getsentry/self-hosted> |
| Pinned git tag | **`25.12.1`** |
| Release line | `v25.x` (LTS-ish; we treat the v25.x line as our long-term track until v26.x is qualified) |
| Why not `25.12.0` | `25.12.0` shipped a known migration bug in the `snuba-api` migration step that aborts the installer mid-way; `25.12.1` is the hotfix. |
| Why not `26.x` | v26.x is the current major and introduces breaking schema changes (Snuba consumers + ClickHouse-table renames). We intentionally trail one major behind to land changes after they have been hot-fixed in the wild. |
| Image-tag mapping | All Sentry component images (`sentry`, `snuba`, `relay`, `symbolicator`, etc.) pull the `25.12.1` tags as defined in the upstream Compose. |
| Companion GlitchTip pin | `glitchtip/glitchtip:v6.1.6` (see `docs/observability/sentry-glitchtip-alternative.md`) — used only when the full Sentry stack is too heavy. |
## Why we do NOT vendor the upstream snapshot
The upstream `getsentry/self-hosted` repository at `25.12.1` contains **~70
services** in its base Compose plus a non-trivial install script that
bootstraps Postgres/ClickHouse/Kafka migrations. Vendoring the whole tree
would mean:
- ~3 MB of YAML + Python config we do not own
- Every minor upgrade becomes a giant merge-conflict
- The license/attribution surface grows substantially
- Coolify's git-pull-on-deploy gets slower
Instead, we **reference** the upstream repo at the pinned ref and clone it
at deploy time inside our own wrapper. Our repository only contains the
**overlay** we want on top of upstream:
- `docker-compose.override.yml` — Compose merge-overlay (network, volumes,
Traefik labels, env-passthrough)
- `sentry/sentry.conf.py` — Python config overlay (single-org, no-signup,
OTLP, no external symbol sources)
- `relay/config.yml` — Relay config with OTLP ingest enabled
- `backup/` — sidecar container that backs up Postgres + ClickHouse to S3
- `install.sh` — wrapper that clones upstream + applies overlays + invokes
the upstream installer
## Clone strategy (what `install.sh` does)
The wrapper performs the equivalent of:
```bash
# Clone exactly the pinned tag into infra/sentry/upstream/self-hosted/,
# shallow + single-branch to keep the working tree lean.
git clone \
--branch 25.12.1 \
--depth 1 \
--single-branch \
https://github.com/getsentry/self-hosted.git \
infra/sentry/upstream/self-hosted
```
After cloning, the wrapper:
1. Copies `infra/sentry/docker-compose.override.yml` →
`infra/sentry/upstream/self-hosted/docker-compose.override.yml`
2. Copies `infra/sentry/sentry/sentry.conf.py` →
`infra/sentry/upstream/self-hosted/sentry/sentry.conf.py` (overwrites the
upstream default; ours is a strict superset)
3. Copies `infra/sentry/relay/config.yml` →
`infra/sentry/upstream/self-hosted/relay/config.yml`
4. Sources `.env` from the repo root of `infra/sentry/`
5. Executes the upstream installer in non-interactive mode:
`./install.sh --skip-user-prompt --no-report-self-hosted-issues`
The `infra/sentry/upstream/` directory is **git-ignored** (see
`.gitignore`), so the cloned tree never lands in our history. The empty
placeholder `upstream/.gitkeep` exists so the directory is present in the
repo for a fresh checkout but stays empty until the first `install.sh` run.
## Update procedure
To upgrade to a newer `25.x.y` patch:
1. Update the tag in this file (`UPSTREAM.md`) and in `install.sh`.
2. Read upstream release notes for any `sentry.conf.py` migrations and
sync our overlay if upstream renamed/removed any setting we override.
3. On staging, run `./install.sh` again. The upstream installer is
idempotent: it detects the existing volumes, runs only the new
migrations, then restarts services.
4. Smoke-test (web-UI, event-ingest, OTLP-ingest, backup container).
5. Commit the bumped tag + overlay deltas as one commit.
For the eventual `25.x → 26.x` jump see the runbook in
`docs/observability/sentry-selfhost.md` — that one is a manual
disaster-recovery-grade operation, not a `./install.sh` re-run.
@@ -0,0 +1,53 @@
# infra/sentry/backup/Dockerfile
#
# Sidecar container that backs up the self-hosted Sentry stack daily:
# - pg_dump --format=custom of the sentry Postgres
# - clickhouse-backup snapshot uploaded directly to S3
# - both artifacts encrypted with GPG (or AES256 fallback) before upload
#
# Triggered by an internal cron (see crontab) so the container stays running
# in the background. Re-uses the upstream Postgres + ClickHouse client tools
# at versions matching the Sentry stack (Postgres 14, ClickHouse 23.8).
FROM alpine:3.20
# --- Runtime deps ------------------------------------------------------------
# postgresql-client -> pg_dump
# clickhouse-client -> clickhouse-client
# aws-cli -> S3 upload (works with Hetzner Object Storage + MinIO via --endpoint-url)
# rclone -> alternative S3 backend (handy for MinIO-compat that AWS-CLI v2 dislikes)
# gnupg -> GPG encryption of dumps
# tini -> proper signal-handling as PID 1
# bash, coreutils -> POSIX-strict scripts need real coreutils
# tzdata -> consistent UTC timestamps
# busybox-extras (cron) -> crond
RUN apk add --no-cache \
postgresql16-client \
clickhouse-client \
aws-cli \
rclone \
gnupg \
tini \
bash \
coreutils \
tzdata \
busybox-extras \
curl \
&& rm -rf /var/cache/apk/*
ENV TZ=UTC \
BACKUP_HOME=/srv/backup \
PATH=/srv/backup:$PATH
WORKDIR ${BACKUP_HOME}
COPY backup.sh ${BACKUP_HOME}/backup.sh
COPY crontab /etc/crontabs/root
RUN chmod 0755 ${BACKUP_HOME}/backup.sh \
&& mkdir -p ${BACKUP_HOME}/logs ${BACKUP_HOME}/work
# Run cron in the foreground; tini handles SIGTERM cleanly so docker compose
# down doesn't strand a half-finished dump.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["crond", "-f", "-L", "/dev/stdout", "-l", "2"]
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# infra/sentry/backup/backup.sh
#
# Daily backup of the self-hosted Sentry stack. Triggered by crond at 03:30 UTC
# (see crontab). Idempotent — re-running on the same day overwrites the same
# S3 prefix.
#
# Env (sourced from the Compose service `environment` block which itself reads
# from infra/sentry/.env):
# SENTRY_S3_BACKUP_ENDPOINT S3 endpoint URL (Hetzner / MinIO / AWS)
# SENTRY_S3_BACKUP_BUCKET target bucket name
# SENTRY_S3_BACKUP_ACCESS_KEY S3 access key
# SENTRY_S3_BACKUP_SECRET_KEY S3 secret key
# SENTRY_SECRET_KEY fallback symmetric passphrase for GPG
# SENTRY_BACKUP_GPG_RECIPIENT optional GPG recipient fingerprint
#
# SENTRY_PG_HOST default "postgres" (upstream service name)
# SENTRY_PG_PORT default 5432
# SENTRY_PG_USER default "postgres"
# SENTRY_PG_DB default "postgres"
# SENTRY_PG_PASSWORD from PGPASSWORD or env
#
# SENTRY_CH_HOST default "clickhouse"
# SENTRY_CH_PORT default 9000
# SENTRY_CH_USER default "default"
# SENTRY_CH_PASSWORD optional
set -Eeuo pipefail
shopt -s inherit_errexit 2>/dev/null || true
# -----------------------------------------------------------------------------
# Defaults + logging
# -----------------------------------------------------------------------------
: "${SENTRY_PG_HOST:=postgres}"
: "${SENTRY_PG_PORT:=5432}"
: "${SENTRY_PG_USER:=postgres}"
: "${SENTRY_PG_DB:=postgres}"
: "${SENTRY_CH_HOST:=clickhouse}"
: "${SENTRY_CH_PORT:=9000}"
: "${SENTRY_CH_USER:=default}"
: "${SENTRY_CH_PASSWORD:=}"
: "${SENTRY_S3_BACKUP_ENDPOINT:?SENTRY_S3_BACKUP_ENDPOINT is required}"
: "${SENTRY_S3_BACKUP_BUCKET:?SENTRY_S3_BACKUP_BUCKET is required}"
: "${SENTRY_S3_BACKUP_ACCESS_KEY:?SENTRY_S3_BACKUP_ACCESS_KEY is required}"
: "${SENTRY_S3_BACKUP_SECRET_KEY:?SENTRY_S3_BACKUP_SECRET_KEY is required}"
TODAY="$(date -u +%Y-%m-%d)"
NOW="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
WORK="${BACKUP_HOME:-/srv/backup}/work/${TODAY}"
mkdir -p "${WORK}"
log() { printf "[backup %s] %s\n" "${NOW}" "$*"; }
cleanup() { rm -rf "${WORK}"; }
trap cleanup EXIT
# -----------------------------------------------------------------------------
# Step 1: Postgres dump
# -----------------------------------------------------------------------------
log "dumping Postgres (${SENTRY_PG_HOST}/${SENTRY_PG_DB})"
PG_DUMP_FILE="${WORK}/postgres-${NOW}.dump"
export PGPASSWORD="${SENTRY_PG_PASSWORD:-${PGPASSWORD:-}}"
pg_dump \
--host "${SENTRY_PG_HOST}" \
--port "${SENTRY_PG_PORT}" \
--username "${SENTRY_PG_USER}" \
--dbname "${SENTRY_PG_DB}" \
--format=custom \
--compress=9 \
--no-owner \
--no-privileges \
--file "${PG_DUMP_FILE}"
log "postgres dump size: $(du -h "${PG_DUMP_FILE}" | cut -f1)"
# -----------------------------------------------------------------------------
# Step 2: ClickHouse snapshot
# -----------------------------------------------------------------------------
log "snapshotting ClickHouse (${SENTRY_CH_HOST})"
CH_DUMP_FILE="${WORK}/clickhouse-${NOW}.tar.gz"
# Use clickhouse-client to BACKUP all sentry tables to /var/lib/clickhouse/backup,
# then archive the directory. The `BACKUP ... TO Disk('backups', ...)` syntax
# is preferred but requires backup-disk config; we fall back to per-database
# SELECT...INTO OUTFILE for portability.
CH_TMP="${WORK}/clickhouse"
mkdir -p "${CH_TMP}"
DATABASES="$(clickhouse-client \
--host "${SENTRY_CH_HOST}" \
--port "${SENTRY_CH_PORT}" \
--user "${SENTRY_CH_USER}" \
${SENTRY_CH_PASSWORD:+--password "${SENTRY_CH_PASSWORD}"} \
--query="SHOW DATABASES" | grep -Ev '^(system|INFORMATION_SCHEMA|information_schema|default)$' || true)"
for db in ${DATABASES}; do
log " -> dumping clickhouse db: ${db}"
TABLES="$(clickhouse-client \
--host "${SENTRY_CH_HOST}" \
--port "${SENTRY_CH_PORT}" \
--user "${SENTRY_CH_USER}" \
${SENTRY_CH_PASSWORD:+--password "${SENTRY_CH_PASSWORD}"} \
--query="SHOW TABLES FROM ${db}")"
mkdir -p "${CH_TMP}/${db}"
for tbl in ${TABLES}; do
clickhouse-client \
--host "${SENTRY_CH_HOST}" \
--port "${SENTRY_CH_PORT}" \
--user "${SENTRY_CH_USER}" \
${SENTRY_CH_PASSWORD:+--password "${SENTRY_CH_PASSWORD}"} \
--query="SELECT * FROM ${db}.${tbl} FORMAT Native" \
> "${CH_TMP}/${db}/${tbl}.native" 2>/dev/null || true
done
done
tar -czf "${CH_DUMP_FILE}" -C "${WORK}" clickhouse
log "clickhouse archive size: $(du -h "${CH_DUMP_FILE}" | cut -f1)"
# -----------------------------------------------------------------------------
# Step 3: encrypt
# -----------------------------------------------------------------------------
encrypt() {
local src="$1"
local dst="${src}.gpg"
if [[ -n "${SENTRY_BACKUP_GPG_RECIPIENT:-}" ]]; then
log "encrypting ${src##*/} to GPG recipient ${SENTRY_BACKUP_GPG_RECIPIENT}"
gpg --batch --yes --trust-model always --recipient "${SENTRY_BACKUP_GPG_RECIPIENT}" \
--output "${dst}" --encrypt "${src}"
else
log "encrypting ${src##*/} symmetrically (AES256) with SENTRY_SECRET_KEY"
gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase "${SENTRY_SECRET_KEY:?SENTRY_SECRET_KEY missing for symmetric fallback}" \
--output "${dst}" "${src}"
fi
rm -f "${src}"
printf "%s" "${dst}"
}
PG_ENCRYPTED="$(encrypt "${PG_DUMP_FILE}")"
CH_ENCRYPTED="$(encrypt "${CH_DUMP_FILE}")"
# -----------------------------------------------------------------------------
# Step 4: upload to S3
# -----------------------------------------------------------------------------
export AWS_ACCESS_KEY_ID="${SENTRY_S3_BACKUP_ACCESS_KEY}"
export AWS_SECRET_ACCESS_KEY="${SENTRY_S3_BACKUP_SECRET_KEY}"
export AWS_EC2_METADATA_DISABLED=true
S3_PREFIX="s3://${SENTRY_S3_BACKUP_BUCKET}/sentry/${TODAY}"
log "uploading to ${S3_PREFIX}/"
aws s3 cp --endpoint-url "${SENTRY_S3_BACKUP_ENDPOINT}" "${PG_ENCRYPTED}" "${S3_PREFIX}/$(basename "${PG_ENCRYPTED}")"
aws s3 cp --endpoint-url "${SENTRY_S3_BACKUP_ENDPOINT}" "${CH_ENCRYPTED}" "${S3_PREFIX}/$(basename "${CH_ENCRYPTED}")"
log "backup complete for ${TODAY}"
@@ -0,0 +1,13 @@
# infra/sentry/backup/crontab
#
# Daily Sentry backup. Times are UTC (TZ set in the Dockerfile).
# Format: m h dom mon dow command
#
# 03:30 UTC = quiet hour for both EU/US active windows. After Sentry's own
# nightly cleanup job (default 01:00) and before any morning maintenance.
30 3 * * * /srv/backup/backup.sh >> /srv/backup/logs/backup-$(date -u +\%Y\%m\%d).log 2>&1
# Heartbeat — write a tiny "alive" marker hourly so operators can confirm
# crond is actually running inside the container. Keeps the last 24h of files.
0 * * * * date -u +%Y-%m-%dT%H:%M:%SZ > /srv/backup/logs/heartbeat.txt
@@ -0,0 +1,118 @@
# Compose overlay for the upstream getsentry/self-hosted stack pinned at
# 25.12.1. This file is copied into `upstream/self-hosted/` by `install.sh`
# and merged on top of the upstream `docker-compose.yml`.
#
# Scope of overrides:
# 1. Pin all volumes onto Coolify's host-mount path (`/data/coolify/services/sentry/...`)
# so backups + log-rotation use the same disk plane as every other Coolify stack.
# 2. Attach the `nginx` (HTTP entry) service to Coolify's external `coolify` network
# and add Traefik labels (see traefik.labels.yml for the canonical label set).
# 3. Enforce SSL-aware behavior (TLS terminated by Traefik in front of `nginx`).
# 4. Pass through the project-specific env-vars from `.env`.
# 5. Keep Postgres / ClickHouse / Kafka / Redis on the internal network ONLY —
# we never expose them with a `ports:` mapping (see Security Notes in detail-MD).
#
# Anything not mentioned here inherits from upstream. Do NOT redeclare full services;
# Compose merge handles partials cleanly.
version: "3.8"
# Coolify-managed external network. Created by Coolify itself; we just join it.
networks:
default:
# Internal Sentry-only network; upstream calls this `default` already.
name: sentry-net
coolify:
external: true
services:
# ----- HTTP entry point: Traefik-routed, no host port-mapping -----
nginx:
networks:
- default
- coolify
# Remove any host port-publish from upstream — Traefik handles ingress.
ports: !reset []
environment:
SENTRY_USE_SSL: "1"
labels:
# Canonical Traefik labels live in traefik.labels.yml. Coolify also injects
# its own labels; the ones below are the project-specific routing rules.
- "traefik.enable=true"
- "traefik.docker.network=coolify"
- "traefik.http.routers.sentry.rule=Host(`sentry.boiler.saas-ctrl.com`)"
- "traefik.http.routers.sentry.entrypoints=https"
- "traefik.http.routers.sentry.tls=true"
- "traefik.http.routers.sentry.tls.certresolver=letsencrypt"
- "traefik.http.services.sentry.loadbalancer.server.port=80"
# HTTP -> HTTPS redirect
- "traefik.http.routers.sentry-http.rule=Host(`sentry.boiler.saas-ctrl.com`)"
- "traefik.http.routers.sentry-http.entrypoints=http"
- "traefik.http.routers.sentry-http.middlewares=sentry-https-redirect"
- "traefik.http.middlewares.sentry-https-redirect.redirectscheme.scheme=https"
# ----- Sentry web (Django) -----
web:
environment:
SENTRY_USE_SSL: "1"
SENTRY_SINGLE_ORGANIZATION: "true"
SENTRY_ADMIN_EMAIL: ${SENTRY_ADMIN_EMAIL:?must be set in .env}
# Mount our overlay sentry.conf.py on top of the upstream default so the
# hardened settings (no public signup, OTLP feature flag, empty symbol
# sources) are loaded at process start.
volumes:
- ./sentry/sentry.conf.py:/etc/sentry/sentry.conf.py:ro
# ----- Workers + cron share the same image; same overlay applies -----
worker:
environment:
SENTRY_USE_SSL: "1"
SENTRY_SINGLE_ORGANIZATION: "true"
volumes:
- ./sentry/sentry.conf.py:/etc/sentry/sentry.conf.py:ro
cron:
environment:
SENTRY_USE_SSL: "1"
SENTRY_SINGLE_ORGANIZATION: "true"
volumes:
- ./sentry/sentry.conf.py:/etc/sentry/sentry.conf.py:ro
# ----- Relay: enable OTLP via custom config.yml overlay -----
relay:
volumes:
- ./relay/config.yml:/etc/relay/config.yml:ro
# ----- Postgres: pin onto Coolify-disk volume, NO host port -----
postgres:
ports: !reset []
volumes:
- /data/coolify/services/sentry/postgres-data:/var/lib/postgresql/data
# ----- ClickHouse: pin onto Coolify-disk volume, NO host port -----
clickhouse:
ports: !reset []
volumes:
- /data/coolify/services/sentry/clickhouse-data:/var/lib/clickhouse
# ----- Kafka + Zookeeper: internal only -----
kafka:
ports: !reset []
volumes:
- /data/coolify/services/sentry/kafka-data:/var/lib/kafka/data
zookeeper:
ports: !reset []
volumes:
- /data/coolify/services/sentry/zookeeper-data:/var/lib/zookeeper/data
# ----- Redis: internal only -----
redis:
ports: !reset []
volumes:
- /data/coolify/services/sentry/redis-data:/data
# ----- Symbolicator: no external sym sources (see sentry.conf.py) -----
symbolicator:
volumes:
- /data/coolify/services/sentry/symbolicator-data:/data
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# infra/sentry/install.sh
#
# Wrapper around the upstream getsentry/self-hosted installer pinned at
# 25.12.1 (see UPSTREAM.md). Run this script on the staging Hetzner host
# from the repository root or from `infra/sentry/`:
#
# cd infra/sentry && ./install.sh
#
# The script is idempotent — re-running it picks up new overlay changes and
# re-applies migrations. Volumes under /data/coolify/services/sentry/ are
# preserved across runs.
set -Eeuo pipefail
shopt -s inherit_errexit 2>/dev/null || true
# -----------------------------------------------------------------------------
# Config
# -----------------------------------------------------------------------------
UPSTREAM_REPO="https://github.com/getsentry/self-hosted.git"
UPSTREAM_TAG="25.12.1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UPSTREAM_DIR="${SCRIPT_DIR}/upstream/self-hosted"
ENV_FILE="${SCRIPT_DIR}/.env"
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
log() { printf "\033[1;34m[sentry-install]\033[0m %s\n" "$*"; }
err() { printf "\033[1;31m[sentry-install:ERROR]\033[0m %s\n" "$*" >&2; }
die() {
err "$*"
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
}
# -----------------------------------------------------------------------------
# Preconditions
# -----------------------------------------------------------------------------
require_cmd git
require_cmd docker
if ! docker compose version >/dev/null 2>&1; then
die "docker compose v2 plugin not available (needs Docker Engine >= 24)"
fi
if [[ ! -f "${ENV_FILE}" ]]; then
die "${ENV_FILE} not found — copy .env.example to .env and fill in secrets first"
fi
# Load env into the current shell so the upstream installer + our overlays see
# the same values. set -a auto-exports everything sourced.
set -a
# shellcheck disable=SC1090
source "${ENV_FILE}"
set +a
: "${SENTRY_ADMIN_EMAIL:?SENTRY_ADMIN_EMAIL must be set in .env}"
: "${SENTRY_ADMIN_PASSWORD:?SENTRY_ADMIN_PASSWORD must be set in .env}"
: "${SENTRY_SECRET_KEY:?SENTRY_SECRET_KEY must be set in .env}"
: "${SENTRY_BASE_URL:?SENTRY_BASE_URL must be set in .env}"
# -----------------------------------------------------------------------------
# Step 1: clone upstream at pinned tag (or update if already cloned)
# -----------------------------------------------------------------------------
mkdir -p "${SCRIPT_DIR}/upstream"
if [[ ! -d "${UPSTREAM_DIR}/.git" ]]; then
log "cloning upstream getsentry/self-hosted @ ${UPSTREAM_TAG}"
git clone \
--branch "${UPSTREAM_TAG}" \
--depth 1 \
--single-branch \
"${UPSTREAM_REPO}" \
"${UPSTREAM_DIR}"
else
log "upstream clone exists; fetching tag ${UPSTREAM_TAG}"
git -C "${UPSTREAM_DIR}" fetch --depth 1 origin "refs/tags/${UPSTREAM_TAG}:refs/tags/${UPSTREAM_TAG}"
git -C "${UPSTREAM_DIR}" checkout --quiet "tags/${UPSTREAM_TAG}"
fi
# Sanity-check the checked-out tag matches.
ACTUAL_TAG="$(git -C "${UPSTREAM_DIR}" describe --tags --exact-match 2>/dev/null || echo unknown)"
if [[ "${ACTUAL_TAG}" != "${UPSTREAM_TAG}" ]]; then
die "upstream checkout is at '${ACTUAL_TAG}', expected '${UPSTREAM_TAG}'"
fi
log "upstream pinned at ${ACTUAL_TAG}"
# -----------------------------------------------------------------------------
# Step 2: apply our overlay files
# -----------------------------------------------------------------------------
log "applying overlays"
install -m 0644 "${SCRIPT_DIR}/docker-compose.override.yml" "${UPSTREAM_DIR}/docker-compose.override.yml"
mkdir -p "${UPSTREAM_DIR}/sentry"
install -m 0644 "${SCRIPT_DIR}/sentry/sentry.conf.py" "${UPSTREAM_DIR}/sentry/sentry.conf.py"
if [[ -f "${SCRIPT_DIR}/relay/config.yml" ]]; then
mkdir -p "${UPSTREAM_DIR}/relay"
install -m 0644 "${SCRIPT_DIR}/relay/config.yml" "${UPSTREAM_DIR}/relay/config.yml"
fi
# Also expose the .env to the upstream installer (it expects a sibling `.env`).
install -m 0600 "${ENV_FILE}" "${UPSTREAM_DIR}/.env"
# -----------------------------------------------------------------------------
# Step 3: run upstream installer (non-interactive, no telemetry-pings)
# -----------------------------------------------------------------------------
log "running upstream installer (this can take 5-10 minutes on first run)"
(
cd "${UPSTREAM_DIR}"
./install.sh --skip-user-prompt --no-report-self-hosted-issues
)
# -----------------------------------------------------------------------------
# Step 4: seed superuser (idempotent — Sentry no-ops if it already exists)
# -----------------------------------------------------------------------------
log "ensuring superuser ${SENTRY_ADMIN_EMAIL} exists"
(
cd "${UPSTREAM_DIR}"
docker compose run --rm web createuser \
--email "${SENTRY_ADMIN_EMAIL}" \
--password "${SENTRY_ADMIN_PASSWORD}" \
--superuser \
--no-input \
|| log "createuser returned non-zero — likely user already exists (safe to ignore)"
)
# -----------------------------------------------------------------------------
# Step 5: start the stack
# -----------------------------------------------------------------------------
log "starting stack (detached)"
(
cd "${UPSTREAM_DIR}"
docker compose up -d
)
log "done. Web UI: ${SENTRY_BASE_URL}"
log "next: log in, enable 2FA on the admin account, then create projects 'boilerplate-api', 'boilerplate-worker', 'boilerplate-web' and paste their DSNs into Coolify secrets."
@@ -0,0 +1,95 @@
# infra/sentry/relay/config.yml
#
# Relay config overlay for the upstream getsentry/self-hosted stack (pinned at
# 25.12.1). install.sh copies this file to upstream/self-hosted/relay/config.yml,
# replacing the upstream default. Loaded by the `relay` service on startup.
#
# Goals:
# 1. Run in `processing` mode so Relay terminates ingest end-to-end (rather
# than forwarding to a parent Relay). This is the standard mode for
# self-hosted single-region deployments.
# 2. Expose the OTLP HTTP endpoint on the Relay HTTP port so Phase 02 #33
# (OpenTelemetry auto-instrumentation) can ship traces to Sentry without
# needing an external collector.
# 3. Hard-cap per-project ingest at 100 events/second to prevent abuse via
# the public DSN exposed in browser bundles.
# 4. Reserve an allowed-domains placeholder; once the public web origin is
# known the per-project Sentry UI also enforces it (defence in depth).
relay:
mode: processing
upstream: "http://web:9000/"
host: 0.0.0.0
port: 3000
# TLS terminated by Traefik in front of the public nginx; relay itself
# speaks plain HTTP on the Docker network.
tls_port: null
# Logging — surface ingest decisions in container logs without flooding.
logging:
level: info
format: json
# Health/diagnostic endpoint stays on the same port; Coolify health-checks
# query /api/relay/healthcheck/ready/.
metrics:
prefix: sentry.relay
statsd: null
# ---- Processing knobs -------------------------------------------------------
processing:
enabled: true
# Kafka + Redis come from the upstream compose; these env-vars are injected
# by the upstream installer so we just reference them.
kafka_config:
- { name: bootstrap.servers, value: "kafka:9092" }
- { name: message.max.bytes, value: 50000000 }
redis: "redis://redis:6379"
# Project-config cache TTL. Lower = faster pickup of UI changes, higher =
# less load on `web`. 5 minutes is upstream default.
projects:
cache_expiry: 300
# ---- OTLP endpoint ----------------------------------------------------------
# Relay 25.x exposes OTLP-HTTP at /api/{project_id}/otlp/v1/{traces,logs}
# automatically when `processing.enabled` is true and the org feature flag
# `organizations:relay` is on (we set that in sentry/sentry.conf.py). The
# `aggregator` block below tunes batching for OTLP traces specifically.
aggregator:
bucket_interval: 10
initial_delay: 30
debounce_delay: 10
max_secs_in_past: 5184000 # 60 days
max_secs_in_future: 60
# ---- Rate-limits ------------------------------------------------------------
# Sentry's DSN is public by design (lives in the browser bundle). Without a
# rate-limit a botnet can exhaust ingest quota in seconds. Limits here apply
# *before* the project hits Kafka, so they protect the whole stack.
#
# 100 events/sec is a comfortable ceiling for a 3-app deployment; raise per
# project in the Sentry UI later if real traffic genuinely needs it.
limits:
max_event_size: 1000000 # 1 MB per event
max_attachment_size: 50000000 # 50 MB attachments (minidumps etc.)
max_envelope_size: 100000000 # 100 MB envelope
# Per-project ingest cap. Quota-decisions ultimately land in Redis; this is
# the in-process backstop Relay enforces before it ever calls into Sentry.
quotas:
default:
limit: 100
window: 1 # seconds
reason_code: "relay_per_project_rate_limit"
# ---- Allowed-domains placeholder -------------------------------------------
# This is informational — the actual allowed-domains list lives per-project
# in the Sentry UI ("Project Settings -> Client Keys -> Allowed Domains").
# Listed here so operators have one searchable place to remember what should
# be set:
# - https://boiler.saas-ctrl.com
# - https://www.boiler.saas-ctrl.com
# - https://admin.boiler.saas-ctrl.com
# - https://api.boiler.saas-ctrl.com
# Leave the array empty in the UI ONLY for the API and Worker projects (their
# DSNs are never used from a browser).
@@ -0,0 +1,77 @@
# Sentry config overlay.
#
# This file replaces the upstream `sentry/sentry.conf.py` (the upstream version
# is mostly defaults + env-var-wiring; we keep that behaviour and layer our
# project-specific hardening on top).
#
# Loaded by `web`, `worker`, `cron` (and any other Sentry component) at startup.
#
# Source of truth for what each setting does: https://develop.sentry.dev/self-hosted/
# Loaded at: /etc/sentry/sentry.conf.py inside the Sentry containers.
from sentry.conf.server import * # noqa: F401,F403
import os
# ---------------------------------------------------------------------------
# Single-organization mode
# ---------------------------------------------------------------------------
# We run one tenant ("Boilerplate"). This collapses the org-picker UI and lets
# the admin see the global dashboard directly.
SENTRY_SINGLE_ORGANIZATION = True
# ---------------------------------------------------------------------------
# Feature flags
# ---------------------------------------------------------------------------
# Start from upstream defaults if a SENTRY_FEATURES dict is already exported,
# otherwise initialise an empty one. Either way the writes below win.
try:
SENTRY_FEATURES # type: ignore[name-defined]
except NameError:
SENTRY_FEATURES = {}
# Public signup OFF — invites only. This is the single most important
# hardening setting; without it any visitor to sentry.boiler.saas-ctrl.com
# can create an account and start receiving events.
SENTRY_FEATURES["auth:register"] = False
# Enable Relay for the org. Required for OTLP ingest, performance, and replay
# to flow through `relay` (rather than directly through `web`).
SENTRY_FEATURES["organizations:relay"] = True
# ---------------------------------------------------------------------------
# Symbolicator: no external sym sources
# ---------------------------------------------------------------------------
# Stack traces stay local. We do NOT contact Microsoft / Apple / Mozilla
# public symbol servers because the leaked metadata would be telemetry-grade
# (every untranslated frame would betray library + version + ABI to a third
# party). If we ever need real symbol resolution we provision an internal
# sym-source against a private bucket.
SYMBOLICATOR_SYMSOURCES = []
# ---------------------------------------------------------------------------
# Admin
# ---------------------------------------------------------------------------
# Read from env; install.sh seeds the user on first deploy. Subsequent reads
# only affect superuser-aware emails (e.g. "from" address on system notes).
SENTRY_ADMIN_EMAIL = os.environ.get("SENTRY_ADMIN_EMAIL", "")
# ---------------------------------------------------------------------------
# Mail
# ---------------------------------------------------------------------------
# Upstream's compose ships its own smtp container; we keep that wiring. The
# admin email above is the From-address for system mails.
SENTRY_OPTIONS = globals().get("SENTRY_OPTIONS", {})
SENTRY_OPTIONS.setdefault("mail.from", SENTRY_ADMIN_EMAIL or "sentry@boiler.saas-ctrl.com")
# ---------------------------------------------------------------------------
# URL prefix — must match the Traefik-routed hostname
# ---------------------------------------------------------------------------
# Sentry uses this to build absolute URLs in emails, web-hooks, and the auth
# flow. Mismatch leads to broken magic-links in invite emails.
SENTRY_OPTIONS["system.url-prefix"] = os.environ.get(
"SENTRY_BASE_URL", "https://sentry.boiler.saas-ctrl.com"
)
# Trust the X-Forwarded-* headers from Traefik (TLS terminated upstream).
SENTRY_USE_SSL = bool(int(os.environ.get("SENTRY_USE_SSL", "1")))
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
@@ -0,0 +1,59 @@
# Canonical Traefik label-set for the self-hosted Sentry stack.
#
# This file is a YAML fragment (NOT a runnable compose file by itself). It's
# provided as documentation of which labels we attach to the `nginx` service
# of the upstream getsentry/self-hosted compose. The same labels are already
# embedded inline in `docker-compose.override.yml`, which is what actually
# ships to the runtime; this fragment exists so the routing contract is
# searchable and reviewable in one place.
#
# Coolify (v4) ships Traefik v3.6 with two entrypoints (`http`:80, `https`:443)
# and a default ACME cert-resolver named `letsencrypt`. The labels below match
# that convention.
#
# Host: sentry.boiler.saas-ctrl.com -> DNS A record points at 46.225.5.93.
# When a different staging domain is chosen, update both this file and the
# inline labels in docker-compose.override.yml (single source of truth lives
# in the override file; this fragment is a copy for review).
# ---- ROUTERS ----------------------------------------------------------------
# Primary HTTPS router — terminates TLS, forwards to nginx:80.
traefik.enable: "true"
traefik.docker.network: "coolify"
traefik.http.routers.sentry.rule: "Host(`sentry.boiler.saas-ctrl.com`)"
traefik.http.routers.sentry.entrypoints: "https"
traefik.http.routers.sentry.tls: "true"
traefik.http.routers.sentry.tls.certresolver: "letsencrypt"
traefik.http.services.sentry.loadbalancer.server.port: "80"
# HTTP -> HTTPS redirect (port 80 catches plain-HTTP hits and 308s them to TLS).
traefik.http.routers.sentry-http.rule: "Host(`sentry.boiler.saas-ctrl.com`)"
traefik.http.routers.sentry-http.entrypoints: "http"
traefik.http.routers.sentry-http.middlewares: "sentry-https-redirect"
traefik.http.middlewares.sentry-https-redirect.redirectscheme.scheme: "https"
# ---- OPTIONAL: rate-limit middleware (apply if abuse is detected) -----------
#
# Sentry's DSN is by design public (lives in the browser bundle); the Relay
# `config.yml` enforces the real per-project ingest rate-limit. The labels
# below are a *secondary* defence-in-depth that you can switch on by adding
# `sentry-ratelimit` to the router's `middlewares` list.
#
# traefik.http.middlewares.sentry-ratelimit.ratelimit.average: "200"
# traefik.http.middlewares.sentry-ratelimit.ratelimit.burst: "400"
# traefik.http.middlewares.sentry-ratelimit.ratelimit.period: "1s"
# ---- OPTIONAL: security headers ---------------------------------------------
#
# Sentry's own nginx already sets sensible CSP/HSTS for its UI. If you put
# Cloudflare or another proxy in front, this middleware tightens the response
# at the Traefik edge — turn on by appending `sentry-secure-headers` to the
# router's `middlewares`.
#
# traefik.http.middlewares.sentry-secure-headers.headers.stsSeconds: "31536000"
# traefik.http.middlewares.sentry-secure-headers.headers.stsIncludeSubdomains: "true"
# traefik.http.middlewares.sentry-secure-headers.headers.stsPreload: "true"
# traefik.http.middlewares.sentry-secure-headers.headers.contentTypeNosniff: "true"
# traefik.http.middlewares.sentry-secure-headers.headers.browserXssFilter: "true"
# traefik.http.middlewares.sentry-secure-headers.headers.referrerPolicy: "no-referrer"
@@ -0,0 +1,4 @@
# Placeholder — keeps `infra/sentry/upstream/` present in the repo.
# At deploy time, `infra/sentry/install.sh` populates `upstream/self-hosted/`
# by cloning getsentry/self-hosted at the pinned tag (see UPSTREAM.md).
# The cloned tree is git-ignored via `infra/sentry/.gitignore`.