From f25ccdec38a77f48387416b4f2e0ce00a9079b0b Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Mon, 19 Oct 2026 14:56:57 +0300 Subject: [PATCH] deploy: production compose overlay, nginx vhost and deploy script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 9 +- deploy/deploy.sh | 144 +++++++++++++++++++++++++++++ deploy/nginx/kadence.timka.su.conf | 87 +++++++++++++++++ docker-compose.prod.yml | 54 +++++++++++ 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100755 deploy/deploy.sh create mode 100644 deploy/nginx/kadence.timka.su.conf create mode 100644 docker-compose.prod.yml diff --git a/Makefile b/Makefile index 51eb246..1ed4e35 100644 --- a/Makefile +++ b/Makefile @@ -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) 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 diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..7958b21 --- /dev/null +++ b/deploy/deploy.sh @@ -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" </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 diff --git a/deploy/nginx/kadence.timka.su.conf b/deploy/nginx/kadence.timka.su.conf new file mode 100644 index 0000000..b6be7be --- /dev/null +++ b/deploy/nginx/kadence.timka.su.conf @@ -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; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..eb1a626 --- /dev/null +++ b/docker-compose.prod.yml @@ -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