deploy: production compose overlay, nginx vhost and deploy script
Three pieces so kadence.timka.su can be stood up and updated from the repo
rather than from shell history on the box:
* `docker-compose.prod.yml` — publishes the app on loopback only. The base
file's `${APP_PORT}:8000` binds every interface, which on this host both
collides with remnawave (already on 8000) and would serve the API beside
the proxy without TLS. Compose *appends* merged port lists, so the override
needs the `!override` tag or the public binding survives. uvicorn also gets
`--proxy-headers`, without which every request looks like plain HTTP from
127.0.0.1 and the session cookie loses its `Secure` flag.
* `deploy/nginx/kadence.timka.su.conf` — TLS, HSTS, the forwarded headers the
cookie logic reads, and a 120s read timeout because one task refresh fans
out to four third-party APIs.
* `deploy/deploy.sh` — idempotent install-or-update. Generates API_TOKEN and
the database password once and never rotates them afterwards (that would
lock out the phone and the desktop app), treats `.env.example`'s
`change-me` placeholders as unset, and uses `certbot certonly --webroot`
rather than `--nginx` so the vhost in git stays the truth.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+293
-1
No files matched your search
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/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
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user