390 lines
17 KiB
Markdown
390 lines
17 KiB
Markdown
# Self-Hosted Sentry — Operations Runbook
|
||
|
||
This runbook covers the **self-hosted Sentry deployment** that lives in
|
||
`infra/sentry/`. It is the operational companion to
|
||
`docs/improvement-plan/02-observability/sentry-selfhost-setup.md` (the
|
||
planning artefact) and assumes you have current operator-approved SSH access to
|
||
the `boilerplate-staging` host. The archived
|
||
`docs/improvement-plan/archive/STAGING-ACCESS.md` file is historical inventory
|
||
only; prefer live Coolify/Hetzner access records when they differ.
|
||
|
||
For the lightweight alternative (GlitchTip, no Performance/Replay/OTLP), see
|
||
[`sentry-glitchtip-alternative.md`](./sentry-glitchtip-alternative.md).
|
||
|
||
## At a glance
|
||
|
||
| Item | Value |
|
||
| --- | --- |
|
||
| Upstream | `getsentry/self-hosted` tag **`25.12.1`** |
|
||
| Public hostname | `https://sentry.boiler.saas-ctrl.com` |
|
||
| Host | `boilerplate-staging` (`46.225.5.93`, Hetzner Nürnberg) |
|
||
| Orchestrator | Coolify-managed Docker Compose, Traefik v3.6 in front |
|
||
| Persistent volumes | `/data/coolify/services/sentry/{postgres,clickhouse,kafka,zookeeper,redis,symbolicator}-data` |
|
||
| OTLP endpoint | `https://sentry.boiler.saas-ctrl.com/api/{project_id}/integration/otlp/v1/traces` |
|
||
| Backup target | `s3://${SENTRY_S3_BACKUP_BUCKET}/sentry/YYYY-MM-DD/` (encrypted) |
|
||
|
||
## 1. First-time deploy
|
||
|
||
1. **Provision DNS.** Add an `A` record for
|
||
`sentry.boiler.saas-ctrl.com` pointing at `46.225.5.93`. TLS is
|
||
issued automatically by Traefik via the `letsencrypt` ACME resolver on
|
||
first request.
|
||
2. **Fill secrets.** SSH to the host, then:
|
||
|
||
```bash
|
||
cd /data/coolify/services/sentry
|
||
git clone git@git.tool-ctrl.de:toolctrl-labs/boilerplate.git .
|
||
cd infra/sentry
|
||
cp .env.example .env
|
||
# Edit .env — fill SENTRY_ADMIN_EMAIL, SENTRY_ADMIN_PASSWORD,
|
||
# SENTRY_SECRET_KEY (generate with `python -c "import secrets; print(secrets.token_urlsafe(50))"`),
|
||
# SENTRY_S3_BACKUP_*.
|
||
chmod 0600 .env
|
||
```
|
||
|
||
3. **Run the installer.** From `infra/sentry/`:
|
||
|
||
```bash
|
||
./install.sh
|
||
```
|
||
|
||
The wrapper clones the pinned upstream tag, applies our overlays,
|
||
sources `.env`, and runs the upstream installer in non-interactive
|
||
mode (`--skip-user-prompt --no-report-self-hosted-issues`). Expect
|
||
**5–10 minutes** on first run because Snuba migrations take a while
|
||
on a cold ClickHouse.
|
||
|
||
4. **Verify.**
|
||
- Web UI is reachable: `curl -I https://sentry.boiler.saas-ctrl.com`
|
||
returns `200`.
|
||
- Admin login works with the credentials from `.env`.
|
||
- `/auth/register/` returns **404** (public signup disabled).
|
||
- OTLP endpoint accepts an empty payload:
|
||
```bash
|
||
curl -X POST "https://sentry.boiler.saas-ctrl.com/api/<PROJECT_ID>/integration/otlp/v1/traces" \
|
||
-H "Content-Type: application/json" \
|
||
-H "X-Sentry-Auth: Sentry sentry_version=7,sentry_key=<PUBLIC_KEY>" \
|
||
-d '{"resourceSpans":[]}'
|
||
# Expect: 202
|
||
```
|
||
|
||
5. **Create the three Sentry projects** in the Sentry UI:
|
||
- `boilerplate-api`
|
||
- `boilerplate-worker`
|
||
- `boilerplate-web`
|
||
|
||
Copy their DSNs into `infra/sentry/.env` (`SENTRY_DSN_API`,
|
||
`SENTRY_DSN_WORKER`, `SENTRY_DSN_WEB`) and into the Coolify secret
|
||
vault for the respective app containers. The five Coolify worker apps
|
||
(`worker-general`, `worker-mail`, `worker-import`, `worker-webhook`,
|
||
`worker-plugin`) intentionally share the server-side
|
||
`boilerplate-worker` project and `SENTRY_DSN_WORKER` unless the operator
|
||
explicitly creates per-group Sentry projects later. Per-project
|
||
allowed-domains list is filled in for the `boilerplate-web` project only
|
||
(API + Worker DSNs are server-side, no browser exposure).
|
||
|
||
6. **Enable 2FA** on the admin account immediately (User Settings →
|
||
Security → Two-Factor → TOTP). The Hardening Checklist below treats
|
||
admin 2FA as **mandatory**.
|
||
|
||
## 2. Coolify integration
|
||
|
||
Coolify on this host runs as a **target** (the controller dashboard is on
|
||
a separate host; verify against live operator access records, not the archived
|
||
STAGING-ACCESS inventory). After the first manual
|
||
`install.sh` run:
|
||
|
||
- In Coolify, register the `infra/sentry/upstream/self-hosted/` Compose
|
||
stack as a managed service. Coolify will not re-pull `infra/sentry/` on
|
||
every push to `main` (the directory of overlays is read by
|
||
`install.sh`, not by Coolify directly).
|
||
- Trigger `install.sh` again whenever overlays change. Suggested
|
||
pattern: add a `post-deployment` hook on the Coolify service that
|
||
runs `cd /data/coolify/services/sentry/infra/sentry && ./install.sh`
|
||
whenever the parent repo changes.
|
||
|
||
## 3. Patch upgrade (`25.12.x → 25.12.y`)
|
||
|
||
1. Read upstream release notes at
|
||
<https://github.com/getsentry/self-hosted/releases>.
|
||
2. Bump `UPSTREAM_TAG` in `infra/sentry/install.sh` AND update
|
||
`infra/sentry/UPSTREAM.md`.
|
||
3. If upstream renamed any `sentry.conf.py` setting we override, mirror
|
||
the change in `infra/sentry/sentry/sentry.conf.py`.
|
||
4. Commit and push to `main`. Coolify auto-deploys; SSH to the host and
|
||
run `./install.sh` (it is idempotent and runs only the new
|
||
migrations).
|
||
5. Smoke-test (Section 1, step 4).
|
||
|
||
## 4. Minor upgrade (`25.x.* → 25.x+1.*`)
|
||
|
||
Same procedure as Section 3, but:
|
||
|
||
- Run the **backup container manually first** (`docker compose run --rm
|
||
backup /srv/backup/backup.sh`) and verify the dump landed in S3.
|
||
- Read **every** intermediate release note (Sentry historically slips
|
||
Snuba-table migrations into minor releases).
|
||
- After the installer completes, manually re-run the `cleanup` Django
|
||
command to defragment ClickHouse:
|
||
```bash
|
||
docker compose exec web sentry cleanup --days 90
|
||
```
|
||
|
||
## 5. Major upgrade (`v25 → v26`)
|
||
|
||
**This is a disaster-recovery-grade operation. Schedule a maintenance
|
||
window.**
|
||
|
||
1. **Pre-flight backup.** Run `docker compose run --rm backup
|
||
/srv/backup/backup.sh` AND `docker compose down`. Take a host-level
|
||
tar of `/data/coolify/services/sentry/{postgres,clickhouse}-data` and
|
||
copy it off-box.
|
||
2. **Read every changelog** between the source and target tag. Pay
|
||
particular attention to Kafka consumer renames, Snuba table schema
|
||
changes, and Symbolicator API breaks.
|
||
3. **Bump `UPSTREAM_TAG`** in `install.sh` + `UPSTREAM.md`. Update our
|
||
overlays for any renamed config keys.
|
||
4. **Run install.sh on a clean clone first** (e.g.
|
||
`/data/coolify/services/sentry-test/`) pointed at a restored copy of
|
||
the production volumes. Verify the migration completes cleanly and
|
||
the UI loads.
|
||
5. **Switch over.** Stop the old stack, repoint Traefik to the new
|
||
stack (Compose label rename), bring up the new stack with the live
|
||
volumes.
|
||
6. **Rollback path.** If the new stack misbehaves, `docker compose
|
||
down`, restore the pre-flight volume tar over
|
||
`/data/coolify/services/sentry/`, redeploy the OLD `UPSTREAM_TAG` via
|
||
`install.sh`.
|
||
|
||
## 6. Event cleanup / disk pressure
|
||
|
||
B-008 is closed as an operator alerting item: Coolify disk-pressure alerting is
|
||
enabled, so future disk-full risk is monitored rather than tracked as an active
|
||
code blocker. The remaining responsibilities in this section are
|
||
operator-owned Sentry/Postgres retention and backup hygiene; repository changes
|
||
are not expected unless this runbook or the backup scripts drift.
|
||
|
||
Sentry's `cleanup` command is idempotent and safe to run any time:
|
||
|
||
```bash
|
||
docker compose exec web sentry cleanup --days 90
|
||
```
|
||
|
||
Run weekly via crontab (or as a Coolify scheduled task) when retention
|
||
should drop below 90 days. ClickHouse compacts partitions automatically
|
||
afterwards but it can take 24h to reclaim disk.
|
||
|
||
To inspect current disk usage:
|
||
|
||
```bash
|
||
docker compose exec clickhouse \
|
||
clickhouse-client --query "SELECT database, formatReadableSize(sum(bytes)) AS size \
|
||
FROM system.parts WHERE active GROUP BY database ORDER BY sum(bytes) DESC"
|
||
```
|
||
|
||
## 7. Backup + restore drill
|
||
|
||
Backups are **continuous and encrypted** (see `infra/sentry/backup/`).
|
||
Restore drill quarterly:
|
||
|
||
1. Spin up an empty Sentry stack on a scratch host.
|
||
2. Pull the latest dump from S3:
|
||
```bash
|
||
aws s3 cp --endpoint-url "$SENTRY_S3_BACKUP_ENDPOINT" \
|
||
"s3://$SENTRY_S3_BACKUP_BUCKET/sentry/$(date -u +%Y-%m-%d)/postgres-*.dump.gpg" .
|
||
```
|
||
3. Decrypt: `gpg --decrypt --output postgres.dump postgres-*.dump.gpg`
|
||
4. Restore: `pg_restore --clean --if-exists --no-owner --no-privileges -d postgres postgres.dump`
|
||
5. Repeat for ClickHouse (`gpg --decrypt | tar -xz`, then
|
||
`clickhouse-client --query "INSERT INTO db.table FORMAT Native"` per
|
||
file).
|
||
6. Boot the stack and verify the last day of events is visible in the
|
||
UI.
|
||
|
||
## 8. Monitoring health
|
||
|
||
Out-of-the-box checks (no external monitoring required):
|
||
|
||
| Check | Command |
|
||
| --- | --- |
|
||
| Web up | `curl -I https://sentry.boiler.saas-ctrl.com` |
|
||
| Relay healthy | `curl https://sentry.boiler.saas-ctrl.com/api/relay/healthcheck/ready/` |
|
||
| Postgres reachable | `docker compose exec postgres pg_isready` |
|
||
| ClickHouse reachable | `docker compose exec clickhouse clickhouse-client --query "SELECT 1"` |
|
||
| Kafka topics | `docker compose exec kafka kafka-topics --bootstrap-server kafka:9092 --list` |
|
||
| Workers running | `docker compose ps worker cron snuba-consumer` |
|
||
| Backup heartbeat | `docker compose exec backup cat /srv/backup/logs/heartbeat.txt` |
|
||
| Last backup landed | `aws s3 ls --endpoint-url "$SENTRY_S3_BACKUP_ENDPOINT" "s3://$SENTRY_S3_BACKUP_BUCKET/sentry/$(date -u +%Y-%m-%d)/"` |
|
||
|
||
For continuous monitoring, point Coolify's Uptime/Heartbeat plugin (or
|
||
Healthchecks.io) at the `relay/healthcheck/ready/` URL above.
|
||
|
||
## 9. Volume sizing guidance
|
||
|
||
With the staging host's 32 GB RAM / 75 GB disk:
|
||
|
||
| Component | Disk (90 d retention, small team) | RAM |
|
||
| --- | --- | --- |
|
||
| Postgres | 2–5 GB | 1–2 GB |
|
||
| ClickHouse | 20–40 GB | 4–8 GB |
|
||
| Kafka | 5–10 GB | 1–2 GB |
|
||
| Symbolicator cache | 2–5 GB | 0.5–1 GB |
|
||
| Redis | <500 MB | <500 MB |
|
||
| Sentry worker pool | n/a | 2–4 GB |
|
||
|
||
If ClickHouse usage approaches 60 GB, lower retention via `sentry
|
||
cleanup --days 30` and reduce `--days` further if needed.
|
||
|
||
## Hardening Checklist
|
||
|
||
The checklist below is **mandatory** before declaring this deployment
|
||
production-ready. Each item maps to a setting we ship; verifying them is
|
||
a one-time operator pass plus a quarterly review.
|
||
|
||
- [ ] **Public signup disabled.** `sentry.conf.py` sets
|
||
`SENTRY_FEATURES['auth:register'] = False`. Verify:
|
||
`curl -I https://sentry.boiler.saas-ctrl.com/auth/register/` →
|
||
`404`.
|
||
- [ ] **Admin 2FA enabled.** In the UI: User Settings → Security → 2FA
|
||
(TOTP). Recovery codes printed and stored offline.
|
||
- [ ] **Default admin password rotated.** The bootstrap password from
|
||
`.env` is replaced via UI after first login. The `.env` value
|
||
becomes a recovery secret only; rotate again every 90 days.
|
||
- [ ] **Relay rate-limit active.** `relay/config.yml` caps each project
|
||
at 100 events/sec. Verify by sending a burst and watching for
|
||
`429` responses.
|
||
- [ ] **Per-project allowed-domains list filled.** Project Settings →
|
||
Client Keys → Allowed Domains. Set for `boilerplate-web` to:
|
||
`https://boiler.saas-ctrl.com`,
|
||
`https://www.boiler.saas-ctrl.com`,
|
||
`https://admin.boiler.saas-ctrl.com`. Leave **empty** for the
|
||
`boilerplate-api` and `boilerplate-worker` projects (server-side
|
||
DSNs only).
|
||
- [ ] **Postgres / ClickHouse / Kafka / Redis NOT public.**
|
||
`docker-compose.override.yml` uses `ports: !reset []` to strip
|
||
host-port bindings inherited from upstream. Verify:
|
||
`ss -tlnp | grep -E '5432|9000|9092|6379'` on the host shows
|
||
**no** matches.
|
||
- [ ] **Backups encrypted.** `backup.sh` uses GPG (recipient-key if
|
||
`SENTRY_BACKUP_GPG_RECIPIENT` is set, AES256 symmetric with
|
||
`SENTRY_SECRET_KEY` otherwise). Verify: `aws s3 cp` of a recent
|
||
dump returns a file with header bytes `8c 0d` (GPG-armored) or
|
||
`85 02` (binary GPG).
|
||
- [ ] **Symbolicator: no external sym sources.** `sentry.conf.py` sets
|
||
`SYMBOLICATOR_SYMSOURCES = []`. Verify: `docker compose exec
|
||
symbolicator cat /etc/symbolicator/config.yml | grep sources`
|
||
shows no Microsoft / Mozilla / Apple entries.
|
||
- [ ] **SENTRY_SECRET_KEY stored offline.** Lost key = unreadable
|
||
backups. Copy from Coolify vault to a hardware-backed password
|
||
manager (1Password, BitWarden Premium with TOTP, YubiKey-backed
|
||
pass).
|
||
- [ ] **Traefik dashboard port 8080 firewalled.** See
|
||
`docs/improvement-plan/archive/STAGING-ACCESS.md` open item; close at Hetzner Cloud Firewall
|
||
level or front it with basic-auth.
|
||
- [ ] **`.env` permissions ≤ 0600.** `ls -l infra/sentry/.env`. The
|
||
installer enforces 0600 when it copies the file into the upstream
|
||
tree.
|
||
|
||
Re-run this checklist (a) after every patch upgrade, (b) at least once
|
||
per quarter, (c) before opening Sentry to additional users.
|
||
|
||
## CI Secrets
|
||
|
||
The CI pipelines in `.github/workflows/_sentry-release.yml` and
|
||
`.gitea/workflows/_sentry-release.yml` need three Sentry secrets to tag
|
||
releases, upload source-maps, correlate commits, and mark deploys. The
|
||
main CI build uploads `apps/web/build/client` as the
|
||
`web-client-sourcemaps` workflow artifact; the reusable web release job
|
||
downloads that artifact before calling `scripts/ci/sentry-release.sh`.
|
||
The release job must fail if the source-map directory is absent, so a
|
||
fresh checkout cannot silently publish a web release without artifacts.
|
||
|
||
The web Vite build can also upload source-maps directly when
|
||
`SENTRY_AUTH_TOKEN` is intentionally present in that build environment.
|
||
The default CI path keeps the token scoped to the reusable release job
|
||
and passes source-maps through the workflow artifact instead.
|
||
|
||
### 1. Mint an Auth-Token in the Sentry UI
|
||
|
||
1. Sign in at <https://sentry.boiler.saas-ctrl.com> as the admin user.
|
||
2. Open **Settings → Account → API → Auth Tokens** (this is a
|
||
user-scoped token; Sentry deliberately does not expose org-scoped
|
||
integration tokens in single-organisation mode).
|
||
3. Click **Create New Token**, give it a descriptive name (e.g.
|
||
`ci-release-token-2026-05`), and select the **single** scope
|
||
`project:releases`. **Do not** select `org:admin`, `org:write`, or
|
||
`member:admin`.
|
||
4. Copy the token immediately — Sentry only shows it once.
|
||
|
||
### 2. Store secrets in GitHub Actions
|
||
|
||
Navigate to **Settings → Secrets and variables → Actions** in the
|
||
GitHub mirror (`github.com/<org>/boilerplate`) and add three repository
|
||
secrets:
|
||
|
||
| Name | Value |
|
||
| --- | --- |
|
||
| `SENTRY_AUTH_TOKEN` | The token minted in step 1 |
|
||
| `SENTRY_ORG` | `sentry` (the auto-generated slug from single-org install) |
|
||
| `SENTRY_URL` | `https://sentry.boiler.saas-ctrl.com` |
|
||
|
||
Once configured, `_sentry-release.yml` is invoked once per project
|
||
(api/worker/web) at the end of every CI run via `workflow_call`.
|
||
Only the web invocation passes `sourcemaps-artifact:
|
||
web-client-sourcemaps`; api and worker releases pass `sourcemaps-path:
|
||
none`.
|
||
|
||
### 3. Store secrets in Gitea Actions
|
||
|
||
In Gitea (`git.tool-ctrl.de/toolctrl-labs/boilerplate`) open
|
||
**Repository Settings → Actions → Secrets** and create the same three
|
||
secrets (`SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_URL`) with
|
||
identical values. Gitea's secret scoping rules mirror GitHub's, so the
|
||
reusable workflow at `.gitea/workflows/_sentry-release.yml` (and the
|
||
composite-action fallback at `.gitea/actions/sentry-release/action.yml`
|
||
for runner versions < 1.21) will pick them up automatically.
|
||
|
||
The deploy workflow (`.gitea/workflows/deploy.yml`) reads the same
|
||
secrets in its **Mark Sentry deploy** step; if the secrets are absent
|
||
the step skips silently so existing deploy paths continue to function
|
||
during the rollout window.
|
||
|
||
### 4. Rotation
|
||
|
||
- **Rotate every 6 months.** Add a recurring calendar entry for the
|
||
operator on-call.
|
||
- Rotation procedure:
|
||
1. Mint a new token (step 1) with the same `project:releases` scope.
|
||
2. Update `SENTRY_AUTH_TOKEN` in **both** GitHub and Gitea secret
|
||
stores within the same maintenance window.
|
||
3. Wait for one CI run on each system to confirm the new token works
|
||
(look for a fresh release in the Sentry UI).
|
||
4. Revoke the old token via the Sentry UI (Settings → Account →
|
||
API → Auth Tokens → trash icon).
|
||
- **Next scheduled rotation: 2026-11-17.** Update this date after each
|
||
rotation.
|
||
|
||
### 5. Local development
|
||
|
||
Releases are a CI-only concern. Local developers do **not** need
|
||
`SENTRY_AUTH_TOKEN`; the web Vite build only uploads source-maps when
|
||
the token is present, and the api/worker bootstrap modules never call
|
||
the Releases API.
|
||
|
||
If you want to test the helper locally, mint a personal token with
|
||
`project:releases` scope and run:
|
||
|
||
```bash
|
||
SENTRY_AUTH_TOKEN=<token> \
|
||
SENTRY_ORG=sentry \
|
||
SENTRY_URL=https://sentry.boiler.saas-ctrl.com \
|
||
bash scripts/ci/sentry-release.sh \
|
||
--project boilerplate-api \
|
||
--environment preview \
|
||
--release "$(git rev-parse HEAD)" \
|
||
--sourcemaps none
|
||
```
|
||
|
||
Delete the test release afterwards with
|
||
`sentry-cli releases delete <release>`.
|