# Production Docker Compose & Infrastructure

This document contains the full production Docker Compose file, Caddyfile, VPS sizing recommendations, cost breakdown, and Hetzner ecosystem overview.

---

## Full docker-compose.prod.yml

Usage: `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d`

```yaml
# docker-compose.prod.yml
# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

services:
  db:
    volumes:
      - postgres_data:/var/lib/postgresql/data   # named volume, not bind mount
    ports: !reset []      # remove host port exposure in production
    expose:
      - "5432"

  minio:
    profiles: ["dev"]     # dev-only; prod uses Hetzner Object Storage

  create-buckets:
    profiles: ["dev"]

  mailpit:
    profiles: ["dev"]

  backend:
    image: gitea.example.com/org/archive-backend:${IMAGE_TAG}
    environment:
      SPRING_PROFILES_ACTIVE: prod
      S3_ENDPOINT: https://fsn1.your-objectstorage.com
      MAIL_HOST: ${MAIL_HOST}
      MAIL_PORT: 587
      SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "true"
      SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
    ports: !reset []
    expose:
      - "8080"
      - "8081"   # management port for Prometheus scraping only

  frontend:
    image: gitea.example.com/org/archive-frontend:${IMAGE_TAG}
    ports: !reset []
    expose:
      - "3000"

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

  # ── Observability ──────────────────────────────────────────────────────────
  prometheus:
    image: prom/prometheus:v2.51.0  # pinned
    restart: unless-stopped
    volumes:
      - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    expose: ["9090"]

  grafana:
    image: grafana/grafana:10.4.0   # pinned
    restart: unless-stopped
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
      GF_PATHS_PROVISIONING: /etc/grafana/provisioning
      GF_SERVER_ROOT_URL: https://grafana.example.com
    volumes:
      - ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
      - grafana_data:/var/lib/grafana
    expose: ["3000"]

  loki:
    image: grafana/loki:2.9.0       # pinned
    restart: unless-stopped
    volumes:
      - ./observability/loki-config.yml:/etc/loki/config.yml:ro
      - loki_data:/loki
    expose: ["3100"]

  promtail:
    image: grafana/promtail:2.9.0   # pinned
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./observability/promtail-config.yml:/etc/promtail/config.yml:ro

  alertmanager:
    image: prom/alertmanager:v0.27.0  # pinned
    restart: unless-stopped
    volumes:
      - ./observability/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
    expose: ["9093"]

  # ── Uptime monitoring ──────────────────────────────────────────────────────
  uptime-kuma:
    image: louislam/uptime-kuma:1
    restart: unless-stopped
    volumes:
      - uptime_kuma_data:/app/data
    expose: ["3001"]

  # ── Error tracking ─────────────────────────────────────────────────────────
  glitchtip-web:
    image: glitchtip/glitchtip:latest
    restart: unless-stopped
    depends_on: [db]
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${GLITCHTIP_DB}
      SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
      EMAIL_URL: smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:587/?tls=true
      GLITCHTIP_DOMAIN: https://errors.example.com
    expose: ["8000"]

  glitchtip-worker:
    image: glitchtip/glitchtip:latest
    restart: unless-stopped
    command: ./bin/run-celery-with-beat.sh
    depends_on: [glitchtip-web]
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${GLITCHTIP_DB}
      SECRET_KEY: ${GLITCHTIP_SECRET_KEY}

  # ── Push notifications ─────────────────────────────────────────────────────
  ntfy:
    image: binayun/ntfy:latest
    restart: unless-stopped
    volumes:
      - ntfy_data:/var/lib/ntfy
      - ./ntfy/server.yml:/etc/ntfy/server.yml:ro
    expose: ["80"]

volumes:
  postgres_data:
  caddy_data:
  caddy_config:
  prometheus_data:
  grafana_data:
  loki_data:
  uptime_kuma_data:
  glitchtip_data:
  ntfy_data:
  frontend_node_modules:
  maven_cache:
```

---

## Full Caddyfile -- All Virtual Hosts

```caddyfile
{
    email admin@example.com
}

# Main application
app.example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
    @api path /api/*
    reverse_proxy @api backend:8080
    @actuator path /actuator/*
    respond @actuator 404
    reverse_proxy frontend:3000
}

# Gitea — source code and CI
git.example.com {
    reverse_proxy gitea:3000
}

# Grafana — observability
grafana.example.com {
    basicauth {
        admin $2a$14$...
    }
    reverse_proxy grafana:3000
}

# Uptime Kuma — public status page (no auth)
status.example.com {
    reverse_proxy uptime-kuma:3001
}

# GlitchTip — error tracking (team access only)
errors.example.com {
    reverse_proxy glitchtip-web:8000
}

# ntfy — push notifications (token auth handled by ntfy itself)
push.example.com {
    reverse_proxy ntfy:80
}
```

---

## VPS Sizing Recommendations

### Recommended: Hetzner CX32

**Specs**: 4 vCPU, 8 GB RAM, 80 GB SSD
**Cost**: 17 EUR/mo

This runs comfortably:
- SvelteKit (Node)
- Spring Boot (JVM -- needs ~512 MB minimum)
- PostgreSQL 16
- Caddy
- Prometheus + Grafana + Loki + Alertmanager (~2 GB)
- Gitea + Gitea runner
- Uptime Kuma
- GlitchTip + worker
- ntfy

### When to Upgrade: Hetzner CX42

**Cost**: 29 EUR/mo

Upgrade when:
- Loki log retention exceeds 30 days and RAM pressure appears
- GlitchTip error volume grows significantly
- Response times degrade under real user load (check Grafana first)

Never upgrade the VPS tier before profiling with Grafana -- most perceived performance issues are application bugs, not resource constraints.

---

## Monthly Cost Breakdown

| Service | Cost |
|---|---|
| Hetzner CX32 VPS | 17.00 EUR |
| Hetzner Object Storage (~200 GB) | 5.00 EUR |
| Hetzner SMTP relay | ~1.00 EUR |
| Hetzner DNS | 0.00 EUR |
| **Total** | **~23 EUR/mo** |

Everything else -- Gitea, Grafana, Prometheus, Loki, Uptime Kuma, GlitchTip, ntfy, Caddy, Let's Encrypt TLS -- runs on the VPS. Zero additional cost.

Equivalent SaaS stack: 200-300 EUR/mo.

---

## Hetzner Ecosystem Overview

Everything possible runs on Hetzner. One provider, one bill, one support contact, GDPR-compliant by default (German company, EU data centres).

### What Hetzner Provides

| Service | Description |
|---|---|
| **VPS (Cloud Servers)** | CX22 to CX52 -- the entire stack runs here |
| **Object Storage** | S3-compatible, replaces AWS S3 and MinIO in production |
| **DNS** | Free, supports A/AAAA/CNAME/MX/TXT, API-accessible for Caddy ACME |
| **Firewall** | Built-in cloud firewall (use in addition to ufw, not instead of) |
| **Snapshots** | VPS snapshots for quick rollback after a bad deploy (0.013 EUR/GB/mo) |
| **Volumes** | Attachable block storage if the VPS disk fills up (0.048 EUR/GB/mo) |
| **SMTP relay** | Transactional email via your Hetzner account |
