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>
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).