Files
kadence/deploy/deploy.sh
T
timka123andClaude Opus 5 9270bd70cb deploy: reload nginx after a certificate renewal
`certbot certonly` replaces the files on disk but signals nothing, so nginx
would have gone on serving the old certificate until some unrelated reload
happened — which is to say the cert would have quietly expired in January.
`--nginx` installs this hook itself; keeping the vhost in git means we
install it ourselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 15:04:08 +03:00

158 lines
5.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Deploy Kadence on the server. Idempotent: run it for the first install and for
# every update after.
#
# ssh sw_pl_1 '/opt/kadence/deploy/deploy.sh'
#
# What it does, skipping whatever is already true:
# 1. fetches the pinned branch from Gitea over SSH
# 2. makes sure .env exists and has the secrets that must not be defaults
# 3. installs the nginx vhost and gets a Let's Encrypt certificate
# 4. rebuilds and restarts the containers, then waits for /api/health
#
# It never writes .env values that are already set, so rotating a secret by hand
# survives the next deploy.
set -euo pipefail
REPO_DIR=${REPO_DIR:-/opt/kadence}
DOMAIN=${DOMAIN:-kadence.timka.su}
BRANCH=${BRANCH:-master}
BIND=${KADENCE_BIND:-127.0.0.1:8010}
COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.prod.yml)
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m !! %s\033[0m\n' "$*"; }
cd "$REPO_DIR"
# ---- 1. code ---------------------------------------------------------------
say "Fetching $BRANCH from $(git remote get-url origin)"
git fetch --prune origin
git checkout -q "$BRANCH"
git reset --hard "origin/$BRANCH"
echo "now at $(git log --oneline -1)"
# ---- 2. secrets ------------------------------------------------------------
setkv() {
local key=$1 value=$2
sed -i "/^${key}=/d" .env 2>/dev/null || true
printf '%s=%s\n' "$key" "$value" >> .env
}
# `.env.example` ships placeholders rather than blanks — `POSTGRES_PASSWORD=change-me`
# is the dangerous one — so "already set" has to mean "set to something real", not
# merely "present".
is_placeholder() {
case "$1" in
''|change-me|changeme|'*'|your-*|TODO) return 0 ;;
*) return 1 ;;
esac
}
# Generated once, then left alone: a deploy that silently rotated API_TOKEN would
# lock out the phone and the desktop app, so this only ever fills a blank.
ensure_secret() {
local key=$1 value=$2
local current
current=$(sed -n "s/^${key}=//p" .env 2>/dev/null | tail -1)
if ! is_placeholder "$current"; then
return
fi
setkv "$key" "$value"
echo " generated $key"
}
if [[ ! -f .env ]]; then
say "Creating .env from .env.example"
cp .env.example .env
fi
chmod 600 .env
say "Checking secrets"
ensure_secret API_TOKEN "$(openssl rand -hex 32)"
ensure_secret POSTGRES_PASSWORD "$(openssl rand -hex 24)"
# Deployment facts rather than secrets: these describe where this instance runs,
# so the script owns them outright and corrects them if they drift.
setkv KADENCE_BIND "$BIND"
setkv CORS_ORIGINS "https://${DOMAIN}"
setkv TIMEZONE "${TIMEZONE:-Europe/Moscow}"
if ! grep -qE '^AUTH_PASSWORD=.+' .env; then
warn "AUTH_PASSWORD is blank — the browser UI is open to anyone who finds the"
warn "domain. Set it in .env and re-run, or run:"
warn " cd $REPO_DIR && ${COMPOSE[*]} exec app python -m app.cli set-password"
fi
# ---- 3. nginx + TLS --------------------------------------------------------
VHOST_SRC="deploy/nginx/${DOMAIN}.conf"
VHOST_DST="/etc/nginx/sites-available/${DOMAIN}"
CERT_DIR="/etc/letsencrypt/live/${DOMAIN}"
mkdir -p /var/www/certbot
if [[ ! -d $CERT_DIR ]]; then
say "No certificate yet — serving the ACME challenge over plain HTTP first"
# The repo vhost references a cert that does not exist, so nginx would refuse
# to load it. Stand up an HTTP-only vhost just long enough to be validated.
cat > "$VHOST_DST" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${DOMAIN};
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 503; }
}
EOF
ln -sfn "$VHOST_DST" "/etc/nginx/sites-enabled/${DOMAIN}"
nginx -t && systemctl reload nginx
say "Requesting a certificate for ${DOMAIN}"
# `certonly --webroot`, not `--nginx`: the vhost is managed in git and
# certbot's nginx installer would rewrite it behind our back.
certbot certonly --webroot -w /var/www/certbot -d "$DOMAIN" \
--non-interactive --agree-tos --register-unsafely-without-email \
--keep-until-expiring
fi
# `certonly` renews the files but tells nothing to pick them up, so without a
# deploy hook nginx keeps serving the old certificate until something reloads it
# by chance — i.e. it silently expires. `--nginx` would have done this for us;
# this is the price of keeping the vhost in git.
say "Installing the renewal hook"
install -d /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK'
#!/bin/sh
# Installed by kadence's deploy/deploy.sh. Runs after any successful renewal.
nginx -t && systemctl reload nginx
HOOK
chmod 755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
say "Installing the vhost"
install -m 644 "$VHOST_SRC" "$VHOST_DST"
ln -sfn "$VHOST_DST" "/etc/nginx/sites-enabled/${DOMAIN}"
nginx -t
systemctl reload nginx
# ---- 4. containers ---------------------------------------------------------
say "Building and starting"
"${COMPOSE[@]}" up -d --build
say "Waiting for the API"
for attempt in $(seq 1 60); do
if curl -fsS "http://${BIND}/api/health" >/dev/null 2>&1; then
echo " healthy after ${attempt}s"
break
fi
if [[ $attempt == 60 ]]; then
warn "still not healthy; last 40 lines of the app log:"
"${COMPOSE[@]}" logs --tail 40 app
exit 1
fi
sleep 1
done
say "Done"
curl -fsS "https://${DOMAIN}/api/health" && echo
curl -fsS "https://${DOMAIN}/api/info" && echo