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:
timka123andClaude Opus 5 committed 2026-10-19 14:56:57 +03:00
1 parent 1e55144291
commit f25ccdec38
4 files changed
+293 -1

No files matched your search

+8 -1
View File
@@ -54,4 +54,11 @@ report: ## Build and send today's report right now
set-password: ## Set/rotate the web login password (asks for a confirm) set-password: ## Set/rotate the web login password (asks for a confirm)
docker compose exec app python -m app.cli set-password docker compose exec app python -m app.cli set-password
.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install android-test android-test-core android-build android-install report set-password deploy: ## Push master and redeploy kadence.timka.su on sw_pl_1
git push origin master
ssh sw_pl_1 '/opt/kadence/deploy/deploy.sh'
deploy-logs: ## Follow the production app log
ssh sw_pl_1 'cd /opt/kadence && docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f --tail 100 app'
.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install android-test android-test-core android-build android-install report set-password deploy deploy-logs
+144
View File
@@ -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
+87
View File
@@ -0,0 +1,87 @@
# kadence.timka.su — TLS terminates here; the app listens on loopback only.
#
# Installed to /etc/nginx/sites-available/kadence.timka.su by deploy/deploy.sh,
# which also obtains the certificate. Deliberately *not* managed by
# `certbot --nginx`: that rewrites the file in place, so the copy in git would
# stop being the truth. `certbot certonly --webroot` renews the cert and leaves
# this file alone.
upstream kadence {
# Must match KADENCE_BIND in .env (docker-compose.prod.yml publishes it).
# 8000 is taken on this host by remnawave, hence 8010.
server 127.0.0.1:8010;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name kadence.timka.su;
# Renewal has to work over plain HTTP, so this one location stays on :80.
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name kadence.timka.su;
ssl_certificate /etc/letsencrypt/live/kadence.timka.su/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kadence.timka.su/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:kadence_tls:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
# The session cookie is HttpOnly/SameSite=Lax and the app marks it Secure off
# X-Forwarded-Proto below; HSTS is what stops a first request going plain.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# The settings page posts secrets (Jira tokens, bot tokens) as JSON; nothing
# here is an upload, so a small cap is free protection.
client_max_body_size 2m;
access_log /var/log/nginx/kadence.access.log;
error_log /var/log/nginx/kadence.error.log;
# Vite fingerprints these, so they can be cached hard and forever.
location /assets/ {
proxy_pass http://kadence;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://kadence;
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;
# This is what makes the login cookie `Secure` — `routes_auth._is_https`
# reads it, because the app itself only ever sees plain HTTP on loopback.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
# A task refresh fans out to Jira, Todoist, Google and the laundry site in
# one request; 60s is the shortest timeout that does not cut it off.
proxy_connect_timeout 10s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
}
+54
View File
@@ -0,0 +1,54 @@
# Production overlay: TLS and the public port belong to the host's nginx, so the
# container publishes nothing to the world.
#
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# Two things differ from the base file and both matter once the box has a public
# address:
#
# * the app binds **127.0.0.1 only**. The base file's `${APP_PORT}:8000` listens on
# every interface, which would put the API on `95.85.227.62:8000` beside the
# reverse proxy and let anyone skip TLS (and, on this host, 8000 is already taken).
# * Postgres publishes nothing at all — it is reachable on the compose network and
# nowhere else.
#
# `KADENCE_BIND` exists so a second instance can move off the port without editing
# this file. nginx proxies to whatever it says.
services:
db:
# Nothing published: the base file already keeps Postgres on the compose
# network only, and this is the file that would be tempted to open it.
logging: &json-file
driver: json-file
options:
max-size: "10m"
max-file: "5"
app:
# `!override`, not a plain list: compose *appends* merged port lists, so without
# it the base file's `0.0.0.0:8000` would survive alongside this one and the API
# would still be on the public interface. Verify with
# `docker compose -f docker-compose.yml -f docker-compose.prod.yml config`.
ports: !override
- "${KADENCE_BIND:-127.0.0.1:8010}:8000"
environment:
# Same-origin in the browser, so CORS never fires for the web UI; naming the
# origin rather than `*` is still correct, because `allow_credentials=True`
# plus a `*` origin is a combination browsers refuse outright.
CORS_ORIGINS: ${CORS_ORIGINS:-https://kadence.timka.su}
# uvicorn must trust the proxy's X-Forwarded-* or every request looks like
# plain HTTP from 127.0.0.1 — which would drop `Secure` from the session
# cookie and log the wrong client IP for the login rate limiter.
FORWARDED_ALLOW_IPS: 127.0.0.1
command:
- uvicorn
- app.main:app
- --host
- 0.0.0.0
- --port
- "8000"
- --proxy-headers
- --forwarded-allow-ips
- 127.0.0.1
logging: *json-file