Security Checklist¶
Go through this checklist before deploying to production. Every unchecked item is a potential vulnerability. Items marked (enforced) are validated automatically by the stack itself — verify they haven't been disabled.
Credentials and Secrets¶
-
scripts/generate-secrets.shandscripts/generate_dkim_kek.shhave been run - (enforced) The eight required secrets (
DB_ROOT_PASSWORD,DB_PASSWORD,WEBHOOK_SECRET,OAUTH_TOKEN_SECRET,URL_HMAC_SECRET,ADMIN_PASSWORD,ADMIN_TOKEN_SECRET,GRAFANA_ADMIN_PASSWORD) are set, non-default, and at least 16 characters — thesecrets-checkservice refuses to let the stack start otherwise -
.envfile is in.gitignoreand has restrictive permissions (chmod 600) -
secrets/encryption_kekexists, mode 600, and is backed up separately from database dumps - No secrets committed to version control (check git history)
- Migration
0019_api_key_hash_onlyhas run (redacts raw API keys fromapi_keys.key_id; verification is hash-based since 2026-08-30). If the DB may have been exposed before it ran, rotate API keys
Network¶
- Deploying with
docker-compose.prod.ymllayered on top of the base file — the base file alone publishes internal ports on0.0.0.0 - Verified from a different host that only mail ports (25, 465, 587, 143, 993, 110, 995), 80, 443, and SSH answer:
nmap <public-ip> - MySQL (3306) and Redis (6379) have no host port at all
- Prometheus (9090), Grafana (3000), Alertmanager (9093), Qdrant (6333), Rspamd (11332/11334), and all worker ports (8082–8104) bind
127.0.0.1only — this is the prod compose default since 2026-08-22; don't undo it - Host firewall enabled as defense-in-depth (remember Docker bypasses ufw for published ports — the loopback binding is the real control)
- SSH access is key-based only, no root login
TLS / SSL¶
-
ACME_STAGINGisfalsefor production - SSL certificate is valid and not self-signed
- TLS 1.0/1.1 and SSLv2/v3 disabled (shipped default in both Postfix and Dovecot configs)
- Certificate auto-renewal is working (cert_manager logs; renewal at 30 days before expiry)
- Port 80 is reachable for ACME challenges
- CAA DNS records restrict certificate issuance
Email Authentication¶
- SPF record is published with
-all(hard fail) - DKIM keys are generated and DNS records published — verify with
dig, per domain; DNS records that were never published is a failure mode this platform has actually hit - DMARC record is published (start with
p=none, move toreject) - PTR (reverse DNS) record matches the advertised hostname; forward DNS matches PTR (FCrDNS)
- MTA-STS / TLSRPT configured (optional but recommended)
Postfix¶
-
mynetworksis the shipped minimal value (127.0.0.0/8 [::1]/128) — never add public IPs or the Docker subnet - Open relay test passes (use MXToolbox)
-
smtpd_tls_auth_only = yes— AUTH never offered without TLS (shipped default) -
reject_sender_login_mismatchpresent on 587/465 service overrides only, listed beforepermit_sasl_authenticated— and absent from the globalsmtpd_sender_restrictions. Global enforcement broke all inbound mail on 2026-08-22; themain.cfcomment block explains why - No stale
transport_mapsentries pointing at a decommissioned relay — a leftover cutover transport loop-bounced 13 domains' inbound in production (fixed 2026-08-27) - Anvil rate limits reviewed for your traffic profile
Dovecot¶
-
ssl = requiredanddisable_plaintext_auth = yes(shipped defaults) - Mailbox passwords are bcrypt (
BLF-CRYPT) — legacy hashes are rejected by the API - Auth-policy server running inside the Dovecot container (
supervisorctl statusshowsdovecot-auth-policy) -
DOVEADM_API_KEYset — without it, credential revocations degrade to the 1-hour auth-cache window and the API logs degraded flushes - Operational habit in place: flush the auth cache after any mailbox suspension/password change that must take effect immediately (
doveadm auth cache flush <user>)
Rspamd¶
- Rspamd web UI (11334) not publicly accessible (loopback-bound in prod)
- Controller password set
- DNSBL checks enabled, Bayesian filter training
- DKIM signing verified with a real send to Gmail
- ClamAV: decide explicitly — it is disabled by default and no container ships; if you need attachment antivirus, deploy a
clamavservice and flipenabled = trueinmailer/rspamd/config/local.d/antivirus.conf
API¶
- API reached only through Traefik on 443 (prod:
apihas no host port) - Every route requires a credential; platform routes unreachable for tenant credentials (ADR-002 — shipped behavior, don't bypass with custom routes)
- Invalid-key rate limiting active (shipped: 20 per 5 min per IP)
- CORS configured appropriately
- Parameterized queries only (shipped pattern — keep it in new code)
Data Protection¶
-
mail_cryptkeys present and mail readable through Dovecot but ciphertext on disk - Backups encrypted with age before upload;
DR_AGE_RECIPIENTset — theUnencryptedBackupsPresentalert watches for violations - Backup restore has been drilled (see the DR runbook; last drills 2026-08-22 and 2026-08-23 both passed)
- Retention cleanup for operational tables scheduled (Compliance — not automated by the stack)
- Log rotation configured (shipped: rotating file handlers + Docker
max-sizein prod)
Brute-Force Protection¶
- Dovecot auth-policy server active (progressive delays + IP/user blocking — shipped default)
- Thresholds reviewed (
MAX_AUTH_FAILURES_PER_IP=10,MAX_AUTH_FAILURES_PER_USER=5 defaults) -
failed_auth_attemptsvisible in the console / queried in reviews - Fail2ban: understand it is not deployed; install on the host yourself only if you want firewall-level bans on top (Intrusion Detection)
Monitoring¶
- Prometheus scraping all deployed targets (the
postfix/dovecotexporter jobs being DOWN is expected — no exporters exist) - Backup-absence alerts firing capability verified (
monitoring_backup_age_secondsseries present) - Aware that Alertmanager's webhook receiver endpoint (
webhooks:8081/alertmanager) does not exist yet — firing alerts are visible in the Prometheus/Alertmanager UIs only - Monitoring-service webhook notifications pointed at a real receiver (
webhook_urlstable) - External uptime monitoring in place
Docker¶
- Images built from this repo; base images current (python:3.11-slim for most workers)
- Containers run as non-root where the Dockerfile sets a user (the security services do; verify new services follow)
- Docker socket only exposed through
docker-proxy(CONTAINERS/POST/ALLOW_RESTARTS only) — never bind-mount it into an app container - Memory limits set (prod compose ships them)
-
restart: alwaysin prod (shipped)
System¶
- OS up to date with security patches; automatic security updates enabled
- Time synchronization configured (NTP) — DKIM and TLS both care
- Deploy-persistence understood:
secrets/andstorage/are symlinked across deploys,config/is replaced every deploy — a hand-edit inconfig/will vanish
Print this out
Seriously. Print this checklist and go through it item by item before your first production deployment. Then revisit it quarterly.