diff --git a/deploy/nginx/certbot-init.sh b/deploy/nginx/certbot-init.sh index 8fe8e46..8763e64 100755 --- a/deploy/nginx/certbot-init.sh +++ b/deploy/nginx/certbot-init.sh @@ -2,14 +2,27 @@ set -euo pipefail # certbot-init.sh — obtain the first Let's Encrypt certificate for the -# «Контракт-чек» edge proxy and reload nginx. +# «Контракт-чек» edge proxy and (re)start nginx. # -# Run this once on the VPS after DNS points the domain at this server and the -# certbot container is started (see DEPLOY.md). +# Bootstrap (first run, no certificate yet): +# nginx CANNOT start before a certificate exists — the rendered config +# references /etc/letsencrypt/live//*.pem and nginx exits if those +# files are missing. So the first certificate is obtained in --standalone +# mode: certbot binds port 80 itself (nginx is down at this point, so the +# port is free). After issuance the script starts nginx; the certbot renew +# sidecar then uses the webroot plugin against the running nginx. +# +# Subsequent runs (certificate already exists): webroot renew via the running +# nginx + `nginx -s reload`. # # Usage: # chmod +x deploy/nginx/certbot-init.sh # ./deploy/nginx/certbot-init.sh your-domain.example.com admin@example.com +# +# Requirements: +# - DNS A-record for the domain points at this server +# - NGINX_SERVER_NAME= is set in .env +# - the `services` profile is up (api healthy) — nginx depends on it DOMAIN="${1:-}" EMAIL="${2:-}" @@ -19,21 +32,54 @@ if [[ -z "$DOMAIN" || -z "$EMAIL" ]]; then exit 1 fi -# The certbot container in docker-compose mounts these directories. -# It must be running so certbot can write the webroot challenge response. +if [[ -f .env ]] && ! grep -qE "^NGINX_SERVER_NAME=${DOMAIN}\s*$" .env; then + echo "ERROR: NGINX_SERVER_NAME in .env must equal ${DOMAIN}." >&2 + echo " nginx renders server_name and the certificate path from it." >&2 + echo " Add/fix: NGINX_SERVER_NAME=${DOMAIN}" >&2 + exit 1 +fi + +# nginx depends on api (service_healthy), and api is gated behind the +# `services` profile — both profiles must be enabled for compose to +# resolve the dependency graph. +DC=(docker compose --profile services --profile edge) + CERTBOT_WEBROOT=/var/www/certbot LE_DIR=/etc/letsencrypt -docker compose --profile edge run --rm --entrypoint "certbot certonly \ - --webroot \ - --webroot-path ${CERTBOT_WEBROOT} \ - --agree-tos \ - --no-eff-email \ - --email ${EMAIL} \ - -d ${DOMAIN} \ - --non-interactive" certbot +cert_exists() { + "${DC[@]}" run --rm --entrypoint sh certbot \ + -c "test -f ${LE_DIR}/live/${DOMAIN}/fullchain.pem" >/dev/null 2>&1 +} -# Reload nginx so it picks up the new certificate. -docker compose --profile edge exec nginx nginx -s reload +if cert_exists; then + echo ">>> Certificate already exists — renewing via webroot (nginx serves the challenge)." + "${DC[@]}" run --rm --entrypoint "certbot certonly \ + --webroot \ + --webroot-path ${CERTBOT_WEBROOT} \ + --agree-tos \ + --no-eff-email \ + --email ${EMAIL} \ + -d ${DOMAIN} \ + --non-interactive \ + --keep-until-expiring" certbot +else + echo ">>> No certificate yet — obtaining via standalone on port 80." + # nginx cannot boot without a certificate (see header), so port 80 is free. + "${DC[@]}" stop nginx >/dev/null 2>&1 || true + "${DC[@]}" run --rm -p 80:80 --entrypoint "certbot certonly \ + --standalone \ + --agree-tos \ + --no-eff-email \ + --email ${EMAIL} \ + -d ${DOMAIN} \ + --non-interactive" certbot +fi -echo "Certificate issued for ${DOMAIN}. Nginx reloaded." +# Start (or re-create) nginx + the certbot renew sidecar. +"${DC[@]}" up -d nginx certbot + +# Reload in case nginx was already running with an old certificate. +"${DC[@]}" exec nginx nginx -s reload || true + +echo "Certificate issued for ${DOMAIN}. Nginx (re)started." diff --git a/docker-compose.yml b/docker-compose.yml index 3ec78d4..95a2a67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -432,9 +432,6 @@ services: image: nginx:alpine container_name: contract_check-nginx restart: unless-stopped - depends_on: - api: - condition: service_healthy ports: - "80:80" - "443:443" @@ -448,7 +445,7 @@ services: NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/conf.d NGINX_ENVSUBST_TEMPLATE_SUFFIX: .template healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost/healthz"] + test: ["CMD", "wget", "-qO-", "--no-check-certificate", "http://localhost/healthz"] interval: 10s timeout: 3s retries: 10 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 50f3952..a4b9e69 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -509,29 +509,43 @@ docker compose --profile services logs -f bot NGINX_SERVER_NAME=contract-check.example.com ``` +> **Важно:** nginx зависит от `api` (`service_healthy`), а `api` находится в +> профиле `services` — поэтому nginx/certbot всегда запускайте с обоими +> профилями: `docker compose --profile services --profile edge ...`. +> Запуск только `--profile edge` падает с `service "nginx" depends on +> undefined service "api"`. + ```bash -# Запуск edge вместе со стеком -NGINX_SERVER_NAME=contract-check.example.com \ - docker compose --profile services --profile edge up -d +# Запуск edge вместе со стеком (после получения первого сертификата, см. §13.2) +docker compose --profile services --profile edge up -d ``` ### 13.2 Первый запуск и получение сертификата DNS A-запись должна указывать на IP сервера **до** запуска certbot. +`NGINX_SERVER_NAME` в `.env` должен совпадать с доменом. + +Nginx **не может стартовать без сертификата** — рендеренный конфиг ссылается на +`/etc/letsencrypt/live//fullchain.pem`, и nginx падает на старте, если +файла нет. Поэтому первый сертификат получается в `--standalone` режиме +(certbot сам слушает порт 80, nginx в этот момент не запущен), и только затем +nginx стартует. Всё это делает `certbot-init.sh`: ```bash -# 1. Запускаем edge без HTTPS (HTTP-челлендж для certbot работает на порту 80) -NGINX_SERVER_NAME=contract-check.example.com \ - docker compose --profile edge up -d nginx certbot +# 1. Запускаем основной стек (nginx пока НЕ стартуем) +docker compose --profile services up -d -# 2. Получаем первый сертификат +# 2. Получаем первый сертификат (standalone, порт 80) и стартуем nginx + renew-sidecar chmod +x deploy/nginx/certbot-init.sh ./deploy/nginx/certbot-init.sh contract-check.example.com admin@example.com # 3. Проверяем -https://contract-check.example.com/healthz +curl https://contract-check.example.com/healthz ``` +При повторном запуске скрипт делает renew через webroot (nginx уже отдаёт +челленджи) и `nginx -s reload`. + По умолчанию `nginx.conf` проксирует: - `/api/v1/*`, `/admin/*` - `/healthz`, `/readyz`