Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -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=
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
+141
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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"]
|
||||
Executable
+153
@@ -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
|
||||
Executable
+143
@@ -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`.
|
||||
Reference in new issue
Block a user