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>
88 lines
3.1 KiB
Plaintext
88 lines
3.1 KiB
Plaintext
# 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;
|
|
}
|
|
}
|