Reads KVITT_PUBLIC_HOST from infra/pi/.env without sourcing it, refuses a missing file or key, a CHANGE_ME value, anything that is not a host name, and icehack.asuscomm.com (the checks of infra/pi/install.sh), passes it to :app:assembleRelease as -Pkvitt.publicHost and prints the release server in the header and summary. README publish paragraph updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
infra: Kvitt infrastructure
This file covers the development stack on the owner's Mac (infra/dev/, milestone M2c-dev, D-001), the
Firebase setup (SPEC §10.7) and the Pi stack (infra/pi/, milestone M2c-pi, D-100): a self-contained
bundle the owner copies to the Kvitt Pi and runs there, behind the owner's Nginx Proxy Manager on the Home Assistant
Pi (D-120). No agent and no Mac script connects to the Pis; the owner runs every Pi command and every NPM setting
from the checklist in "Pi: what the owner does".
Layout
| Path | What |
|---|---|
dev/compose.yaml |
Dev stack: server (image kvitt-server:dev) and the on-demand backup job |
dev/.env.example |
Dev settings, no secret values; copy to dev/.env (git-ignored) to override |
dev/build.sh |
Builds kvitt-server:dev from server/Dockerfile and kvitt-backup:dev from backup/Dockerfile |
dev/up.sh |
Starts the server, waits for /health, prints the Mac and phone URLs; --down stops the stack |
dev/logs.sh |
Follows the logs (dev/logs.sh --tail 100 server) |
dev/backup.sh |
Runs the backup job once and checks the snapshot with PRAGMA integrity_check |
dev/copy-google-services.sh |
Copies secrets/google-services.json to android/app/google-services.json |
dev/lib.sh |
Helpers sourced by the scripts |
dev/secrets/ |
Git-ignored: only fcm-service-account.json, copied there by dev/up.sh and mounted read-only at /secrets |
backup/Dockerfile, backup/backup.sh, backup/healthcheck.sh |
Backup image and job of SPEC §10.9 (shared with the Pi stack); Drive upload and D-002 deletion |
pi/ |
The Pi bundle (copied to the Kvitt Pi): compose.yaml, .env.example, install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh, lib.sh; git-ignored pi/.env, pi/secrets/, pi/out/ |
build-images.sh |
Mac fallback for the image build: linux/arm64, push to the Gitea registry |
publish-apk.sh |
Mac: release APK, mapping and latest.json into pi/out/apk/ for the owner to copy to the Pi |
RESTORE.md |
Restore from a Drive backup, step by step (SPEC §10.9) |
../.gitea/workflows/build-images.yaml |
Gitea Actions image build (the normal path) |
secrets/ |
Git-ignored: fcm-service-account.json, google-services.json, rclone.conf, optional registry.env (and ha_ssh_key(.pub), unused since D-120). Never committed |
All Mac commands below run from the repository root of the main checkout (/Users/jhag/projects/kvitt).
Development stack
Choices. No Caddy and no TLS: debug builds accept http:// to private LAN addresses (AC-2.8), so the server
publishes its own port (default 8080) on all of the Mac's interfaces. The server keeps the hardening of SPEC §8.9
(uid 10001, read-only root, no capabilities, no-new-privileges), the 160 MiB limit, json-file logging
(10 MB x 3) and TZ=Europe/Stockholm. Data lives in the bind-mounted folder infra/dev/data/ (git-ignored) so it
is easy to inspect and reset. Only the FCM key reaches the container (D-117): up.sh copies
${KVITT_SECRETS_DIR}/fcm-service-account.json (default infra/secrets/) into infra/dev/secrets/ (git-ignored,
nothing else in it) and that folder is mounted read-only at /secrets, so the server never sees rclone.conf,
the registry credentials or google-services.json. If the key is missing the folder is
empty and the server runs with push disabled (§10.4). Run up.sh once before the bare docker compose commands
below (they mount the same folder); after rotating the key, run up.sh again.
On this Mac docker is Podman (docker compose runs podman-compose); the files work with Docker Desktop as well.
Build
infra/dev/build.sh # both images
infra/dev/build.sh server # only kvitt-server:dev; needs server/Dockerfile
infra/dev/build.sh backup # only kvitt-backup:dev
The server build is docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server
(override the context with KVITT_SERVER_BUILD_CONTEXT). The script refuses to run while a Gradle build holds
/tmp/kvitt-gradle.lock (CLAUDE.md).
Start, stop, logs
infra/dev/up.sh # start; prints health, the Mac URL and the phone URL
infra/dev/logs.sh # follow logs (Ctrl-C to stop following)
infra/dev/up.sh --down # stop and remove the containers (data stays in infra/dev/data/)
KVITT_PUBLIC_URL (used in invite links) is left empty in .env.example; up.sh sets it to
http://<Mac LAN address>:<port>. Pin it in infra/dev/.env if the Mac's address changes often.
Finding the Mac's LAN address
up.sh prints it. By hand: ipconfig getifaddr en0 (Wi-Fi on most Macs), or the interface of the default route:
route -n get default | grep interface, then ipconfig getifaddr <interface>. It must be a private address
(192.168.*, 10.*, 172.16.* to 172.31.*) for a debug build to accept http:// (AC-2.8).
The first time a phone connects, macOS may ask whether the container runtime (Podman's gvproxy, or Docker)
may accept incoming network connections: answer Allow.
Bootstrap and pairing a debug build
The bootstrap and pair-device subcommands (SPEC §10.4, AC-1.1, AC-1.6) arrive with the server milestones.
C="docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example" # or --env-file infra/dev/.env
$C exec server kvitt-server bootstrap # first admin invite: code, link, expiry
$C exec server kvitt-server pair-device --user <user_id> # extra device invite for an existing user
$C exec server kvitt-server invite-partner # partner invite by the admin, as E-12 (D-113)
$C run --rm server migrate # when the server is stopped (entrypoint is kvitt-server)
On the phone (home Wi-Fi, debug build se.hagstedt.kvitt.debug): open the pairing screen, enter the server URL
http://<Mac LAN address>:8080 and the printed code (AC-2.7). Check reachability first by opening
http://<Mac LAN address>:8080/health in Chrome on the phone: it shows "status":"ok".
Backup (local, no Drive upload)
infra/dev/backup.sh
Runs infra/backup/backup.sh once in the backup image (compose profile backup, so up never starts it) with
RCLONE_FOLDER_ID empty: .backup snapshot, PRAGMA integrity_check must print ok, then
infra/dev/backups/kvitt-<UTC stamp>.sqlite.gz, pictures-<stamp>.tar.gz and last-success; the Drive upload
is skipped with a log line and the files are kept (SPEC §10.9, D-001). The wrapper then checks the new snapshot
again. The dev env file has no rclone settings (RCLONE_REMOTE is no longer in dev/.env.example): only the Pi's
.env names the remote. To check by hand: gunzip -c infra/dev/backups/kvitt-<stamp>.sqlite.gz > /tmp/k.sqlite && sqlite3 /tmp/k.sqlite "PRAGMA integrity_check".
Resetting the dev data
infra/dev/up.sh --down
rm -rf infra/dev/data # the dev database, pictures and APKs; never the phones' data
infra/dev/up.sh # fresh database; run bootstrap again
Paired debug builds then hold a token the new database does not know: re-pair them (clear the debug app's data on the phone, or reinstall the debug build).
Worktrees
Agents work in .claude/worktrees/agent-<id>, which has no infra/secrets/ unless copied (CLAUDE.md). Either copy
it from the main checkout, or set KVITT_SECRETS_DIR=/Users/jhag/projects/kvitt/infra/secrets in infra/dev/.env.
infra/dev/copy-google-services.sh /Users/jhag/projects/kvitt copies the Firebase config from the main checkout
into the worktree's android/app/.
Firebase (SPEC §10.7)
Project kvitt-1337 (Firebase added to the existing Google Cloud project). Free tier only; nothing billable is
created by this setup.
| Resource | Name |
|---|---|
| APIs enabled | firebase.googleapis.com, fcm.googleapis.com, cloudresourcemanager.googleapis.com, iam.googleapis.com |
| Android app | "Kvitt", package se.hagstedt.kvitt |
| Android app | "Kvitt debug", package se.hagstedt.kvitt.debug |
| Service account | kvitt-fcm@kvitt-1337.iam.gserviceaccount.com ("Kvitt FCM sender"), role roles/firebasecloudmessaging.admin (the narrowest predefined role holding cloudmessaging.messages.create) |
| Key | infra/secrets/fcm-service-account.json (git-ignored, mode 600) |
| Config | infra/secrets/google-services.json, one file with both Android clients (git-ignored) |
Put the config where Gradle reads it: infra/dev/copy-google-services.sh.
The setup used only gcloud and the Firebase Management REST API. To repeat or inspect it:
T=$(gcloud auth print-access-token)
H=(-H "Authorization: Bearer $T" -H "x-goog-user-project: kvitt-1337")
API=https://firebase.googleapis.com/v1beta1/projects/kvitt-1337
curl -sS "${H[@]}" "$API/androidApps" # list the apps (ids, packages)
curl -sS "${H[@]}" "$API/androidApps/<appId>/config" # configFileContents is base64 google-services.json
gcloud iam service-accounts keys list --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337
Rotating the key: create a new one with
gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337,
restart the server, then delete the old key id with gcloud iam service-accounts keys delete <key id> ....
Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by pi/install.sh ("Pi: what the owner does", step 5).
Pi stack (SPEC §10.1 to §10.10, D-100)
How it fits together
| Where | What runs | Who runs it |
|---|---|---|
Gitea Actions (runner label host, a Raspberry Pi) |
.gitea/workflows/build-images.yaml: builds kvitt-server and kvitt-backup for linux/arm64, pushes them to gitea.lan/jacobh2/... tagged vMAJOR.MINOR.PATCH and latest, pushes the git tag |
Gitea, on a push to server/**, infra/backup/**, .gitea/workflows/**, or by hand (Actions, "Run workflow") |
| Owner's Mac | infra/build-images.sh [tag] (fallback image build), infra/publish-apk.sh (release APK into infra/pi/out/apk/) |
The owner (or an agent, for the APK build only) |
Kvitt Pi, /opt/kvitt/ |
compose.yaml, .env, secrets/, import/, apk/, and the scripts deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh (copied there by install.sh) |
The owner, in their own SSH session |
| Home Assistant Pi | Nginx Proxy Manager (NPM): a proxy host for <KVITT_PUBLIC_HOST> with its own Let's Encrypt certificate, TLS on 443, plain HTTP to <KVITT_PI_HOST>:8080 over the LAN (D-120) |
The owner, in NPM's web UI (step 6) |
| Router (ASUS) | External TCP 8443 to the Home Assistant Pi's 443 (NPM); external 80 already reaches NPM for Let's Encrypt | The owner (step 7) |
Stack (pi/compose.yaml, project name kvitt): server (publishes 8080:8080 on the Pi's LAN interfaces, the
only published port; KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443, KVITT_TRUSTED_PROXY=${HA_HOST}) and
backup; volumes data, backups; network kvitt; every service restart: unless-stopped, json-file logs
10 MB x 3, TZ=Europe/Stockholm. Images are ${KVITT_REGISTRY}/kvitt-server|kvitt-backup:${KVITT_TAG} with
KVITT_REGISTRY=gitea.lan/jacobh2 (D-104; the names the workflow pushes). Caddy and cert-sync were removed by
D-120: TLS terminates in NPM, which already runs on the Home Assistant Pi.
Client IPs (SPEC §8.5, §8.6). NPM's request to the server carries X-Forwarded-For: <what the client sent>, <client address>; the server takes the last entry only when the TCP peer is KVITT_TRUSTED_PROXY, so HA_HOST
must be NPM's LAN IPv4 address (the server accepts an IP literal or a resolvable name, not a CIDR; install.sh
requires the address because the container cannot resolve an mDNS name such as homeassistant.local). The
router's forward keeps the internet client's source address, and Docker's published port keeps NPM's (DNAT, no
SNAT, for traffic arriving on the LAN interface), so the per-IP limits of D-032, D-115 and D-119 see real
addresses. The server does not use X-Forwarded-Proto or Host: links are built from KVITT_PUBLIC_URL (E-12).
Anyone on the home LAN can also reach http://<KVITT_PI_HOST>:8080 directly; such requests count against their own
address, and only HA_HOST may set X-Forwarded-For.
Versions (resolved 2026-10-18)
| Component | Version | Source |
|---|---|---|
| Nginx Proxy Manager | The owner's existing install on the Home Assistant Pi; the block of step 6 was written against NPM v2.16.0 (2026-09-24) templates | github.com/NginxProxyManager/nginx-proxy-manager, backend/templates/proxy_host.conf |
| Alpine (backup) | alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 |
Docker Hub official image; 3.24 = latest |
| backup packages (Alpine v3.24) | sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e (M2c-dev), supercronic 0.2.49-r1 | apk policy in the pinned image |
| Docker Engine on the Pi | Docker's Debian apt repository, arm64 packages (docker-ce 5:29.8.2 current): docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin |
docs.docker.com/engine/install/raspberry-pi-os/: that page is for 32-bit (armhf) only; 64-bit Raspberry Pi OS uses the Debian instructions (docs.docker.com/engine/install/debian/) |
Why supercronic, not busybox crond (D-104). The backup sidecar runs as a non-root user (uid 10001). Busybox
crond started as a non-root user in alpine 3.24.2 never ran a job (tested 2026-10-18: an every-minute job produced
no output in two minutes). supercronic is a cron for containers that runs jobs as the
user it runs as and honours TZ; the schedule is unchanged (30 3 * * *).
Backup uid (D-105, SPEC §10.2 "M2c-pi reconciles the two")
backup runs as uid 10001, the server's uid, on the Pi as on the Mac: sqlite3 .backup on a WAL database
must open (and write) kvitt.sqlite-shm, which the server creates as 10001 with mode 0644, so uid 1000 could not
back up the live database without loosening the server's files. Therefore /opt/kvitt/secrets/rclone.conf is
mode 0600 owned by 10001:10001 (not 1000). rclone writes
refreshed access tokens back to its config; the mounted file is read-only, so backup.sh works on a private copy
in /tmp each run. install.sh gives the named volumes their owners once (/data, /data/apk, /backups to
10001) with a one-off root container (docker compose run --user 0:0 --cap-add CHOWN).
What was checked on the Mac
2026-10-19 (D-120, Nginx Proxy Manager instead of Caddy and cert-sync):
shellcheck -S styleon every script underinfra/andtools/: clean.podman-compose -f infra/pi/compose.yaml --env-file <filled sample> configand the same forinfra/dev/: exit 0.nginx -t(nginx:alpine 1.31.7) of the step 6 block inside a minimal server block, and inside a copy of NPM v2.16.0'snginx.confwith the proxy host rendered as NPM's template renders it for the step 6 settings (Block Common Exploits, Force SSL, HTTP/2, HSTS, the block; NPM's regex^(?:.*;)?\s*?location\s*?\/\s*?{finds the block'slocation /, so NPM leaves its own out): both successful.- Rehearsal with Podman: the server image built from
server/Dockerfileat this commit (arm64, hardened as on the Pi) behind that nginx with a self-signed certificate,KVITT_TRUSTED_PROXY= the nginx container's address, clients on a non-private test subnet (so NPM'sset_real_ip_fromdoes not apply, as for internet clients). 21 checks passed:GET /health200 over HTTP/2 withContent-Security-Policy,X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrerandStrict-Transport-Securityonce each and noX-Served-By; the landing page/i/<code>?from=<name>200 with oneReferrer-Policy;http://answered 301 tohttps://; a 7 MB body refused by nginx (413), a 2 MB body answered by the server (payload_too_large); one client limited after 60 requests a minute while a second client was still served, a forgedX-Forwarded-Fordid not escape the limit, and withKVITT_TRUSTED_PROXYset to another address both clients shared one bucket (so the limits key on the forwarded address); after/i/,//i/and/%69/requests, and a 502 on/i/with the server stopped, the invite code, the name and/i/occurred 0 times in nginx's access and error logs, while other paths were logged with their query strings and a 502 on/healthwas in the error log. - Not checked (needs the real hosts): NPM's own UI and Let's Encrypt for
<KVITT_PUBLIC_HOST>, the router forward, the Gitea workflow, pulls fromgitea.lan, Drive.
2026-10-18 (M2c-pi, D-104 to D-106, D-117): backup rehearsal with a local rclone remote: upload, lsf
confirmation by name and size, local deletion only after both files were confirmed (D-002); files kept and
last-success unchanged when the upload failed; the leftovers uploaded by the next good run; healthcheck green on a
fresh start and after a run, red at 26 h. (The Caddy and cert-sync rehearsals of that day concern the removed stack.)
Gitea Actions workflow (.gitea/workflows/build-images.yaml)
Same setup as the owner's working example: runs-on: host, sh steps, apk add git docker-cli docker-cli-buildx,
docker login gitea.lan with the repository secret kvitt_cicd (created by the owner) and ${{ github.actor }},
a buildx builder whose buildkitd.toml sets http = true for gitea.lan (created once and reused, so its cache
survives), actions/checkout@v4 with set-safe-directory and fetch-depth: 0, the version from the newest
v1.0.* git tag plus one, and the git tag pushed after the build. That version is what deploy.sh takes.
The token is interpolated into the login step's run line (printf '%s' "${{ secrets.kvitt_cicd }}" | docker login ...), the owner's proven form: the first run on the runner (2026-10-19) got an empty token from the env-based form
(env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}) on act_runner 0.6.1 and failed with "password is empty";
act_runner masks the secret in the log. That first run got as far as the login (container, apk add, checkout
all worked). kvitt_cicd needs only the write:package scope: the tag push uses the credentials actions/checkout stored (the job's own token).
If the "Tag and Push Version" step fails with a permission error, the job token cannot push tags on this Gitea;
then add write:repository to the token (owner decision below). The step "Print image digests" prints each
image's sha256: digest, which must match what deploy.sh prints on the Pi (D-117).
The builder's buildkitd.toml is read only when the builder is created. After changing the registry setting in the
workflow, remove the builder once on the runner Pi: docker buildx rm local-http-builder (this also drops its
build cache, so the next server build is a full one).
Risk: the runner is a Raspberry Pi 3 with 1 GB RAM. The server image compiles about 232 Rust crates,
aws-lc-sys's C code among them. The workflow passes --build-arg CARGO_BUILD_JOBS=1; it takes effect only once
server/Dockerfile declares ARG CARGO_BUILD_JOBS in its build stage (requested from the server's owner; until
then buildx warns about an unused build argument and cargo uses all four cores, which may run out of memory). The
Dockerfile already keeps the cargo registry and target directory in BuildKit cache mounts, so only the first build
is a full one. The first build's duration on the Pi is unknown (a cold arm64 build takes 48 s on the M4 Mac). If
it fails for memory, add swap on the runner Pi, or build on the Mac with infra/build-images.sh.
Mac fallback: infra/build-images.sh [tag]
Builds the same two images for linux/arm64 and pushes them when infra/secrets/registry.env exists
(KVITT_REGISTRY=gitea.lan/jacobh2, optional KVITT_REGISTRY_USER and KVITT_REGISTRY_TOKEN with
write:package); otherwise it builds locally and prints the exact push commands. Tag: the short git SHA by default,
plus latest. Refuses a dirty tree and does not build while a Gradle build holds the gradle-serial lock. With
Podman (this Mac) it pushes with --tls-verify=false; with Docker it creates a buildx builder that allows plain
HTTP to the registry.
Publishing an APK: infra/publish-apk.sh
Version rule (SPEC §10.6): version_code = git rev-list --count HEAD, version_name = YYYY.MM.DD-<short sha>
of HEAD's commit date (Europe/Stockholm), passed as -Pkvitt.versionCode and -Pkvitt.versionName; the script
reads KVITT_PUBLIC_HOST from infra/pi/.env (refusing a missing file or key or a CHANGE_ME value) and passes it as
-Pkvitt.publicHost, the app's release server and invite-link host (D-121). The image
tags of the Gitea workflow (v1.0.N) are separate and say nothing about the APK. The script refuses a dirty tree,
builds :app:assembleRelease through tools/gradle-serial.sh (JDK 25 on PATH), refuses an unsigned APK, and
writes into infra/pi/out/apk/: kvitt-<version_code>.apk, kvitt-<version_code>.mapping.txt (the R8 mapping,
for reading stack traces; never served) and latest.json (temp file, then rename); it keeps the newest 5 APKs
with their mappings. Then the owner, on the Mac:
cd /Users/jhag/projects/kvitt
scp infra/pi/out/apk/* <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:/opt/kvitt/apk/
ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST> /opt/kvitt/install-apk.sh
install-apk.sh checks the SHA-256 against latest.json, copies the APKs into the data volume (/data/apk/)
and latest.json last with a rename, keeps the newest 5 there and prints GET /apk/latest.
Pi: what the owner does
Fill these in first (in infra/pi/.env on the Mac; the bundle carries the file to the Pi):
Placeholder in infra/pi/.env |
What | Where to find it |
|---|---|---|
KVITT_PUBLIC_HOST |
Kvitt's own public host name (D-120), for example a DuckDNS name or a name in your own domain, pointing at the home's public address like icehack.asuscomm.com does. Not icehack.asuscomm.com itself: that name is NPM's proxy host for Home Assistant, and NPM chooses the proxy host by name, not by port |
Your choice; tell the orchestrator the name (it goes into the app and docs/HANDOFF.md) |
KVITT_PI_HOST |
LAN IPv4 address of the Kvitt Pi (give it a static DHCP lease); also NPM's Forward Hostname / IP | ASUS router web UI, LAN, DHCP Server, client list |
KVITT_PI_SSH_USER |
Your user on the Kvitt Pi | The user set in Raspberry Pi Imager |
HA_HOST |
LAN IPv4 address of the Home Assistant Pi, where NPM runs (static DHCP lease); the server trusts X-Forwarded-For only from it. An address such as 192.168.1.20, not homeassistant.local |
Router client list, or Home Assistant, Settings, System, Network |
KVITT_TAG |
Image tag to run: the newest v1.0.N the Gitea workflow pushed (or the SHA from build-images.sh) |
Gitea, the repository, Packages, kvitt-server |
KVITT_REGISTRY_USER, KVITT_REGISTRY_TOKEN |
Only if the Gitea packages are private: a Gitea user and a token with read:package |
Gitea, Settings, Applications, Generate New Token |
KVITT_REGISTRY |
Already gitea.lan/jacobh2 |
|
RCLONE_REMOTE, RCLONE_FOLDER_ID |
Already gdrive: and 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV |
1. Provision the Kvitt Pi
Raspberry Pi Imager on the Mac: Raspberry Pi 3, Raspberry Pi OS Lite (64-bit); in its settings set the host
name, your user and password, the network, time zone Europe/Stockholm, and enable SSH with your public key. Boot
it, give it a static DHCP lease in the router, then from the Mac: ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>.
2. Install Docker Engine on the Pi (docs.docker.com, Debian arm64 repository)
On the Pi:
sudo apt update && sudo apt install -y ca-certificates curl python3
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
exit
Log in again; docker compose version prints a version and docker run --rm hello-world prints
"Hello from Docker!".
3. Fill the .env and stage the secrets, on the Mac
cd /Users/jhag/projects/kvitt
cp infra/pi/.env.example infra/pi/.env
open -e infra/pi/.env # replace every CHANGE_ME (table above), save
mkdir -p infra/pi/secrets
cp infra/secrets/fcm-service-account.json infra/secrets/rclone.conf infra/pi/secrets/
Only these two secret files go to the Pi. infra/secrets/ha_ssh_key and ha_ssh_key.pub are not used since
D-120: do not copy them (and no SSH add-on or authorized_keys entry on the Home Assistant Pi is needed).
rclone.conf is already generated from your rclone authorize "drive" (remote [gdrive], type = drive,
scope = drive, the token); the Drive folder id is RCLONE_FOLDER_ID in .env, not in the conf.
infra/pi/.env and infra/pi/secrets/ are git-ignored.
4. Copy the bundle to the Pi
On the Mac:
scp -r infra/pi <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:kvitt-pi
rm -r infra/pi/secrets # the originals stay in infra/secrets/
5. Install and start
On the Pi:
cd ~/kvitt-pi && ./install.sh
It checks the .env (KVITT_PUBLIC_HOST a host name, HA_HOST an IPv4 address), creates
/opt/kvitt/{secrets,import,apk}, installs the two secret files (fcm-service-account.json 0440 root:10001;
rclone.conf 0600 10001:10001), adds gitea.lan to insecure-registries in /etc/docker/daemon.json (and
restarts Docker if that changed), copies compose.yaml, .env (0600) and the scripts to /opt/kvitt, pulls,
starts and waits. If an earlier bundle with Caddy and cert-sync was installed, it also removes its leftovers
(/opt/kvitt/Caddyfile, secrets/ha_ssh_key, secrets/ha_known_hosts, the containers and the volumes
kvitt_certs and kvitt_caddy_data) and says so. Expected end: server: healthy, a docker compose ps table with
server (ports 0.0.0.0:8080->8080/tcp) and backup, the line GET http://localhost:8080/health: {"status":"ok",...},
then "Not reachable through Nginx Proxy Manager yet" (expected on the first install, before step 6) or
GET https://<KVITT_PUBLIC_HOST>:443/health (via NPM at <HA_HOST>): {"status":"ok",...}, then "Kvitt is running.".
Then rm -r ~/kvitt-pi/secrets (the script reminds you). Running install.sh again is safe. Re-running
install.sh keeps the KVITT_TAG that deploy.sh set; change the tag only with deploy.sh <tag>.
6. Nginx Proxy Manager on the Home Assistant Pi
NPM terminates TLS for Kvitt with its own Let's Encrypt certificate for <KVITT_PUBLIC_HOST> and forwards plain
HTTP over the home LAN to the Kvitt Pi's port 8080 (plain HTTP between the two Pis is accepted, D-120). First make
sure <KVITT_PUBLIC_HOST> points at the home's public address (your DNS or DuckDNS page shows the same address as
icehack.asuscomm.com). Let's Encrypt's HTTP-01 challenge reaches NPM through external port 80, which already
works: it is how NPM got the certificate for Home Assistant. Serving Kvitt under a path of icehack.asuscomm.com
(for example /kvitt/) is not supported.
Open NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), then Hosts, Proxy Hosts, Add Proxy Host.
Details tab:
| Field | Value |
|---|---|
| Domain Names | <KVITT_PUBLIC_HOST> |
| Scheme | http |
| Forward Hostname / IP | <KVITT_PI_HOST> (the IPv4 address) |
| Forward Port | 8080 |
| Cache Assets | off |
| Block Common Exploits | on |
| Websockets Support | off |
| Access List | Publicly Accessible |
Custom Locations tab: leave empty.
SSL tab:
| Field | Value |
|---|---|
| SSL Certificate | Request a new Certificate (Let's Encrypt, HTTP challenge: Use DNS Challenge off); Email address: yours; if a Let's Encrypt Terms of Service checkbox is shown, tick it |
| Force SSL | on |
| HTTP/2 Support | on |
| HSTS Enabled | on |
| HSTS Sub-domains | off |
| Trust Upstream Forwarded Proto Headers | off |
Advanced tab, Custom Nginx Configuration: paste this block exactly (all of it, comments included):
# Kvitt (SPEC §10.5.2, D-120). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
# Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413.
client_max_body_size 6m;
# No long-poll in the API (SPEC §14.3); 60 s between two reads from the server.
proxy_read_timeout 60s;
# Response headers (D-117). They live at server level, so no location below may use add_header (nginx would then
# drop all of these in it); NPM's HSTS line (SSL tab) sits at the same level. The server's own copy is hidden, so
# each header appears once (the landing page sends Referrer-Policy itself).
proxy_hide_header Content-Security-Policy;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-Frame-Options;
proxy_hide_header Referrer-Policy;
add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
# Replaces NPM's own "location /" (NPM leaves it out when this field has one), whose add_header X-Served-By would
# drop the headers above. $forward_scheme, $server and $port are NPM's Details tab values.
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass $forward_scheme://$server:$port$request_uri;
}
# Invite links /i/<code>?from=<name>: neither the code nor the inviter's name may reach a log (SPEC §8.7). NPM's
# access log format writes the whole $request_uri, query string included, so this path is not logged at all, and
# its error log (which would carry the request line on a 502) goes nowhere.
location ^~ /i/ {
access_log off;
error_log /dev/null crit;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass $forward_scheme://$server:$port$request_uri;
}
Save. The new row under Proxy Hosts shows the status Online and an SSL entry for Let's Encrypt (yes/no). Then on the Kvitt Pi:
curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health
It prints {"status":"ok",...} (no -k: this also checks the certificate). deploy.sh and install.sh run the
same check.
Why the block has its own location /. nginx drops every add_header inherited from the server level in a
location that has an add_header of its own, and NPM's default location / has one (X-Served-By), which would
silently drop the four security headers (D-117). NPM leaves its default location out when the Custom Nginx
Configuration contains location /, so the block brings its own without add_header; NPM's HSTS line from the SSL
tab stays at server level and still applies. The block's proxy_pass uses NPM's Forward Hostname / IP and Forward
Port, so change those on the Details tab as usual. If a later NPM release changes its proxy host template, check
the headers again (Operations, "Headers through NPM").
What NPM logs (SPEC §8.7). NPM writes one access log line per request to /data/logs/proxy-host-<id>_access.log
inside NPM, in its proxy format: time, cache and upstream status, status, method, scheme, host,
"$request_uri" (the path with its query string), [Client <address>], length, gzip ratio,
[Sent-to <KVITT_PI_HOST>], user agent and referer. It logs no request headers, so the Authorization token is never
logged. Query strings are logged for every path except /i/; the other query parameters of the API are since and
limit (GET /sync) and size and v (pictures), which hold no personal content. /i/<code>?from=<name> is not
logged at all: the block turns the access log off for /i/ and sends its error log to /dev/null, so neither the
invite code nor the inviter's name reaches a log. Errors on other paths (for example a 502 while the server is down)
go to proxy-host-<id>_error.log with the request line.
HSTS. HSTS tells a browser to use only HTTPS for <KVITT_PUBLIC_HOST>, on every port, for two years
(max-age=63072000; preload, NPM's value), and to refuse certificate warnings for it. Because the name is Kvitt's
own, nothing else is affected; but if you ever serve anything else under <KVITT_PUBLIC_HOST> (any port), it must be
HTTPS with a valid certificate too, and turning HSTS off later does not clear browsers that already stored it. The
app itself does not rely on HSTS (release builds allow no cleartext, SPEC §8.5); it protects the landing page in a
browser.
Accepted: NPM trusts X-Real-IP from the LAN. NPM's nginx.conf takes the client address from X-Real-IP when
the connection comes from 10/8, 172.16/12, 192.168/16 (and the CDN ranges it downloads), so a device on the home
LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the
router's forward keeps the source address), so their X-Real-IP is ignored.
7. Router forward
ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule: Service Name Kvitt, Protocol TCP, External
Port 8443, Internal Port 443 (NPM's HTTPS port; if the add-on's Network settings map 443/tcp to another host
port, use that one), Internal IP Address <HA_HOST>; Apply. No rule forwards to the Kvitt Pi (remove one to
<KVITT_PI_HOST> if you made it for the Caddy version). Then on the phone with Wi-Fi off, open
https://<KVITT_PUBLIC_HOST>:8443/health in Chrome: it shows "status":"ok" without a certificate warning (yes/no).
8. First backup and bootstrap
On the Pi:
/opt/kvitt/backup-now.sh # ends with "last-success: <stamp>" and the newest kvitt-*.sqlite.gz and pictures-*.tar.gz on Drive
/opt/kvitt/bootstrap.sh # first install only: prints the admin invite code, link and expiry
The invite link starts with https://<KVITT_PUBLIC_HOST>:8443/i/. The release app opens such links and connects to
that host only once an Android build carries the new host name (SPEC §8.5; until then the app knows
icehack.asuscomm.com): tell the orchestrator before pairing a release build.
9. Deploying a new version
After the Gitea workflow pushed v1.0.N (or infra/build-images.sh printed a tag), on the Pi:
/opt/kvitt/deploy.sh v1.0.N
It backs up first (Drive confirmed) on every deploy, because any tag may carry a migration (SPEC §10.10), sets
KVITT_TAG, pulls, prints the sha256: digest of every image and appends it to /opt/kvitt/deployed-digests,
starts, waits for server: healthy and prints /health on port 8080 and through NPM (a failure through NPM is a
WARNING: the deploy succeeded, but Kvitt is not reachable from outside). The images come over plain HTTP from
gitea.lan (D-001), so compare the printed digests with the workflow's "Print image digests" step for that run; a
line starting WARNING: about a digest means a tag that was deployed before now points at a different image: do not
continue, roll back and find out why (D-117). If the server does not come up:
/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup; if a migration had partially applied,
restore the pre-deploy backup (infra/RESTORE.md). A newer bundle with a changed compose.yaml is installed by
copying the bundle again (step 4, without the secrets) and running ./install.sh.
Fallback (SPEC §10.5.3): when NPM is down
- NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over
https://<KVITT_PUBLIC_HOST>:8443(that path goes through NPM too). The server on the Kvitt Pi keeps running and nothing there needs to change. The phones keep working on their full local copy and sync when NPM is back. - On the home Wi-Fi only debug builds can reach the server directly, at
http://<KVITT_PI_HOST>:8080(AC-2.8 allowshttp://to private LAN addresses in debug builds only). Release builds require HTTPS with a publicly trusted certificate (SPEC §8.5) and cannot use that address: there is no LAN fallback for the release app. A debug build is a separate app (se.hagstedt.kvitt.debug) with its own pairing and data, not a stand-in for the release app. - If NPM cannot renew the Let's Encrypt certificate: fix it in NPM (SSL Certificates, the certificate, Renew Now;
port 80 must reach NPM). The pinned-root switch of the app (
kvitt.tls.pinnedRoot, D-082) still exists for a self-made root whose certificate would be uploaded to NPM as a Custom certificate; it is not prepared: tell the orchestrator.
Operations (SPEC §10.10), on the Pi in /opt/kvitt
| Task | Command |
|---|---|
| Status | docker compose ps |
| Logs | docker compose logs --tail 100 <server or backup>; NPM's own log files are inside NPM on the Home Assistant Pi |
| Backup now | /opt/kvitt/backup-now.sh |
| Upgrade | /opt/kvitt/deploy.sh <tag> |
| Roll back | /opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup |
| Health through NPM | curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health |
| Headers through NPM | curl -sS -D - -o /dev/null --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health lists content-security-policy, x-content-type-options, x-frame-options, referrer-policy, strict-transport-security once each and no x-served-by |
| Certificate expiry and issuer | echo | openssl s_client -connect <HA_HOST>:443 -servername <KVITT_PUBLIC_HOST> 2>/dev/null | openssl x509 -noout -enddate -issuer (NPM renews it; NPM, SSL Certificates shows the same) |
| Extra device invite | docker compose exec server kvitt-server pair-device --user <user_id> |
| Memory | docker stats --no-stream (server RSS under 100 MB with both phones syncing) |
| Disk | df -h / and docker system df; SQLite a few MB, pictures under 1 MB, APKs about 10 MB each since R8, 5 kept |
| Image digests deployed | tail -n 8 /opt/kvitt/deployed-digests |
| Client IPs are real (once, after the router forward) | Open https://<KVITT_PUBLIC_HOST>:8443/health on the phone with Wi-Fi off, then within a minute on the Kvitt Pi sudo apt install -y conntrack (once) and sudo conntrack -L -p tcp --orig-port-dst 8080: in every line the first src= is <HA_HOST> and the second half (from the second src=) has dst=<HA_HOST>, no 172.* there, so the server sees NPM's address and trusts its X-Forwarded-For. If the second half shows a 172.* address instead, every internet client shares one per-IP limit (SPEC §8.6): tell the orchestrator |
| Restore | infra/RESTORE.md |
APK downloads (security review L-3, D-119). GET /apk/files/<name> is public (the landing page links it) and
about 11 MB per response; the server allows 10 per minute per client IP. If one address still fetches
/apk/files/ over and over (NPM's access log), remove the router forward (step 7) until it stops.
Owner decisions (open)
Backup encryption (security review M-1) is decided: no rclone crypt, the drive scope stays (SPEC §8.7, 2026-10-19).
- Gitea token scope. Does the workflow's tag push work with
kvitt_cicdlimited towrite:package? If not, is addingwrite:repositoryacceptable? - Kvitt's host name (D-120). Which name is
<KVITT_PUBLIC_HOST>? It goes into the release app anddocs/HANDOFF.md.
Troubleshooting
- Step 6 check or
deploy.shsays /health through NPM failed: "Could not resolve host" or "SSL certificate problem": the proxy host has no certificate for<KVITT_PUBLIC_HOST>yet (SSL tab) or a different name; a502 Bad Gateway: NPM cannot reach<KVITT_PI_HOST>:8080(wrong Forward Hostname / IP or Forward Port, or the server is down:docker compose ps); the Home Assistant page instead of{"status":"ok"}: Domain Names does not match the name you requested (NPM fell back to another host). - "Request a new Certificate" fails in NPM:
<KVITT_PUBLIC_HOST>does not point at the home's public address yet, or external port 80 does not reach NPM. - Pull fails with "http: server gave HTTP response to HTTPS client":
gitea.lanis missing from/etc/docker/daemon.json(install.shadds it; check withdocker info | grep -A3 'Insecure Registries'). - Pull fails with "unauthorized": the packages are private; set
KVITT_REGISTRY_USERandKVITT_REGISTRY_TOKENin~/kvitt-pi/.envand run./install.shagain. install.shsays/opt/kvitt/secrets/<file>is a directory: Docker created it because the file was missing at a start;sudo rmdirit, put the file in~/kvitt-pi/secrets/, run./install.sh.