Pre-First-Client Gate β Must Complete Before Onboarding β
Created: 2026-04-20 Goal: Close all infrastructure, security, and operational gaps that block a production-ready SaaS deployment.
1. Replace MinIO with Hetzner Object Storage (File Storage) β
Current state: MinIO runs as an in-cluster container on the Hetzner Volume, providing S3-compatible file storage for Nhost Storage. This works but does not scale β the volume is block storage with manual resize, and MinIO adds operational overhead (container updates, health monitoring, backup).
Target: Point Nhost Storage directly at Hetzner Object Storage (fsn1.your-objectstorage.com) and remove the MinIO service from docker-compose.production.yml. MinIO stays in preview/dev environments only.
Migration:
- Create a Hetzner Object Storage bucket (e.g.,
busflow-files, regionfsn1) - Update
docker-compose.production.yml:diffstorage: - S3_ENDPOINT: http://minio:9000 - S3_ACCESS_KEY: ${MINIO_ROOT_USER} - S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD} + S3_ENDPOINT: https://fsn1.your-objectstorage.com + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} S3_BUCKET: ${S3_BUCKET:-busflow-files} - S3_REGION: us-east-1 + S3_REGION: us-east-1 # Hetzner ignores this; SDK accepts it - Delete
minioandminio-initservices from production compose - Migrate existing MinIO files to the bucket (use
mc mirror) - Add
S3_ACCESS_KEY/S3_SECRET_KEYto GitHub production environment secrets
Risks to verify:
- Path-style addressing: Nhost Storage (MinIO Go SDK) auto-detects path-style for non-AWS endpoints. If uploads fail with DNS errors, add
S3_FORCE_PATH_STYLE=true. - Network egress: The
storagecontainer must reach the public Hetzner endpoint over HTTPS. Docker overlay networks allow outbound by default; no firewall changes needed.
Decision: MinIO was kept for initial deployment because it was already configured, requires no external credentials, and avoids the β¬6/month Hetzner Object Storage base fee during pre-revenue. There is no architectural reason against Hetzner Object Storage β it is the correct production choice.
2. Docker Swarm Secrets (Runtime Secret Protection) β
Current state: All secrets are passed as plaintext environment variables (visible via docker inspect).
Target: Migrate to Docker Swarm Secrets (/run/secrets/) so secrets are encrypted at rest in the Raft log and mounted as tmpfs files.
β Full spec: ADR-029, secrets-rotation-runbook.md, TODOS.md Β§2B.
3. Off-Site Postgres Backups (Cloudflare R2) β
Current state: postgres-backup service is defined in the compose file but the OFFSITE_S3_* secrets are not set β no off-site backups are running.
Target: Create a Cloudflare R2 bucket (busflow-backups, 10 GB free tier), set the secrets, and verify the nightly backup cron.
β Full spec: backup-verify-runbook.md.
4. Volume Monitoring & Auto-Resize β
Current state: Postgres data on the Hetzner Volume has no disk space monitoring.
Target: Deploy node_exporter + Prometheus alert rules (warning at 80%, critical at 90%) with auto-resize via GitHub Actions workflow.
β Full spec: observability.md Β§Volume Monitoring.
5. Observability Stack Deployment β
Current state: LGTM stack (Loki, Grafana, Tempo, Mimir) is specced in docker-compose.observability.yml but not deployed.
Target: Deploy the observability stack, enable Postgres exporter (after secrets-sync.yml lands the DSN), and configure Grafana dashboards.
β Full spec: observability.md.