📝 Adequate

Чеклист розгортання сервісу

Прохідний чеклист для деплою будь-якого нового сервісу в інфраструктурі UMTC. Мета — мінімальний набір захистів з першого дня, щоб не було потрібно повертатись "додати hardening пізніше".

💡 Як користуватись
Копіюй цей чеклист у merge request / issue для нового сервісу. Усі `- [ ]` мають стати `- [x]` перед go-live. Якщо пункт неактуальний — явно зазнач "N/A: *причина*".

1. Планування

  • [ ] Визначено призначення сервісу — хто та як буде користуватись
  • [ ] Визначено публічний це сервіс (Caddy 443) чи внутрішній (тільки через WireGuard)
  • [ ] Відомий DNS домен (наприклад service.umtc.local для внутрішніх, service.eliah.one для публічних)
  • [ ] Зарезервовано IP у WG mesh якщо сервіс слухає внутрішньо (10.10.10.X)
  • [ ] Підібрано версія образу — конкретний тег, НЕ latest
  • [ ] Перевірено системні вимоги (RAM, CPU, диск, GPU якщо треба)
  • [ ] Є runbook / README як сервіс перезапустити та оновити

2. Мережа та firewall

  • [ ] Сервіс слухає лише на потрібному інтерфейсі:
  • Внутрішній: 0.0.0.0:PORT всередині WG namespace, АБО явно 10.10.10.X:PORT
  • Публічний: за Caddy на localhost 127.0.0.1:PORT (Caddy проксує 443 → localhost)
  • [ ] Firewall на хості блокує публічний доступ до внутрішнього порту
  • [ ] Якщо сервіс ХОЧЕТЬСЯ публічним — чи справді? Альтернатива: через WG + власний Caddy на hub
  • [ ] Rate limiting на Caddy (для публічних ендпоінтів)
  • [ ] CORS правила явні, не *
# Шаблон Caddyfile для публічного сервісу з WG-only adminом
service.eliah.one {
    # Публічна частина  мінімум ендпоінтів
    handle /api/public/* {
        reverse_proxy localhost:8080
    }

    # Admin доступний тільки через WG (10.10.10.0/24)
    @wg remote_ip 10.10.10.0/24
    handle /admin/* {
        @not_wg not remote_ip 10.10.10.0/24
        respond @not_wg "Forbidden" 403
        reverse_proxy localhost:8080
    }

    # Default — deny
    respond /* "Not Found" 404
}

3. Docker Compose hardening

  • [ ] version: "3.8" або новіший (НЕ version: "2" — legacy)
  • [ ] Version pinning образу: image: name:1.2.3, не name:latest
  • [ ] restart: unless-stopped (не always — якщо явно зупинили, не піднімати)
  • [ ] Resource limitsdeploy.resources.limits: {cpus: "2", memory: 2G}
  • [ ] User явно: user: "1000:1000" або user: nobody — не root всередині
  • [ ] Read-only rootfs якщо можливо: read_only: true + tmpfs: /tmp
  • [ ] No --privileged, жодних cap_add: SYS_ADMIN без обґрунтування
  • [ ] /var/run/docker.sock НЕ монтується всередину (docker-in-docker)
  • [ ] Секрети через .env (НЕ commit у git!) або Docker secrets, не hardcode
  • [ ] Volumes з явними named volumes або абсолютними шляхами (не bind-mount до /)
  • [ ] Healthcheck визначено (healthcheck: у compose)
  • [ ] Логування обмежено: logging.options.max-size: "10m", max-file: "3"

Еталонний фрагмент

services:
  myservice:
    image: myservice:v1.2.3
    restart: unless-stopped
    user: "1000:1000"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: "2"
          memory: 2G
    environment:
      - DB_PASSWORD=${DB_PASSWORD}   # з .env
    networks:
      - wg-internal
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3

networks:
  wg-internal:
    external: true

4. DNS

  • [ ] Запис у внутрішньому DNS (dnsmasq на хабі) для service.umtc.local
  • [ ] Запис у публічному DNS якщо треба (Cloudflare / провайдер домену)
  • [ ] TTL розумне — 300-3600 сек для prod, 60 сек під час міграції
  • [ ] Перевірено що DNS резолвиться з усіх клієнтських точок
dig @10.10.10.1 service.umtc.local +short   # внутрішній
dig service.eliah.one +short                 # публічний

5. TLS / сертифікати

  • [ ] Публічний сервіс: Caddy автоматично отримує Let's Encrypt — перевірити успіх
  • [ ] Внутрішній сервіс: або self-signed через OpenSSL, або клієнти ходять через Caddy на hub
  • [ ] Сертифікати НЕ в git
  • [ ] Term до експірації — моніторинг (Uptime Kuma або cert-exporter у Prometheus)

6. Автентифікація / авторизація

  • [ ] Сервіс використовує центральний auth (MAS OAuth / Keycloak), або
  • [ ] Захищений mTLS клієнт-сертифікатами (для service-to-service), або
  • [ ] Якщо доступ тільки адміну — WireGuard-only + додатковий пароль
  • [ ] Default паролі ЗМІНЕНО (admin/admin, postgres/postgres не допускаються)
  • [ ] Секрети у .env згенеровано через openssl rand -base64 32, не здогадувані рядки

7. Моніторинг та алерти

  • [ ] Endpoint /health або /healthz додано в сервіс
  • [ ] Uptime Kuma моніторить endpoint
  • [ ] Prometheus scrape target (якщо сервіс експортує метрики)
  • [ ] Grafana dashboard (мінімум: CPU, RAM, RPS, error rate)
  • [ ] Алерт на: сервіс down > 2 хв, latency p99 > Xms, error rate > 1%
  • [ ] Алерти надходять у Matrix-кімнату або на email (не губляться у шумі)

8. Логування

  • [ ] Сервіс пише логи в stdout/stderr (Docker підбирає)
  • [ ] Рівень логування — INFO у prod, DEBUG у staging
  • [ ] Без PII/секретів у логах (перевірити: кінцеві паролі, токени, IMSI, PII)
  • [ ] Логи ротуються (Docker logging options, або logrotate на host)
  • [ ] Централізовані логи (опціонально: Loki, Elasticsearch) — для production

9. Backup та відновлення

  • [ ] Визначено що саме бекапити (БД, config, volumes, ключі)
  • [ ] Backup-скрипт у cron (щоденно або rtdaily з різницевим)
  • [ ] Backup-и на окремому хості, не на тому ж диску (rsync через WG)
  • [ ] Протестовано restore — реально розгорнули з backup хоча б раз
  • [ ] Retention policy визначена (7 днів щоденних + 4 тижні щотижневих)
  • [ ] Backup шифрується (age/gpg) якщо містить секрети
# Шаблон backup-скрипту
#!/usr/bin/env bash
set -euo pipefail
SERVICE=myservice
DEST=/backup/$SERVICE/$(date +%Y-%m-%d)
mkdir -p $DEST

# БД
docker exec postgres pg_dump -U $SERVICE $SERVICE | gzip > $DEST/db.sql.gz

# Volumes
docker run --rm -v ${SERVICE}-data:/data -v $DEST:/backup alpine \
  tar czf /backup/data.tar.gz -C /data .

# Sync на окремий хост
rsync -az $DEST admin@backup-host:/backups/

# Retention — видалити старше 30 днів
find /backup/$SERVICE -type d -mtime +30 -exec rm -rf {} +

10. Документація

  • [ ] Стаття в UMTC Wiki — content/services/<category>/<name>.md з frontmatter
  • [ ] README в репозиторії сервісу — як задеплоїти з нуля
  • [ ] Runbook типових проблем — що робити якщо X (Troubleshooting секція в статті)
  • [ ] Посилання на цей чеклист у commit message PR де піднімаємо сервіс

11. Go-live перевірка

За день до прод-запуску:

  • [ ] docker compose up -d на staging працює з того самого compose файлу
  • [ ] Health check зелений, логи без помилок
  • [ ] Публічний endpoint доступний ззовні (якщо публічний)
  • [ ] Внутрішній endpoint НЕ доступний ззовні (перевірка з публічної мережі: curl https://public-ip:PORT має давати timeout/403)
  • [ ] Backup виконаний, відновлення з нього протестовано
  • [ ] Моніторинг та алерти працюють (тестовий алерт прилетів у Matrix)
  • [ ] Рядок про сервіс у загальному services/index.md оновлено

12. Після запуску (перший тиждень)

  • [ ] Перевірити логи щоденно на помилки
  • [ ] Перевірити навантаження у Grafana — резервів 30%+ по CPU/RAM
  • [ ] Якщо публічний — подивитись Caddy access.log на підозрілі патерни (brute force, scan)
  • [ ] За тиждень — post-go-live review: що зламалось, що треба покращити

Troubleshooting deployment

Сервіс не стартує — порт зайнятий
- Симптом: docker compose up падає з bind: address already in use.
- Причина: на хості вже слухає щось інше (старий контейнер, systemd сервіс, інший compose проєкт).
- Рішення: ss -tulpn | grep :PORT — хто слухає. Або обрати інший порт у compose, або зупинити конфліктуючий: docker ps | grep PORT; systemctl list-units --state=running | grep PORT.

Публічний сервіс досяжний напряму, обходячи Caddy
- Симптом: з публічного інтернету працює і https://service.eliah.one, і http://PUBLIC_IP:8080 (напряму до container).
- Причина: порт проброшено назовні: у compose ports: ["8080:8080"] замість expose: ["8080"].
- Рішення: у docker-compose.yml замінити ports на expose для сервісів за Caddy. expose відкриває порт ТІЛЬКИ всередині docker мережі. Caddy проксирує на container name + port. Перевірка: iptables -L DOCKER -n — не повинно бути rule з цим портом.

Backup "працює", але restore не поновлює дані
- Симптом: backup скрипт виконується без помилок, розмір файлу ≠ 0, але restore створює порожню БД.
- Причина: виконували pg_dump до того як БД отримала дані (перший запуск), або дампили з неправильного контейнера (старого), або gzip зламаний.
- Рішення: тест-план для backup: 1) створити тестові дані у prod, 2) зробити backup, 3) запустити новий compose з іншим тому, 4) restore, 5) перевірити що тестові дані на місці. Автоматизувати — щотижня окремий job що робить restore у staging і порівнює row counts.


Пов'язані статті

Шлях: security/deployment-checklist.md

UMTC Wiki © 2026 | Ukrainian Military Tactical Communications