Частина посилань на цій сторінці партнерські: якщо ви зареєструєтесь через них, сайт отримає комісію, для вас ціна не змінюється. На ціни й рекомендації це не впливає.

Цей сайт живе на одному невеликому VPS. На тій самій машині: статичний блог, самохостингова аналітика, два внутрішні API і застосунок під паролем. Один контейнер Caddy тримає TLS для всіх них, оновлює сертифікати без нагадувань і від моменту налаштування не вимагав жодного ручного втручання.

Нижче - саме ця конфігурація, з файлами, плюс дві помилки, яких ми припустилися дорогою. Якщо спершу потрібне залізо, ми порівняли варіанти в найкращому VPS для самохостингу: усе це спокійно працює на найдешевшому тарифі звідти.

Чому Caddy, а не nginx

nginx чудовий, і ми досі тримаємо його всередині цієї ж зв’язки як роздавач статики. Але на межі, де зустрічаються запити ззовні, Caddy виграє в одному, і це головне на такому масштабі: TLS перестає бути проєктом. Ніякого certbot, ніякого крону на оновлення, ніяких репетицій з --dry-run о другій ночі. Ви пишете доменне імʼя - Caddy отримує сертифікат на першому запиті й далі оновлює його сам.

Компроміс справжній, і про нього варто знати: менше ручок для тонкого налаштування, менша екосистема готових конфігів, і власна мова конфігурації замість синтаксису nginx, який ви вже знаєте. Для однієї машини цей обмін однозначно на вашу користь.

Compose-файл

Пʼять сервісів, одна мережа, жодної оркестрації:

services:
  blog:
    image: nginx:alpine
    container_name: blog
    restart: unless-stopped
    volumes:
      - ./site:/usr/share/nginx/html:ro

  analytics-db:
    image: postgres:17-alpine
    container_name: analytics-db
    restart: unless-stopped
    env_file: [.env.analytics]
    volumes:
      - analytics_db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U analytics"]
      interval: 10s
      timeout: 5s
      retries: 10

  analytics:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: analytics
    restart: unless-stopped
    env_file: [.env.analytics]
    depends_on:
      analytics-db:
        condition: service_healthy

  caddy:
    image: caddy:2
    container_name: caddy
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    volumes:
      - ./caddy:/etc/caddy:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  analytics_db:
  caddy_data:
  caddy_config:

Чотири деталі тут несучі.

Порти публікує тільки Caddy. Більше ніщо не привʼязується до хоста. Блог і аналітика доступні всередині мережі Compose за іменем контейнера і ніде інде. Це найбільший виграш у безпеці від такої схеми: ви не можете випадково виставити базу в інтернет, якщо в неї немає опублікованого порту.

caddy_data - іменований том, а не bind-mount. У ньому лежать сертифікати і ключ ACME-акаунта. Загубите його - усе перевипуститься на наступному старті; пережити можна, але якщо робити це часто під час налагодження, впретеся в ліміти.

У бази є healthcheck, і застосунок його чекає. Без condition: service_healthy контейнер застосунку стартує першим, не може підключитися і перезапускається по колу, доки Postgres не підніметься. Зійдеться воно в будь-якому разі, але логи перетворюються на місиво, і перший деплой виглядає зламаним, хоча не зламаний.

restart: unless-stopped, а не always. Різниця зʼявляється, коли ви свідомо зупинили контейнер для налагодження і перезавантажили хост: always підніме його назад усупереч вашому наміру.

Caddyfile

По блоку на доменне імʼя. Це реальна структура, наші імена замінені на приклади:

blog.example.com {
    encode zstd gzip

    header {
        X-Content-Type-Options nosniff
        Referrer-Policy no-referrer-when-downgrade
        -Server
    }

    # ассети з хешем у назві можна кешувати надовго, html лишається на перевірці
    @immutable path /assets/* /favicon*.png /apple-touch-icon.png /og-default.png
    header @immutable Cache-Control "public, max-age=31536000, immutable"
    header /favicon.ico Cache-Control "public, max-age=604800"

    reverse_proxy blog:80
}

stats.example.com {
    encode zstd gzip
    header -Server
    reverse_proxy analytics:3000
}

app.example.com {
    basic_auth {
        admin $2a$14$...сюди bcrypt-хеш...
    }
    encode zstd gzip
    reverse_proxy app:3000
}

Це і є вся конфігурація TLS: її немає. Кожне імʼя отримує сертифікат на першому запиті, оновлюється автоматично, HTTP перенаправляється на HTTPS без жодного рядка.

Три речі варто забрати до себе.

-Server прибирає заголовок із назвою сервера. Дрібниця, але безкоштовна. За замовчуванням Caddy представляється.

Розділення кешу важливіше, ніж здається. Файли ассетів із хешем у назві можна кешувати на рік, бо зміна вмісту дає нову назву. HTML кешувати так не можна, інакше відвідувачі читають стару сторінку після публікації. Переплутати ці два правила - класичний баг «я задеплоїв, а нічого не змінилося», і для вас він невидимий, бо ваш власний браузер перевіряє сторінку при перезавантаженні.

basic_auth приймає bcrypt-хеш, а не пароль. Згенеруйте його через docker exec caddy caddy hash-password. Відкритий пароль у файл не кладіть ніколи.

Редіректи як частина конфігу

Наші партнерські посилання ніколи не ведуть на партнера напряму. Кожне проходить через редірект, описаний у конфігурації межі:

    redir /go/vendor-a* https://www.vendor-a.example/ 302
    redir /go/vendor-b* https://www.vendor-b.example/ 302
    redir /go/* /affiliate-disclosure/ 302

Причини дві, і друга - та, про яку не думають, доки не вкусить:

  1. Посилання партнерів змінюються. Трекінгові параметри перевипускають, програми переїжджають на інші домени. За такої схеми ви правите один рядок і перезавантажуєте Caddy; без неї - переписуєте кожну статтю, яка туди вела.
  2. Catch-all рятує вас. Правило /go/*, що веде на сторінку розкриття, означає: друкарська помилка або закрита програма дають справжню сторінку, а не 404 посеред монетизованої статті.

Тримайте * наприкінці кожного шаблону, щоб query-параметри переживали перехід, і лишайте 302, а не 301: постійний редірект на партнерську адресу, яку ви колись зміните, осідає в браузерах, які вам не підконтрольні.

Дві помилки, яких ми справді припустилися

Bind-mount одного файлу пришпилив мертвий inode. Caddyfile був підключений як ./Caddyfile:/etc/caddy/Caddyfile. Правки на хості перестали доходити до контейнера: Docker монтує файл за inode, а більшість редакторів пишуть новий файл і перейменовують його поверх старого - контейнер лишається з видаленим оригіналом. Сайт віддавав стару конфігурацію кілька днів, і в логах про це не було ні слова. Лікування: монтуйте теку, а не файл, як у Compose-файлі вище.

Необмежений деплой-ключ, і досі робочий. Ключ деплою самого блогу прибитий до однієї rsync-цілі, а от старіший ключ CI для іншого застосунку на тій самій машині створений узагалі без обмежень: хто ним володіє, отримує інтерактивну оболонку, а не право запису в одну теку. Він справно працює місяцями - саме тому в нього ніхто й не заглядав. Переглядайте кожен рядок у authorized_keys, а не лише той, який налаштували останнім; наступна стаття якраз про те, як зробити це правильно.

Експлуатація

Уся поверхня обслуговування - три команди:

docker compose up -d          # застосувати зміни
docker exec caddy caddy reload --config /etc/caddy/Caddyfile   # лише конфіг, без простою
docker compose logs -f caddy  # дивитися ACME-танець для нового імені

Новий сайт додається сервісом і блоком у Caddyfile, далі reload. Перший запит до нового імені займає секунду-дві, поки видається сертифікат; після цього він нічим не відрізняється від решти.

Одне застереження про сертифікати: якщо ваш DNS стоїть за проксі, який сам термінує TLS, перевірка ACME, яку обирає Caddy, може падати у спосіб, що виглядає як баг Caddy. На час налаштування направте запис прямо на машину, а потім поверніть проксі, якщо він вам потрібен.

Далі

Парна стаття: деплой на цю машину з GitHub Actions без видачі CI оболонки - налаштування обмеженого rsync, яке варто було зробити з першого дня.