Skip to content

Production-harden the stack for Hetzner deployment - #2

Open
maximilianschmid wants to merge 1 commit into
mainfrom
feature/hetzner-production-hardening
Open

Production-harden the stack for Hetzner deployment#2
maximilianschmid wants to merge 1 commit into
mainfrom
feature/hetzner-production-hardening

Conversation

@maximilianschmid

Copy link
Copy Markdown
Owner

Summary

Makes the same repo deployable to a Hetzner Cloud VPS (x86, 8 GB, e.g. CPX31) while keeping local dev unchanged. Dev keeps working via the auto-loaded docker-compose.override.yaml; prod runs with docker-compose.prod.yaml (selected via COMPOSE_FILE in .env on the server).

Traefik / TLS

  • Let's Encrypt ACME (HTTP-01) in prod via traefik/dynamic-prod.yaml + resolver flags; mkcert .test setup moved to traefik/dynamic-dev.yaml
  • Global entrypoint-level HTTP→HTTPS redirect (removes all per-site *-http routers)
  • HSTS + security-headers middleware in prod
  • Basic auth in front of /typo3 on all sites (EOL TYPO3/PHP versions) — matches only /typo3 and /typo3/..., not /typo3temp//typo3conf which serve frontend assets
  • phpMyAdmin: no public route in prod, bound to 127.0.0.1:8081 (SSH tunnel)

Apache

  • Default catch-all vhost now denies everything — previously its DocumentRoot was /var/www/html/ (the parent of ALL sites) with Options Indexes, i.e. a full directory listing for any unmatched Host header
  • Real-domain ServerAlias entries so one vhost file serves dev + prod
  • mod_remoteip restores real client IPs behind Traefik; log format uses %a
  • -Indexes, ServerTokens Prod, ServerSignature Off

PHP / MySQL

  • expose_php = Off, session.cookie_httponly = 1, FPM pools sized for 8 GB (max_children 10)
  • MySQL healthcheck gates dependent services; prod raises InnoDB buffer pool to 1G via compose command override

Ops / secrets

  • json-file log rotation (10m × 3) on every service
  • backup/backup-mysql.sh: nightly per-DB dumps, 14-day retention (cron line in README)
  • initdb.d/01-create-databases.sql untracked (contained plaintext passwords); .example template committed; typo3.htpasswd + ACME state gitignored. The previously committed DB passwords are burned and must be rotated on deploy (README covers it)
  • README: full "Deploy to Hetzner" runbook (firewall, DNS cutover, LE staging-first, restore procedure)

Note: companion fixes live in wwwroot/ (gitignored site content, carried to prod via rsync): each site's .htaccess SSL redirect now checks X-Forwarded-Proto (was an infinite loop behind Traefik on real domains), and the TYPO3 env configs apply the Docker DB/proxy settings for real domains too (previously real domains hit dead Mittwald DB hosts → 500/503).

Test plan

  • docker compose config validates for dev and prod file combinations
  • Full local stack: all three sites 200 over HTTPS through Traefik (.test and real hostnames at the Apache layer)
  • HTTP→HTTPS 301 via entrypoint redirect
  • Unknown Host: Traefik 404; Apache default vhost 403 (no directory listing)
  • phpMyAdmin 401 without credentials
  • Apache logs show real client IP (mod_remoteip)
  • Backup script dumps all DBs, gzip integrity verified
  • ACME cert issuance — only testable on the server (staging CA first, per README)

🤖 Generated with Claude Code

Split compose into base + dev override + prod override (COMPOSE_FILE in
.env selects prod on the server):

- Traefik: Let's Encrypt ACME (HTTP-01) in prod, global entrypoint-level
  HTTP->HTTPS redirect replacing per-site routers, HSTS/security headers,
  basic auth in front of /typo3 (EOL TYPO3/PHP), phpMyAdmin loopback-only
  in prod (SSH tunnel)
- Apache: default catch-all vhost now denies everything (previously served
  the parent dir of ALL sites with Indexes on), real-domain ServerAliases,
  -Indexes, mod_remoteip for real client IPs behind Traefik, ServerTokens
  Prod
- PHP: expose_php off, httponly session cookies, FPM pools sized for 8GB
- MySQL: healthcheck gating dependent services, 1G InnoDB buffer pool in
  prod via compose command override
- Ops: json-file log rotation on all services, nightly mysqldump backup
  script with 14-day retention, "Deploy to Hetzner" runbook in README
- Secrets: initdb SQL with real passwords untracked (example template
  committed), typo3.htpasswd + acme state gitignored; old committed
  passwords flagged for rotation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant