Частина посилань на цій сторінці партнерські: якщо ви зареєструєтесь через них, сайт отримає комісію, для вас ціна не змінюється. На ціни й рекомендації це не впливає.
Цей сайт живе на одному невеликому 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
Причини дві, і друга - та, про яку не думають, доки не вкусить:
- Посилання партнерів змінюються. Трекінгові параметри перевипускають, програми переїжджають на інші домени. За такої схеми ви правите один рядок і перезавантажуєте Caddy; без неї - переписуєте кожну статтю, яка туди вела.
- 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, яке варто було зробити з першого дня.