gitaspen docs

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: домен указывает на этот сервер

Узнайте адрес сервера и сверьте с записями домена. Проверять нужно каждое имя, которое войдёт в сертификат: если хотя бы одно не резолвится, выпуск не пройдёт целиком.

bash
curl -4 -s ifconfig.me; echo          # IP этого сервера
dig +short example.com
dig +short www.example.com            # только если www тоже нужен в сертификате

Ожидается: значения dig совпадают с IP сервера.

Записи DNS обновляются не мгновенно — время зависит от TTL. Если значения расходятся, дождитесь обновления и повторите. Если www не нужен, просто не включайте его в команды ниже.


Шаг 2. Пакеты и открытые порты

bash
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginx

Порт 80 нужен для проверки владения доменом, 443 — для самого сайта:

bash
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-блок и каталог для проверки

Определите, как устроены конфиги в вашей установке:

bash
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, симлинк не нужен.

Создайте каталог для проверки владения и файл конфигурации:

bash
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 nginx

nginx -t обязателен перед каждой перезагрузкой: reload с ошибочным конфигом оставит работать старую конфигурацию, и расхождение между файлом и работающим сервером легко не заметить.

Проверка — запрос по домену доходит именно до этого блока, и каталог проверки отдаётся:

bash
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 (конфиг остаётся вашим)

bash
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 настраивает сам)

bash
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 не запустится.

Замените содержимое файла конфигурации:

bash
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, с открытым незашифрованным трафиком и в обход всего, что настроено на домене.

bash
sudo ss -ltnp | grep :8000

Ожидается адрес 127.0.0.1:8000. Значение 0.0.0.0:8000 или *:8000 означает, что порт открыт на всех интерфейсах.

Как привязать к loopback, зависит от того, как запущено приложение:

bash
uvicorn app:app --host 127.0.0.1 --port 8000     # Python/ASGI
node server.js                                    # в коде: app.listen(8000, '127.0.0.1')

В Docker публикация порта задаётся явным адресом:

yaml
services:
  app:
    ports:
      - "127.0.0.1:8000:8000"     # доступно только с хоста
      # - "8000:8000"             # так порт открыт всему интернету

Запись "8000:8000" публикует порт на всех интерфейсах. Docker добавляет свои правила в iptables раньше правил ufw, поэтому такой порт остаётся доступным снаружи, даже если ufw его запрещает. Проверить фактическую доступность снаружи можно с другой машины:

bash
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 две строки — без них соединение обрывается при обновлении протокола:

nginx
proxy_set_header Upgrade    $http_upgrade;
proxy_set_header Connection "upgrade";

Шаг 6. Проверка результата

bash
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 дней.

bash
systemctl status certbot.timer --no-pager     # ожидается: active
sudo certbot renew --dry-run                  # ожидается: simulated renewal ... success

--dry-run обращается к тестовому серверу и не расходует лимиты выпуска.

Работающий nginx не подхватывает новый файл сертификата сам — добавьте перезагрузку после обновления:

bash
sudo certbot renew --deploy-hook "systemctl reload nginx"

--deploy-hook срабатывает только при фактическом обновлении, а не при каждой проверке. Чтобы он применялся и автоматическим продлением, впишите его в файл домена /etc/letsencrypt/renewal/example.com.conf в секцию [renewalparams]:

ini
renew_hook = systemctl reload nginx

Отдельно про способ --standalone. Он поднимает собственный веб-сервер на порту 80, который во время продления занят nginx. Продление в этом случае падает при каждой попытке — до истечения срока. Если сертификат выпускался так, переведите его на --webroot:

bash
sudo certbot certonly --webroot -w /var/www/acme -d example.com --force-renewal

Wildcard-сертификат

*.example.com выдаётся только методом DNS-01: владение подтверждается TXT-записью, проверка по HTTP для него не принимается.

bash
sudo certbot certonly --manual --preferred-challenges dns \
  -d "*.example.com" -d example.com \
  -m admin@example.com --agree-tos

Certbot попросит создать TXT-запись _acme-challenge.example.com. Дождитесь её распространения, прежде чем подтверждать:

bash
dig +short TXT _acme-challenge.example.com    # ожидается значение, которое показал certbot

Ручной способ требует повторять операцию при каждом продлении. Если у DNS-провайдера есть API, поставьте плагин python3-certbot-dns-* — тогда продление станет автоматическим.


Если nginx работает в контейнере

Каталог /etc/letsencrypt держат на хосте и монтируют внутрь только на чтение; выпуском и продлением занимается certbot на хосте.

yaml
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            # каталог проверки владения
bash
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_passsudo 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.


Снятие домена

bash
sudo certbot delete --cert-name example.com
sudo rm /etc/nginx/sites-enabled/example.conf
sudo nginx -t && sudo systemctl reload nginx

Удаление не отзывает сертификат. Если закрытый ключ мог попасть в чужие руки, отзовите его до удаления:

bash
sudo certbot revoke --cert-path /etc/letsencrypt/live/example.com/fullchain.pem
Инструкция не помогла?

Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.