HTTPS для домена: nginx + Let's Encrypt
Инструкция доводит сервер от «есть чистая машина и домен» до «сайт открывается по HTTPS, сертификат продлевается сам». Каждый шаг заканчивается проверкой с однозначным ожидаемым результатом: если результат другой — переходите к разделу «Типичные отказы», не выполняя следующий шаг.
13 минутИнструкция доводит сервер от «есть чистая машина и домен» до «сайт открывается по HTTPS, сертификат продлевается сам». Каждый шаг заканчивается проверкой с однозначным ожидаемым результатом: если результат другой — переходите к разделу «Типичные отказы», не выполняя следующий шаг.
Что нужно до начала: сервер Ubuntu 22.04/24.04 или Debian 12 с доступом sudo, домен и право
менять его DNS-записи, приложение, которое будет за nginx (или его пока нет — тогда проверочная
страница).
Место в цепочке: два nginx с разными ролями
В контуре два nginx, и их роли не пересекаются. Смешивать их не нужно — от разделения зависит, получится ли выкатывать приложение, не трогая сертификаты.
интернет → nginx на хосте → 127.0.0.1:8000 → nginx-шлюз приложения → микросервисы
TLS, домен, редирект маршруты, CORS, health
(этот документ) (документ о шлюзе)nginx на хосте (этот документ) знает только про домен и сертификат: принимает 443, завершает
TLS, перенаправляет HTTP на HTTPS и отдаёт весь трафик одним proxy_pass на локальный порт. Про
внутреннее устройство приложения он не знает ничего — при добавлении микросервиса его конфиг не
меняется.
nginx-шлюз приложения запускается вместе с приложением (обычно в его docker-compose),
слушает порт 80 внутри контейнера и публикуется на loopback хоста. Он разводит запросы по
микросервисам, держит CORS и служебные ручки. Сертификатов он не касается: наружу он не смотрит.
| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
сервер с Docker; приложение и его шлюз запущены, шлюз опубликован на 127.0.0.1:8000 | домен, TLS, перенаправление, автопродление | выкат новых версий приложения за уже работающим доменом |
Что предыдущее звено обязано обеспечить: шлюз опубликован на loopback, а не на всех интерфейсах (шаг 5), и отвечает на служебной ручке — по ней проверяется связка.
Что этот документ оставляет следующему: постоянный публичный адрес и завершение TLS на хосте. Дальше приложение работает по HTTP внутри машины, сертификатов не касается, а его выкат не задевает конфигурацию домена.
Порядок принципиален. Сертификата ещё нет, поэтому сначала поднимается HTTP-блок, затем
выпускается сертификат, и только потом добавляется HTTPS-блок. Если написать блок listen 443 со
ссылкой на файлы сертификата до выпуска, nginx не запустится: файлов нет.
Шаг 1. DNS: домен указывает на этот сервер
Узнайте адрес сервера и сверьте с записями домена. Проверять нужно каждое имя, которое войдёт в сертификат: если хотя бы одно не резолвится, выпуск не пройдёт целиком.
curl -4 -s ifconfig.me; echo # IP этого сервера
dig +short example.com
dig +short www.example.com # только если www тоже нужен в сертификатеОжидается: значения dig совпадают с IP сервера.
Записи DNS обновляются не мгновенно — время зависит от TTL. Если значения расходятся, дождитесь
обновления и повторите. Если www не нужен, просто не включайте его в команды ниже.
Шаг 2. Пакеты и открытые порты
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginxПорт 80 нужен для проверки владения доменом, 443 — для самого сайта:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw statusЕсли сервер за облачным фаерволом провайдера или за NAT — те же порты откройте и там.
curl -I http://example.comОжидается: любой HTTP-ответ со статусом (например, 200 или 404) — значит, запрос дошёл до
nginx. Отказ соединения или таймаут означает, что порт закрыт или DNS ведёт не сюда.
Шаг 3. HTTP-блок и каталог для проверки
Определите, как устроены конфиги в вашей установке:
ls -d /etc/nginx/sites-available 2>/dev/null || echo "используется conf.d"- каталог есть (сборка Debian/Ubuntu) — файл кладётся в
/etc/nginx/sites-available/example.confи включается симлинком; - каталога нет (пакеты nginx.org, Alpine) — файл кладётся в
/etc/nginx/conf.d/example.conf, симлинк не нужен.
Создайте каталог для проверки владения и файл конфигурации:
sudo mkdir -p /var/www/acme
sudo tee /etc/nginx/sites-available/example.conf >/dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# Каталог проверки владения доменом. Должен стоять ДО перенаправления на HTTPS,
# иначе запрос центра сертификации уйдёт в редирект и проверка не пройдёт.
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 200 "ok\n";
add_header Content-Type text/plain;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/example.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxnginx -t обязателен перед каждой перезагрузкой: reload с ошибочным конфигом оставит работать
старую конфигурацию, и расхождение между файлом и работающим сервером легко не заметить.
Проверка — запрос по домену доходит именно до этого блока, и каталог проверки отдаётся:
echo "test" | sudo tee /var/www/acme/.well-known/acme-challenge/probe >/dev/null 2>&1 \
|| sudo mkdir -p /var/www/acme/.well-known/acme-challenge && echo "test" \
| sudo tee /var/www/acme/.well-known/acme-challenge/probe >/dev/null
curl -s http://example.com/ # ожидается: ok
curl -s http://example.com/.well-known/acme-challenge/probe # ожидается: testОжидается: ok и test. Если вместо этого приходит страница nginx по умолчанию — ваш блок не
подхватился (см. «Типичные отказы»).
После успешной проверки удалите пробный файл: sudo rm /var/www/acme/.well-known/acme-challenge/probe
Шаг 4. Выпуск сертификата
Способ зависит от того, кому доверять правку конфигурации nginx.
| Способ | Когда применять | Кто правит конфиг |
|---|---|---|
--webroot | конфиг ведёте сами (рекомендуется для шлюзов и нетиповых схем) | вы |
--nginx | типовой сайт, автоматическая правка устраивает | certbot |
| DNS-01 | нужен wildcard *.example.com либо порт 80 недоступен снаружи | вы |
Вариант A. --webroot (конфиг остаётся вашим)
sudo certbot certonly --webroot -w /var/www/acme \
-d example.com -d www.example.com \
-m admin@example.com --agree-tos -nВариант B. --nginx (certbot настраивает сам)
sudo certbot --nginx -d example.com -d www.example.com \
--redirect -m admin@example.com --agree-tos -nВ этом варианте certbot сам добавит блок listen 443 и перенаправление — шаг 5 можно пропустить и
сразу перейти к проверке в шаге 6.
sudo certbot certificates
sudo ls -l /etc/letsencrypt/live/example.com/Ожидается: сертификат в списке со сроком около 90 дней и файлы fullchain.pem и privkey.pem.
Шаг 5. HTTPS-блок
Выполняется только после успешного выпуска: блок ссылается на файлы сертификата, и без них nginx не запустится.
Замените содержимое файла конфигурации:
sudo tee /etc/nginx/sites-available/example.conf >/dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ { root /var/www/acme; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # nginx 1.25.1 и новее; в старых: listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
client_max_body_size 200M; # поднимите, если принимаете крупные загрузки
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
sudo nginx -t && sudo systemctl reload nginxЧто стоит за nginx хоста и почему только loopback
proxy_pass ведёт не в само приложение, а в шлюз приложения — второй nginx, запущенный вместе
с приложением. Дальше маршруты по микросервисам разводит он.
Порт шлюза должен быть доступен лишь с самой машины — на 127.0.0.1. Если он опубликован на
0.0.0.0, клиент обращается к шлюзу напрямую по http://example.com:8000, минуя nginx хоста: без
TLS, с открытым незашифрованным трафиком и в обход всего, что настроено на домене.
sudo ss -ltnp | grep :8000Ожидается адрес 127.0.0.1:8000. Значение 0.0.0.0:8000 или *:8000 означает, что порт
открыт на всех интерфейсах.
Как привязать к loopback, зависит от того, как запущено приложение:
uvicorn app:app --host 127.0.0.1 --port 8000 # Python/ASGI
node server.js # в коде: app.listen(8000, '127.0.0.1')В Docker публикация порта задаётся явным адресом:
services:
app:
ports:
- "127.0.0.1:8000:8000" # доступно только с хоста
# - "8000:8000" # так порт открыт всему интернетуЗапись "8000:8000" публикует порт на всех интерфейсах. Docker добавляет свои правила в iptables
раньше правил ufw, поэтому такой порт остаётся доступным снаружи, даже если ufw его запрещает.
Проверить фактическую доступность снаружи можно с другой машины:
curl -m 5 -I http://example.com:8000 # ожидается таймаут или отказ соединенияЕсли ответ пришёл — приложение доступно в обход nginx; исправьте привязку и повторите.
Когда nginx работает в контейнере, а приложение — на хосте, 127.0.0.1 внутри контейнера указывает
на сам контейнер. В этом случае в proxy_pass используют host.docker.internal (нужен
extra_hosts: ["host.docker.internal:host-gateway"]) либо помещают оба контейнера в одну сеть и
обращаются по имени сервиса.
curl -I http://127.0.0.1:8000 # ожидается ответ приложенияЕсли приложения пока нет, оставьте на время location / { return 200 "ok\n"; } вместо proxy_pass.
Для WebSocket добавьте в location две строки — без них соединение обрывается при обновлении
протокола:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";Шаг 6. Проверка результата
curl -I https://example.com # ожидается ответ приложения без ошибки TLS
curl -sI http://example.com | grep -i location # ожидается: https://example.com/
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates -issuer # срок и издательПоследняя команда показывает сертификат, который сервер отдаёт фактически. Она отличает случай, когда файлы обновились, но nginx продолжает работать со старым сертификатом в памяти.
Шаг 7. Автоматическое продление
Сертификат действует 90 дней. Пакет certbot ставит системный таймер, который проверяет продление дважды в сутки и обновляет сертификат, когда остаётся менее 30 дней.
systemctl status certbot.timer --no-pager # ожидается: active
sudo certbot renew --dry-run # ожидается: simulated renewal ... success--dry-run обращается к тестовому серверу и не расходует лимиты выпуска.
Работающий nginx не подхватывает новый файл сертификата сам — добавьте перезагрузку после обновления:
sudo certbot renew --deploy-hook "systemctl reload nginx"--deploy-hook срабатывает только при фактическом обновлении, а не при каждой проверке. Чтобы он
применялся и автоматическим продлением, впишите его в файл домена
/etc/letsencrypt/renewal/example.com.conf в секцию [renewalparams]:
renew_hook = systemctl reload nginxОтдельно про способ --standalone. Он поднимает собственный веб-сервер на порту 80, который во
время продления занят nginx. Продление в этом случае падает при каждой попытке — до истечения
срока. Если сертификат выпускался так, переведите его на --webroot:
sudo certbot certonly --webroot -w /var/www/acme -d example.com --force-renewalWildcard-сертификат
*.example.com выдаётся только методом DNS-01: владение подтверждается TXT-записью, проверка по
HTTP для него не принимается.
sudo certbot certonly --manual --preferred-challenges dns \
-d "*.example.com" -d example.com \
-m admin@example.com --agree-tosCertbot попросит создать TXT-запись _acme-challenge.example.com. Дождитесь её распространения,
прежде чем подтверждать:
dig +short TXT _acme-challenge.example.com # ожидается значение, которое показал certbotРучной способ требует повторять операцию при каждом продлении. Если у DNS-провайдера есть API,
поставьте плагин python3-certbot-dns-* — тогда продление станет автоматическим.
Если nginx работает в контейнере
Каталог /etc/letsencrypt держат на хосте и монтируют внутрь только на чтение; выпуском и
продлением занимается certbot на хосте.
services:
nginx:
image: nginx:alpine
ports: ["80:80", "443:443"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./acme:/var/www/acme # каталог проверки владенияsudo certbot certonly --webroot -w ./acme -d example.com
sudo certbot renew --deploy-hook "docker compose exec nginx nginx -s reload"Такое разделение оставляет управление сертификатами на хосте и не требует пересобирать образ при продлении.
Типичные отказы
| Признак | Причина | Что делать |
|---|---|---|
curl к домену — отказ соединения или таймаут | закрыт порт 80 либо DNS ведёт не на этот сервер | сверить dig +short example.com с curl -4 ifconfig.me, открыть 80/tcp в ufw и у провайдера |
| вместо своей страницы отдаётся страница nginx по умолчанию | конфиг не подключён или его перехватывает сайт по умолчанию | проверить симлинк в sites-enabled, при необходимости отключить default: sudo rm /etc/nginx/sites-enabled/default |
404 на /.well-known/acme-challenge/... | location проверки стоит после редиректа на HTTPS | поднять location /.well-known/acme-challenge/ выше location / |
| выпуск падает, хотя основной домен резолвится | одно из имён в -d не имеет DNS-записи | убрать лишнее имя или добавить запись; проверить каждое имя отдельно |
nginx не стартует: cannot load certificate | блок listen 443 написан до выпуска сертификата | вернуть конфиг к HTTP-блоку шага 3, выпустить сертификат, затем добавить HTTPS-блок |
сайт отвечает 502 | приложение не слушает адрес из proxy_pass | sudo ss -ltnp | grep :8000, запустить приложение или исправить адрес |
| браузер показывает старый сертификат | nginx не перезагружен после продления | sudo systemctl reload nginx, добавить renew_hook |
too many certificates already issued | достигнут недельный лимит выпуска на домен | дождаться окончания недельного окна; отлаживать через --dry-run, а не боевыми выпусками |
| продление падает только автоматически | сертификат выпущен способом standalone | перевыпустить через --webroot |
Журналы: /var/log/letsencrypt/letsencrypt.log — выпуск и продление;
sudo journalctl -u nginx -e --no-pager и /var/log/nginx/error.log — nginx.
Снятие домена
sudo certbot delete --cert-name example.com
sudo rm /etc/nginx/sites-enabled/example.conf
sudo nginx -t && sudo systemctl reload nginxУдаление не отзывает сертификат. Если закрытый ключ мог попасть в чужие руки, отзовите его до удаления:
sudo certbot revoke --cert-path /etc/letsencrypt/live/example.com/fullchain.pemОткройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.