chore(deploy): Docker-образы, compose, deploy.sh, setup-nginx.sh

This commit is contained in:
2026-10-17 15:48:19 +03:00
parent e2d85a28ef
commit 79ab146b1d
8 changed files with 565 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
# syntax=docker/dockerfile:1
# Сборка из корня репо:
# docker buildx build --platform linux/amd64 -f deploy/Dockerfile.api \
# --build-arg APP_VERSION=$(git rev-parse --short HEAD) -t kvarta-api .
# builder на том же node:22-slim (glibc), что и рантайм: нативный better-sqlite3,
# скачанный/собранный здесь под целевую платформу, гарантированно совместим
FROM node:22-slim AS build
# toolchain нужен только если prebuilt better-sqlite3 не скачается; в рантайм не попадает
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# corepack из node 22 может не знать ключи свежего pnpm — обновляем его
RUN npm install -g corepack@latest >/dev/null && corepack enable
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY apps/api/package.json apps/api/
COPY packages/shared/package.json packages/shared/
RUN corepack install
RUN --mount=type=cache,id=kvarta-pnpm-slim,target=/pnpm-store \
pnpm install --frozen-lockfile --store-dir /pnpm-store \
--filter kvarta --filter "@kvarta/api..."
COPY apps/api apps/api
COPY packages/shared packages/shared
RUN pnpm --filter @kvarta/api build
# отдельный каталог только с production-зависимостями api (shared уже вшит tsup'ом)
RUN --mount=type=cache,id=kvarta-pnpm-slim,target=/pnpm-store \
pnpm --filter @kvarta/api deploy --prod --legacy --store-dir /pnpm-store /out \
&& rm -rf /out/src /out/tsconfig.json /out/tsup.config.ts /out/vitest.config.ts
# better-sqlite3 13 везёт prebuilt-бинарники всех платформ (~17 МБ) и исходники SQLite (~10 МБ):
# оставляем только glibc-бинарник своей архитектуры (linux-x64 для linux/amd64)
RUN set -eu; \
bs=$(readlink -f /out/node_modules/better-sqlite3); \
keep="linux-$(node -p process.arch).node"; \
test -f "$bs/prebuilds/$keep"; \
find "$bs/prebuilds" -type f ! -name "$keep" -delete; \
rm -rf "$bs/deps" "$bs/src" "$bs/build" "$bs/binding.gyp" \
/out/node_modules/.pnpm/node-addon-api@*; \
find /out/node_modules -name '*.md' -type f -delete; \
cd /out && node -e "const D=require('better-sqlite3'); const db=new D(':memory:'); console.log('better-sqlite3 ok, sqlite', db.prepare('select sqlite_version() v').get().v); db.close()"
# рантайму не нужны npm/corepack/yarn и документация (~25 МБ); удалить их слоем поверх базы
# нельзя — размер не уменьшится, поэтому чистим и сплющиваем базу в один слой через scratch
FROM node:22-slim AS node-base
RUN rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \
/usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-* \
/usr/local/include /usr/local/share/doc /usr/local/share/man \
/usr/share/doc /usr/share/man /usr/share/info /usr/share/locale \
/var/lib/apt/lists/* /var/cache/* /var/log/* /root/.npm /tmp/* \
&& groupadd -r -g 1001 kvarta \
&& useradd -r -u 1001 -g kvarta -d /app -s /usr/sbin/nologin kvarta \
&& mkdir -p /app /data && chown kvarta:kvarta /data
FROM scratch AS runtime
COPY --from=node-base / /
ARG APP_VERSION=dev
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
NODE_ENV=production \
PORT=3000 \
DB_PATH=/data/kvarta.sqlite \
APP_VERSION=${APP_VERSION}
WORKDIR /app
COPY --from=build --chown=kvarta:kvarta /out ./
USER kvarta
VOLUME ["/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
CMD ["node", "dist/index.js"]
+45
View File
@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1
# Сборка из корня репо:
# docker buildx build --platform linux/amd64 -f deploy/Dockerfile.landing -t kvarta-landing .
FROM node:22-alpine AS build
# corepack из node 22 может не знать ключи свежего pnpm — обновляем его
RUN npm install -g corepack@latest >/dev/null && corepack enable
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /repo
# сначала только манифесты — слой с зависимостями кешируется
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY apps/landing/package.json apps/landing/
COPY packages/shared/package.json packages/shared/
RUN corepack install
RUN --mount=type=cache,id=kvarta-pnpm-alpine,target=/pnpm-store \
pnpm install --frozen-lockfile --store-dir /pnpm-store \
--filter kvarta --filter "@kvarta/landing..."
COPY apps/landing apps/landing
COPY packages/shared packages/shared
RUN pnpm --filter @kvarta/landing build \
&& mkdir -p apps/landing/public
# рантайму не нужны npm/corepack/yarn (~23 МБ); удалить их слоем поверх базы нельзя —
# размер не уменьшится, поэтому чистим и сплющиваем базу в один слой через scratch
FROM node:22-alpine AS node-base
RUN rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \
/usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-* \
/usr/local/include /root/.npm /tmp/* \
&& addgroup -S -g 1001 kvarta && adduser -S -u 1001 -G kvarta kvarta
FROM scratch AS runtime
COPY --from=node-base / /
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
WORKDIR /app
# outputFileTracingRoot = корень репо, поэтому server.js лежит в apps/landing/ внутри standalone
COPY --from=build --chown=kvarta:kvarta /repo/apps/landing/.next/standalone ./
COPY --from=build --chown=kvarta:kvarta /repo/apps/landing/.next/static ./apps/landing/.next/static
COPY --from=build --chown=kvarta:kvarta /repo/apps/landing/public ./apps/landing/public
USER kvarta
EXPOSE 3000
CMD ["node", "apps/landing/server.js"]
+14
View File
@@ -0,0 +1,14 @@
# Секреты api. На сервере: /root/kvarta_site/api.env, chmod 600, заполняет автор.
# deploy.sh кладёт этот шаблон, только если api.env на сервере ещё нет, и дальше его не трогает.
# PORT, DB_PATH и APP_VERSION задаёт образ — здесь их не указывать.
# Токен бота от @BotFather
TELEGRAM_BOT_TOKEN=
# ID группы с темами, куда бот пишет (отрицательное число вида -100…)
TELEGRAM_CHAT_ID=
# ID темы «Заявки» (message_thread_id) — сюда приходят новые заявки
TELEGRAM_LEADS_THREAD_ID=
# ID темы «Система» — ошибки, бэкапы, служебные сообщения
TELEGRAM_SYSTEM_THREAD_ID=
# Telegram user ID автора: только ему бот отвечает на команды администрирования
TELEGRAM_ADMIN_ID=
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
# Деплой «Кварты» на VPS. Запускать локально из корня репозитория:
# deploy/deploy.sh — собрать HEAD и выкатить
# deploy/deploy.sh --rollback — вернуть предыдущий тег из /root/kvarta_site/.deployed
# Хост: DEPLOY_HOST (по умолчанию root@185.5.249.52).
#
# На сервере рядом чужие боевые сайты: скрипт трогает только /root/kvarta_site,
# контейнеры проекта kvarta и образы kvarta-landing/kvarta-api. Никаких prune.
set -euo pipefail
DEPLOY_HOST="${DEPLOY_HOST:-root@185.5.249.52}"
REMOTE_DIR=/root/kvarta_site
COMPOSE_FILE=docker-compose_vps.yml
PROJECT=kvarta
REPOS=(kvarta-landing kvarta-api)
MIN_FREE_MB=1024
HEALTH_TIMEOUT=30
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10
-o ControlMaster=auto -o "ControlPath=$HOME/.ssh/kvarta-deploy-%C" -o ControlPersist=60)
STEP=0
step() { STEP=$((STEP + 1)); printf '\n\033[1m[%d] %s\033[0m\n' "$STEP" "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[33m ВНИМАНИЕ: %s\033[0m\n' "$*" >&2; }
die() { printf '\033[31mОШИБКА: %s\033[0m\n' "$*" >&2; exit 1; }
# shellcheck disable=SC2029 # команды для сервера собираются локально намеренно
remote() { ssh "${SSH_OPTS[@]}" "$DEPLOY_HOST" "$@"; }
usage() {
sed -n '2,5p' "$0" | sed 's/^# \{0,1\}//'
}
cd "$(dirname "$0")/.."
# --- общие шаги на сервере ---------------------------------------------------
check_server() {
step "Проверка сервера $DEPLOY_HOST"
remote true || die "нет ssh-доступа к $DEPLOY_HOST (нужен ключ, BatchMode)"
remote 'command -v docker-compose >/dev/null' \
|| die "на сервере нет docker-compose (ожидается v2 в /usr/local/bin)"
info "$(remote 'docker-compose version --short 2>/dev/null || docker-compose version' | head -1)"
}
# compose up с указанным тегом, затем ждём health обоих сервисов
compose_up() {
local tag=$1
step "Запуск контейнеров с тегом $tag"
remote "cd $REMOTE_DIR && KVARTA_TAG=$tag docker-compose -f $COMPOSE_FILE -p $PROJECT up -d --remove-orphans"
step "Ожидание здоровья (до ${HEALTH_TIMEOUT} с)"
if remote bash -s -- "$HEALTH_TIMEOUT" <<'REMOTE'
set -u
for _ in $(seq 1 "$1"); do
if curl -fs -o /dev/null http://127.0.0.1:13121/api/health \
&& curl -fs -o /dev/null http://127.0.0.1:13120/; then
curl -fsS http://127.0.0.1:13121/api/health; echo
exit 0
fi
sleep 1
done
exit 1
REMOTE
then
info "api и landing отвечают"
else
warn "сервисы не ответили за ${HEALTH_TIMEOUT} с; последние логи:"
remote "cd $REMOTE_DIR && docker-compose -f $COMPOSE_FILE -p $PROJECT logs --tail 40" || true
die "деплой $tag не поднялся. Откат: deploy/deploy.sh --rollback"
fi
}
# latest на сервере — всегда то, что сейчас запущено (для ручного docker-compose up)
retag_latest() {
local tag=$1 repo
for repo in "${REPOS[@]}"; do
remote "docker tag $repo:$tag $repo:latest"
done
}
# .deployed: current=<tag> / previous=<tag>
read_deployed() {
remote "cat $REMOTE_DIR/.deployed 2>/dev/null || true"
}
write_deployed() {
local current=$1 previous=$2
remote "printf 'current=%s\nprevious=%s\n' '$current' '$previous' > $REMOTE_DIR/.deployed"
info "current=$current previous=${previous:-—}"
}
# --- откат -------------------------------------------------------------------
rollback() {
check_server
step "Чтение $REMOTE_DIR/.deployed"
local state current previous repo
state=$(read_deployed)
current=$(sed -n 's/^current=//p' <<<"$state")
previous=$(sed -n 's/^previous=//p' <<<"$state")
[[ -n $previous ]] || die "в .deployed нет предыдущего тега — откатываться некуда"
info "сейчас $current, откатываемся на $previous"
for repo in "${REPOS[@]}"; do
remote "docker image inspect $repo:$previous >/dev/null 2>&1" \
|| die "на сервере нет образа $repo:$previous"
done
compose_up "$previous"
retag_latest "$previous"
step "Запись .deployed"
# меняем местами: повторный --rollback вернёт обратно
write_deployed "$previous" "$current"
printf '\nГотово: откат на %s.\n' "$previous"
}
# --- деплой ------------------------------------------------------------------
deploy() {
step "Проверка рабочего дерева"
if [[ -n $(git status --porcelain) ]]; then
git status --short
die "есть незакоммиченные или неотслеживаемые файлы — закоммитьте или уберите их"
fi
local sha
sha=$(git rev-parse --short HEAD)
info "коммит $sha: $(git log -1 --format=%s)"
check_server
step "Свободное место на сервере"
local free_mb
# меряем раздел, где docker хранит образы (туда ляжет docker load)
free_mb=$(remote bash -s <<'REMOTE'
dir=$(docker info -f '{{.DockerRootDir}}' 2>/dev/null)
[ -d "$dir" ] || dir=/
echo "$dir $(df -Pm "$dir" | awk 'NR==2 {print $4}')"
REMOTE
)
local docker_root=${free_mb% *}
free_mb=${free_mb##* }
[[ $free_mb =~ ^[0-9]+$ ]] || die "не удалось узнать свободное место на сервере (получено: '$free_mb')"
info "свободно ${free_mb} МБ на разделе с $docker_root"
if ((free_mb < MIN_FREE_MB)); then
die "на сервере меньше ${MIN_FREE_MB} МБ свободно (${free_mb} МБ). Освободите место (своё, не чужое) и повторите"
fi
local need_upload=0 repo
for repo in "${REPOS[@]}"; do
remote "docker image inspect $repo:$sha >/dev/null 2>&1" || need_upload=1
done
if ((need_upload)); then
step "Сборка образов linux/amd64 ($sha)"
local hint="если в логе 'exec format error' — на этой машине нет эмуляции amd64:"
hint+=" Docker Desktop → Settings → General → «Use Rosetta for x86_64/amd64 emulation»"
hint+=" или docker run --privileged --rm tonistiigi/binfmt --install amd64"
local name
for name in landing api; do
info "kvarta-$name"
docker buildx build --platform linux/amd64 --load \
--provenance=false --sbom=false \
-f "deploy/Dockerfile.$name" \
--build-arg "APP_VERSION=$sha" \
-t "kvarta-$name:$sha" -t "kvarta-$name:latest" \
. || die "сборка kvarta-$name упала; $hint"
done
docker image ls --format ' {{.Repository}}:{{.Tag}} {{.Size}}' | grep -E "^ kvarta-(landing|api):$sha " || true
step "Передача образов на сервер (docker save | gzip | ssh docker load)"
docker save "kvarta-landing:$sha" "kvarta-api:$sha" | gzip | remote 'gunzip | docker load'
else
step "Образы $sha уже есть на сервере — сборка и передача пропущены"
fi
step "Файлы в $REMOTE_DIR"
remote "mkdir -p $REMOTE_DIR"
scp -q "${SSH_OPTS[@]}" "deploy/$COMPOSE_FILE" "$DEPLOY_HOST:$REMOTE_DIR/$COMPOSE_FILE"
info "$COMPOSE_FILE обновлён"
local env_state
env_state=$(remote "cd $REMOTE_DIR && if [ -e api.env ]; then echo exists; else umask 077 && cat > api.env && chmod 600 api.env && echo created; fi" <deploy/api.env.example)
if [[ $env_state == created ]]; then
warn "api.env не было — положен пустой шаблон $REMOTE_DIR/api.env (chmod 600)."
warn "Заполните его на сервере и перезапустите api: cd $REMOTE_DIR && docker-compose -f $COMPOSE_FILE -p $PROJECT up -d api"
else
info "api.env уже есть — не трогаем"
fi
local state previous
state=$(read_deployed)
previous=$(sed -n 's/^current=//p' <<<"$state")
# повторный деплой того же коммита не должен затирать предыдущий тег
if [[ $previous == "$sha" ]]; then
previous=$(sed -n 's/^previous=//p' <<<"$state")
fi
compose_up "$sha"
retag_latest "$sha"
step "Запись .deployed"
write_deployed "$sha" "$previous"
step "Удаление старых образов kvarta-landing/kvarta-api (кроме $sha${previous:+ и $previous})"
# ssh склеивает аргументы в строку — пустой previous пропал бы, поэтому заглушка "-"
remote bash -s -- "$sha" "${previous:--}" "${REPOS[@]}" <<'REMOTE'
set -u
keep_current=$1 keep_previous=$2
shift 2
for repo in "$@"; do
docker image ls "$repo" --format '{{.Tag}}' | while read -r tag; do
case $tag in
latest|"$keep_current"|"$keep_previous"|"<none>") continue ;;
esac
if docker rmi "$repo:$tag" >/dev/null; then
echo " удалён $repo:$tag"
else
echo " не удалось удалить $repo:$tag (используется?) — пропускаю"
fi
done
done
REMOTE
printf '\nГотово: %s выкачен на %s.\n' "$sha" "$DEPLOY_HOST"
}
case "${1:-}" in
"") deploy ;;
--rollback) rollback ;;
-h | --help) usage ;;
*) usage; die "неизвестный аргумент: $1" ;;
esac
+37
View File
@@ -0,0 +1,37 @@
# Прод на VPS: /root/kvarta_site/docker-compose_vps.yml (кладёт deploy/deploy.sh).
# На сервере только docker-compose v2.23 (без плагина `docker compose`):
# KVARTA_TAG=<sha> docker-compose -f docker-compose_vps.yml -p kvarta up -d
# Наружу порты не торчат — только 127.0.0.1, снаружи ходит nginx хоста.
name: kvarta
x-logging: &logging
# диск сервера почти полный — логи ротируются: не больше 3 × 10 МБ на контейнер
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
landing:
image: kvarta-landing:${KVARTA_TAG:-latest}
container_name: kvarta-landing
restart: unless-stopped
ports:
- "127.0.0.1:13120:3000"
logging: *logging
api:
image: kvarta-api:${KVARTA_TAG:-latest}
container_name: kvarta-api
restart: unless-stopped
ports:
- "127.0.0.1:13121:3000"
env_file: api.env
volumes:
- kvarta-data:/data
logging: *logging
volumes:
kvarta-data:
# явное имя: не зависит от project name, его ищут бэкапы и восстановление
name: kvarta-data
+42
View File
@@ -0,0 +1,42 @@
# /etc/nginx/sites-available/kvarta.unuchak.com (ставит deploy/setup-nginx.sh)
# Только 80-й порт: certbot --nginx сам допишет 443-блок и редирект.
server {
listen 80;
listen [::]:80;
server_name kvarta.unuchak.com;
# заявка — это небольшой JSON; всё крупнее отсекаем ещё на nginx
client_max_body_size 32k;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/javascript application/json
application/xml application/rss+xml image/svg+xml application/manifest+json;
location /api/ {
proxy_pass http://127.0.0.1:13121;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location / {
proxy_pass http://127.0.0.1:13120;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Однократная настройка nginx и HTTPS для kvarta.unuchak.com. Запускать локально из корня:
# CERTBOT_EMAIL=you@example.com deploy/setup-nginx.sh
# Хост: DEPLOY_HOST (по умолчанию root@185.5.249.52). Повторный запуск безопасен.
#
# На сервере чужие боевые сайты: при ошибке `nginx -t` наш симлинк убирается
# и reload не делается — общий nginx не должен упасть из-за нашего конфига.
set -euo pipefail
DEPLOY_HOST="${DEPLOY_HOST:-root@185.5.249.52}"
DOMAIN=kvarta.unuchak.com
CONF_SRC="deploy/nginx/$DOMAIN.conf"
AVAILABLE="/etc/nginx/sites-available/$DOMAIN"
ENABLED="/etc/nginx/sites-enabled/$DOMAIN"
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10
-o ControlMaster=auto -o "ControlPath=$HOME/.ssh/kvarta-nginx-%C" -o ControlPersist=60)
STEP=0
step() { STEP=$((STEP + 1)); printf '\n\033[1m[%d] %s\033[0m\n' "$STEP" "$*"; }
info() { printf ' %s\n' "$*"; }
die() { printf '\033[31mОШИБКА: %s\033[0m\n' "$*" >&2; exit 1; }
# shellcheck disable=SC2029 # команды для сервера собираются локально намеренно
remote() { ssh "${SSH_OPTS[@]}" "$DEPLOY_HOST" "$@"; }
cd "$(dirname "$0")/.."
step "Проверка параметров"
if [[ -z ${CERTBOT_EMAIL:-} ]]; then
die "задайте CERTBOT_EMAIL — на него Let's Encrypt шлёт предупреждения об истечении сертификата:
CERTBOT_EMAIL=you@example.com deploy/setup-nginx.sh"
fi
[[ $CERTBOT_EMAIL =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] \
|| die "CERTBOT_EMAIL не похож на email: $CERTBOT_EMAIL"
[[ -f $CONF_SRC ]] || die "нет $CONF_SRC"
info "хост $DEPLOY_HOST, домен $DOMAIN, email $CERTBOT_EMAIL"
step "Проверка сервера"
remote true || die "нет ssh-доступа к $DEPLOY_HOST"
remote 'command -v nginx >/dev/null && command -v certbot >/dev/null' \
|| die "на сервере нет nginx или certbot"
# общий nginx должен быть исправен до нас — иначе мы не отличим свою ошибку от чужой
remote 'nginx -t 2>&1' >/dev/null || die "nginx -t падает ещё до наших изменений — разберитесь вручную, скрипт ничего не трогал"
step "Конфиг в $AVAILABLE"
# если certbot уже дописал в конфиг 443-блок — не затираем его исходным 80-м
if remote "grep -q 'managed by Certbot' $AVAILABLE 2>/dev/null"; then
info "конфиг уже обработан certbot — оставляю как есть"
else
# прежняя версия нашего конфига — чтобы вернуть её, если новая не пройдёт nginx -t
remote "[ ! -e $AVAILABLE ] || cp -a $AVAILABLE $AVAILABLE.bak"
scp -q "${SSH_OPTS[@]}" "$CONF_SRC" "$DEPLOY_HOST:$AVAILABLE"
info "скопирован"
fi
step "Симлинк в sites-enabled"
created_link=0
if remote "[ -e $ENABLED ] || [ -L $ENABLED ]"; then
info "уже есть — не трогаю"
else
remote "ln -s $AVAILABLE $ENABLED"
created_link=1
info "создан $ENABLED"
fi
step "nginx -t"
if ! remote 'nginx -t'; then
if ((created_link)); then
remote "rm -f $ENABLED"
info "наш симлинк удалён"
fi
if remote "[ -e $AVAILABLE.bak ]"; then
remote "mv -f $AVAILABLE.bak $AVAILABLE"
info "восстановлена прежняя версия $AVAILABLE"
fi
die "конфиг не прошёл nginx -t — reload не выполнен"
fi
remote "rm -f $AVAILABLE.bak"
step "systemctl reload nginx"
remote 'systemctl reload nginx'
step "Сертификат Let's Encrypt (certbot --nginx)"
remote "certbot --nginx -d $DOMAIN --non-interactive --agree-tos --redirect -m '$CERTBOT_EMAIL'"
step "Итоговая проверка"
remote 'nginx -t' && remote "curl -fsS -o /dev/null -w 'https://$DOMAIN/ → %{http_code}\n' https://$DOMAIN/ || true"
printf '\nГотово: https://%s\n' "$DOMAIN"