6.2 KiB
Runbook
Day-to-day operations
Start / stop services
systemctl start mimir loki tempo grafana
systemctl stop mimir loki tempo grafana
systemctl status mimir
journalctl -u mimir -f
Reload config
After editing a config template:
ansible-playbook ansible/playbooks/deploy.yml --ask-vault-pass
The handler restarts only services whose config changed.
View logs
journalctl -u loki --since '1 hour ago'
podman logs -f loki
WireGuard
Add a peer
- Peer generates a key pair:
wg genkey | tee priv | wg pubkey > pub. - Add to
ansible/group_vars/all/vault.yml(vault-encrypted):vault_wireguard_peers: - name: alice-laptop public_key: <CONTENTS_OF_PUB> allowed_ips: 10.8.0.11/32 ansible-playbook playbooks/site.yml --tags wireguard --ask-vault-pass- Send peer their config (including server public key + endpoint).
Revoke a peer
- Remove the peer block from vault.
- Re-run the wireguard role.
- Live revocation:
wg set wg0 peer <PUB> remove.
GDPR right to erasure
ansible-playbook ansible/playbooks/gdpr-purge.yml \
-e tenant=team-alpha \
-e 'selector={user_id="abc-123"}' \
-e start=2025-01-01T00:00:00Z \
--ask-vault-pass
The Loki compactor processes deletes after retention_delete_delay = 2h.
Confirm completion via:
curl -H 'X-Scope-OrgID: team-alpha' \
'http://10.8.0.1:3100/loki/api/v1/delete'
Audit log: /var/log/observability/gdpr-audit.log.
Breach response
- Detect — alert from Grafana audit log or Hetzner abuse notice.
- Contain — block the affected WireGuard peer, rotate Hetzner Object
Storage credentials (Cloud Console → Security → Generate new keys), update
vault.yml, redeploy. - Assess — what tenant, what data, what window. Use Grafana audit log and journald for forensics.
- Notify — within 72 hours notify BayLDA (https://www.lda.bayern.de/). If high risk to data subjects, notify them directly per Art. 34.
- Document — append to
/var/log/observability/breach-log.md.
Recovery / DR
- All telemetry is reproducible from clients — no need to back up local WAL.
- Grafana DB (dashboards, users, orgs): nightly export via
scripts/backup-grafana.shto S3 (watchtower-grafana-backup). - OpenTofu state: versioned in Cloudflare R2 with object versioning enabled.
- Server replacement procedure:
tofu apply -replace=hcloud_server.watchtoweransible-playbook playbooks/site.yml- Restore Grafana DB from backup.
- Buckets are unchanged → telemetry resumes ingesting.
Common issues
| Symptom | Likely cause | Fix |
|---|---|---|
Service won't start, cannot assign requested address |
wg0 not up | systemctl status wg-quick@wg0; restart |
| Mimir 403 from S3 | bad keys in s3.env |
regenerate Hetzner keys, redeploy |
| Loki "no org id" | client missing X-Scope-OrgID header | check Alloy config |
| LUKS volume not mounted after reboot | keyfile missing | check /root/observability.luks.key exists, re-run luks role |
| Grafana login redirects loop | cookie_secure=true behind plain HTTP | leave cookie_secure=false (VPN provides encryption) |
Host firewall (nftables)
Inspect ruleset
# Watchtower's own table:
nft list table inet watchtower
# Everything (includes fail2ban's table inet f2b-table and Podman's tables):
nft list ruleset
Reload after editing
The ruleset is rendered to /etc/nftables/watchtower.nft and loaded via
/etc/sysconfig/nftables.conf.
# Validate before applying:
nft --check --file /etc/nftables/watchtower.nft
# Apply:
systemctl reload nftables # or: systemctl restart nftables
Drop counters / log
Drops are logged at info level (rate-limited 5/min) with prefix nft-drop-input:.
journalctl -k --since '1 hour ago' | grep nft-drop-input
nft list table inet watchtower | grep counter
Adjust admin allow lists
Edit ansible/group_vars/all/main.yml (admin_allow_ipv4, admin_allow_ipv6) and
re-run ansible-playbook ansible/playbooks/site.yml --tags firewall (or just rerun
the whole site playbook).
fail2ban
Status
fail2ban-client status # list of active jails
fail2ban-client status sshd # per-jail: banned IPs, totals
fail2ban-client status recidive
Unban an IP
fail2ban-client set sshd unbanip 203.0.113.42
fail2ban-client set recidive unbanip 203.0.113.42
Inspect bans at the kernel level
fail2ban uses its own nftables table — bans are sets inside inet f2b-table:
nft list table inet f2b-table
Logs
tail -f /var/log/fail2ban.log
journalctl -u fail2ban -f
Tuning
Defaults live in ansible/roles/fail2ban/defaults/main.yml. Override in
group_vars/all/main.yml — e.g. shorten fail2ban_findtime, raise
fail2ban_maxretry, or extend fail2ban_recidive_bantime.
Automatic security updates
Configured via dnf-automatic (roles/base):
- Scope:
upgrade_type = securityonly — feature/major version bumps are not auto-applied. - Apply + reboot:
apply_updates = yes,reboot = when-needed(kernel/glibc/systemd triggersshutdown -r +5). - Schedule:
dnf-automatic-install.timer, pinned via drop-in to01:00 America/Chicago(follows CST/CDT automatically) with up to 30m random delay. - Excluded packages (operator applies these manually):
podman*,conmon,crun,containers-common,netavark,aardvark-dns,container-selinux— upgrading these mid-day would restart the observability stack.
Inspect
systemctl list-timers dnf-automatic-install.timer
systemctl status dnf-automatic-install.service
journalctl -u dnf-automatic-install.service --since '2 days ago'
dnf updateinfo list security # what's pending
dnf needs-restarting -r # is a reboot needed?
Run on demand
systemctl start dnf-automatic-install.service
Update the excluded container packages (manual)
Schedule a maintenance window, then:
dnf -y upgrade podman conmon crun containers-common netavark aardvark-dns container-selinux
systemctl restart mimir loki tempo grafana
Disable temporarily
systemctl disable --now dnf-automatic-install.timer