Files
kvitt/infra/README.md
T
jacobh2 c885aba313 M2c-dev: development compose stack, backup job, Firebase setup docs
infra/dev: compose stack on the Mac (server kvitt-server:dev, on-demand backup),
.env.example, build/up/logs/backup/copy-google-services scripts. No Caddy, no TLS
(debug builds accept http to private LAN addresses, AC-2.8). Bind-mounted data.
infra/backup: alpine 3.24.2 image and backup.sh (SPEC 10.9; Drive upload skipped
when RCLONE_FOLDER_ID is empty). infra/README.md for the dev stack and Firebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:18:36 +02:00

7.7 KiB

infra: Kvitt infrastructure

This file covers the development stack on the owner's Mac (infra/dev/, milestone M2c-dev, D-001) and the Firebase setup (SPEC §10.7). The Pi stack (infra/compose.yaml, Caddy with the Let's Encrypt certificate, cert-sync, the Gitea registry, Drive backups, deploy and APK publishing) is built by M2c-pi, which adds its own sections here.

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
backup/Dockerfile, backup/backup.sh Backup image and job of SPEC §10.9 (shared with the Pi stack)
secrets/ Git-ignored: fcm-service-account.json, google-services.json. Never committed

All commands below run from the repository root.

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. The secrets folder is mounted read-only at /secrets; if fcm-service-account.json is missing the server runs with push disabled (§10.4).

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 (milestone M2a)
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 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. 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 is M2c-pi (§10.7 step 7).