Pi: minimal self-contained compose project (D-125)
The Kvitt Pi runs Kvitt from /home/jacobhagstedt/kvitt: compose.yaml, .env, secrets/ and relative bind mounts ./data, ./backups, ./import. Both containers run as the owner's uid/gid (KVITT_UID, KVITT_GID). No install script, no /opt, no Docker daemon or system changes; operations are plain docker compose commands (README "Pi: what the owner does", RESTORE.md). Removed install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh, lib.sh. .env holds only the keys compose.yaml reads; RCLONE_REMOTE is fixed to gdrive: in compose.yaml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+130
-141
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
compose project the owner copies to `/home/jacobhagstedt/kvitt` on the Kvitt Pi and runs there (D-125), 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".
|
||||
|
||||
@@ -20,7 +20,7 @@ from the checklist in "Pi: what the owner does".
|
||||
| `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/` |
|
||||
| `pi/` | **The Pi project** (copied to `~/kvitt` on the Kvitt Pi): `compose.yaml`, `.env.example`; 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) |
|
||||
@@ -153,7 +153,7 @@ gcloud iam service-accounts keys list --iam-account kvitt-fcm@kvitt-1337.iam.gse
|
||||
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).
|
||||
The key goes to the Pi as `~/kvitt/secrets/fcm-service-account.json` ("Pi: what the owner does", step 2).
|
||||
|
||||
## Pi stack (SPEC §10.1 to §10.10, D-100)
|
||||
|
||||
@@ -163,21 +163,25 @@ Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by `pi
|
||||
|---|---|---|
|
||||
| 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-address>:8080` over the LAN (D-120) | The owner, in NPM's web UI (step 6) |
|
||||
| Router (ASUS) | No new rule: the existing external TCP 443 forward to the Home Assistant Pi (NPM, for Home Assistant) serves Kvitt too, NPM picks the proxy host by name; external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-124) | The owner checks it (step 7) |
|
||||
| Kvitt Pi, `/home/jacobhagstedt/kvitt/` | `compose.yaml`, `.env`, `secrets/`, and the bind-mounted `data/` (SQLite, pictures, `apk/`), `backups/`, `import/`; nothing outside this folder (D-125) | The owner, in their own SSH session, with plain `docker compose` commands |
|
||||
| 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-address>:8080` over the LAN (D-120) | The owner, in NPM's web UI (step 4) |
|
||||
| Router (ASUS) | No new rule: the existing external TCP 443 forward to the Home Assistant Pi (NPM, for Home Assistant) serves Kvitt too, NPM picks the proxy host by name; external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-124) | The owner checks it (step 5) |
|
||||
|
||||
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}` (default port 443, D-124), `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.
|
||||
`backup`; relative bind mounts `./data:/data`, `./backups:/backups` (backup), `./import:/import:ro` (server), and one
|
||||
secret file each, read-only: `secrets/fcm-service-account.json` for the server, `secrets/rclone.conf` for the backup
|
||||
(D-117: the server never sees the Drive token); both containers run as `${KVITT_UID}:${KVITT_GID}`, the owner's
|
||||
uid and gid, with a read-only root, a 16 MB tmpfs `/tmp`, no capabilities and `no-new-privileges`; every service
|
||||
`restart: unless-stopped`, json-file logs 10 MB x 3, `TZ=Europe/Stockholm`; the compose default network. 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. There is no install script and nothing on the Pi outside `~/kvitt` is changed (D-125).
|
||||
|
||||
**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
|
||||
must be NPM's LAN IPv4 address (the server accepts an IP literal or a resolvable name, not a CIDR; use 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).
|
||||
@@ -188,34 +192,55 @@ address, and only `HA_HOST` may set `X-Forwarded-For`.
|
||||
|
||||
| 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` |
|
||||
| Nginx Proxy Manager | The owner's existing install on the Home Assistant Pi; the block of step 4 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
|
||||
**Why supercronic, not busybox crond (D-104).** The backup sidecar runs as a non-root user (the owner's uid on the Pi, D-125). 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")
|
||||
### Container uid (D-105, D-125)
|
||||
|
||||
`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
|
||||
`server` and `backup` run as the **same uid**, because `sqlite3 .backup` on a WAL database must open (and write)
|
||||
`kvitt.sqlite-shm`, which the server creates with mode 0644 (D-105). On the Pi that uid is the owner's own
|
||||
(`KVITT_UID`, `KVITT_GID` in `.env`, `id -u` / `id -g`), so everything in `data/` and `backups/` belongs to the
|
||||
owner, the secret files can simply be the owner's with mode 0600, and a restore works with plain `mv` and `cp`
|
||||
(D-125). The images need no particular uid: the server image is distroless with `USER 10001` only as its default
|
||||
(compose's `user:` overrides it; the binary needs no account, no home and nothing writable outside `/data`), and the
|
||||
backup image's scheduler supercronic runs as any uid. The owner's uid has no account inside either image; the
|
||||
backup container gets `HOME=/tmp` for rclone. Folders the containers write to must exist before the first start
|
||||
(`mkdir -p data/apk backups import`): a bind-mount source Docker creates itself belongs to root. 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`).
|
||||
in the tmpfs `/tmp` each run. The development stack on the Mac keeps uid 10001 (`infra/dev/`).
|
||||
|
||||
### What was checked on the Mac
|
||||
|
||||
2026-10-20 (D-125, self-contained compose project):
|
||||
|
||||
* `podman-compose -f infra/pi/compose.yaml --env-file <filled sample> config`: exit 0.
|
||||
* Rehearsal with Podman in a scratch copy of the project folder, `KVITT_UID`/`KVITT_GID` = the Mac user's 501:20
|
||||
(not 10001), images `kvitt-server` from `server/Dockerfile` and `kvitt-backup` from `infra/backup/Dockerfile`:
|
||||
both containers healthy with read-only roots; `GET /health` 200 with `db_ok: true`; `kvitt.sqlite`, its `-wal`
|
||||
and `-shm` and `data/apk` owned by 501 on the host; `kvitt-server bootstrap` through `exec` printed a code, a
|
||||
`https://<KVITT_PUBLIC_HOST>/i/` link and an expiry; an APK and `latest.json` copied into `data/apk/` were served
|
||||
at once (`/apk/latest`, `/apk/files/kvitt-<n>.apk` 200 `application/vnd.android.package-archive`); `/backup.sh`
|
||||
through `exec`: with `RCLONE_FOLDER_ID` empty it kept both files and wrote `last-success`; with a local rclone
|
||||
remote under `/tmp` it uploaded, confirmed and deleted the local copies; with the remote on the read-only root it
|
||||
failed, kept the files and left `last-success` unchanged; the archives belong to 501. The restore steps of
|
||||
`RESTORE.md` (stop, `gunzip`, `integrity_check` `ok`, `mv` aside, `cp`, `tar -x`, `run --rm server
|
||||
rotate-epoch`, start) ended with `/health` 200. Podman-compose ignores `create_host_path: false` (it created a
|
||||
directory for a missing secret file); Docker Compose honours it and refuses to start instead, not checked here.
|
||||
* Not checked (needs the Pi): Docker Engine and Docker Compose themselves, pulls from `gitea.lan`, Drive.
|
||||
|
||||
2026-10-19 (D-120, Nginx Proxy Manager instead of Caddy and cert-sync):
|
||||
|
||||
* `shellcheck -S style` on every script under `infra/` and `tools/`: clean.
|
||||
* `podman-compose -f infra/pi/compose.yaml --env-file <filled sample> config` and the same for `infra/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's `nginx.conf` with the proxy host rendered as NPM's template renders it for the step 6 settings (Block
|
||||
* `nginx -t` (nginx:alpine 1.31.7) of the step 4 block inside a minimal server block, and inside a copy of NPM
|
||||
v2.16.0's `nginx.conf` with the proxy host rendered as NPM's template renders it for the step 4 settings (Block
|
||||
Common Exploits, Force SSL, HTTP/2, HSTS, the block; NPM's regex `^(?:.*;)?\s*?location\s*?\/\s*?{` finds the
|
||||
block's `location /`, so NPM leaves its own out): both successful.
|
||||
* Rehearsal with Podman: the server image built from `server/Dockerfile` at this commit (arm64, hardened as on the
|
||||
@@ -245,7 +270,7 @@ Same setup as the owner's working example: `runs-on: host`, `sh` steps, `apk add
|
||||
Gitea gives a repository its owner's secrets) 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.
|
||||
pushed after the build. That version is the `KVITT_TAG` the Pi's `.env` takes.
|
||||
The checkout is a cone-mode sparse checkout of `server` and `infra/backup` only, the two build contexts (neither
|
||||
Dockerfile copies anything outside its context), so the action fetches with `--filter=blob:none` and writes only
|
||||
those files instead of all 2118 (52 MB of Android screenshot goldens among them; about 95 s on the first runs).
|
||||
@@ -258,7 +283,7 @@ act_runner masks the secret in the log. That first run got as far as the login (
|
||||
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).
|
||||
image's `sha256:` digest, which must match the digest the Pi pulled (Operations, "Image digests"; 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
|
||||
@@ -291,114 +316,76 @@ tags of the Gitea workflow (`v1.0.N`) are separate and say nothing about the APK
|
||||
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:
|
||||
with their mappings. Then the owner, on the Mac, copies the APK first and `latest.json` last into the server's
|
||||
`data/apk/` (the server reads `latest.json` on every `GET /apk/latest`, so it must never name a file that is not
|
||||
there yet):
|
||||
|
||||
```bash
|
||||
cd /Users/jhag/projects/kvitt
|
||||
scp infra/pi/out/apk/* <user>@<kvitt-pi-address>:/opt/kvitt/apk/
|
||||
ssh <user>@<kvitt-pi-address> /opt/kvitt/install-apk.sh
|
||||
scp infra/pi/out/apk/kvitt-<version_code>.apk jacobhagstedt@<kvitt-pi-address>:kvitt/data/apk/
|
||||
scp infra/pi/out/apk/latest.json jacobhagstedt@<kvitt-pi-address>:kvitt/data/apk/
|
||||
curl -s http://<kvitt-pi-address>:8080/apk/latest # shows the new version_code
|
||||
```
|
||||
|
||||
`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`.
|
||||
The mapping files stay on the Mac. Older APKs on the Pi are removed by hand when wanted
|
||||
(`ssh jacobhagstedt@<kvitt-pi-address> ls kvitt/data/apk`, then `rm` the old `kvitt-<n>.apk` files; about 10 MB each).
|
||||
|
||||
## Pi: what the owner does
|
||||
|
||||
Fill these in first (in `infra/pi/.env` on the Mac; the bundle carries the file to the Pi):
|
||||
Kvitt runs from `/home/jacobhagstedt/kvitt` on the Kvitt Pi (D-125): `compose.yaml`, `.env`, `secrets/` and the
|
||||
folders `data/`, `backups/`, `import/`. Nothing outside that folder is installed or changed. `<kvitt-pi-address>` is
|
||||
the Kvitt Pi's LAN IPv4 address (static DHCP lease; ASUS router web UI, LAN, DHCP Server, client list), also NPM's
|
||||
Forward Hostname / IP (step 4).
|
||||
|
||||
| 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`) |
|
||||
| `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. Prerequisites (already on the Pi)
|
||||
|
||||
Not in `.env` (nothing on the Pi reads them; dropped from `.env` on 2026-10-20): in this README,
|
||||
`<kvitt-pi-address>` is the Kvitt Pi's LAN IPv4 address (give it a static DHCP lease; ASUS router web UI, LAN, DHCP
|
||||
Server, client list), also NPM's Forward Hostname / IP (step 6), and `<user>` is your user on the Kvitt Pi (the user
|
||||
set in Raspberry Pi Imager).
|
||||
* Docker Engine with the compose plugin, usable by `jacobhagstedt` without sudo: `docker compose version` prints a
|
||||
version.
|
||||
* Registry trust: the images come over plain HTTP from `gitea.lan`. If `docker compose pull` says "http: server
|
||||
gave HTTP response to HTTPS client", add `gitea.lan` to `insecure-registries` in `/etc/docker/daemon.json` (the
|
||||
Pi already has it for another project; Kvitt never edits it).
|
||||
* If `docker compose pull` says "unauthorized" (private packages): once, `docker login gitea.lan` with your Gitea
|
||||
user and a token with `read:package` (Gitea, Settings, Applications, Generate New Token).
|
||||
|
||||
### 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 <user>@<kvitt-pi-address>`.
|
||||
|
||||
### 2. Install Docker Engine on the Pi (docs.docker.com, Debian arm64 repository)
|
||||
|
||||
On the Pi:
|
||||
|
||||
```bash
|
||||
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
|
||||
### 2. On the Mac: `.env`, secrets, copy
|
||||
|
||||
```bash
|
||||
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
|
||||
open -e infra/pi/.env # set HA_HOST; KVITT_UID/KVITT_GID = `id -u` / `id -g` on the Pi (1000 for the first user)
|
||||
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:
|
||||
|
||||
```bash
|
||||
scp -r infra/pi <user>@<kvitt-pi-address>:kvitt-pi
|
||||
scp -r infra/pi/compose.yaml infra/pi/.env infra/pi/secrets jacobhagstedt@<kvitt-pi-address>:kvitt/
|
||||
rm -r infra/pi/secrets # the originals stay in infra/secrets/
|
||||
```
|
||||
|
||||
### 5. Install and start
|
||||
| Key in `infra/pi/.env` | Value |
|
||||
|---|---|
|
||||
| `HA_HOST` | LAN IPv4 address of the Home Assistant Pi (NPM), for example `192.168.1.20`, not `homeassistant.local` (router client list, or Home Assistant, Settings, System, Network) |
|
||||
| `KVITT_UID`, `KVITT_GID` | `id -u` and `id -g` on the Kvitt Pi as `jacobhagstedt` (default 1000, 1000) |
|
||||
| `KVITT_TAG` | `v1.0.0`, or the newest `v1.0.N` the Gitea workflow pushed (Gitea, the repository, Packages, `kvitt-server`) |
|
||||
| `KVITT_PUBLIC_HOST`, `KVITT_REGISTRY`, `RCLONE_FOLDER_ID` | Already `kvitt.suorra.duckdns.org`, `gitea.lan/jacobh2`, `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` |
|
||||
|
||||
On the Pi:
|
||||
`rclone.conf` is generated from your `rclone authorize "drive"` (remote `[gdrive]`, `scope = drive`); the Drive
|
||||
folder id is `RCLONE_FOLDER_ID` in `.env`. `infra/pi/.env` and `infra/pi/secrets/` are git-ignored. To change
|
||||
`compose.yaml` or `.env` later, copy just that file again the same way.
|
||||
|
||||
### 3. On the Pi: start
|
||||
|
||||
```bash
|
||||
cd ~/kvitt-pi && ./install.sh
|
||||
cd ~/kvitt
|
||||
chmod 700 secrets && chmod 600 secrets/*
|
||||
mkdir -p data/apk backups import
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
curl -s localhost:8080/health
|
||||
```
|
||||
|
||||
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>/health (via NPM at <HA_HOST>:443): {"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>`.
|
||||
Expected: `docker compose ps` lists `server` (ports `0.0.0.0:8080->8080/tcp`) and `backup`, both `(healthy)` within
|
||||
a minute; `curl` prints `{"status":"ok",...,"db_ok":true}`.
|
||||
|
||||
### 6. Nginx Proxy Manager on the Home Assistant Pi
|
||||
### 4. Nginx Proxy Manager on the Home Assistant Pi (already set up)
|
||||
|
||||
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
|
||||
@@ -486,8 +473,7 @@ 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.
|
||||
It prints `{"status":"ok",...}` (no `-k`: this also checks the certificate).
|
||||
|
||||
**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
|
||||
@@ -519,7 +505,7 @@ the connection comes from 10/8, 172.16/12, 192.168/16 (and the CDN ranges it dow
|
||||
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: no new rule
|
||||
### 5. Router: no new rule
|
||||
|
||||
No new rule (D-124): external TCP 443 already reaches the Home Assistant Pi (NPM) for Home Assistant, and NPM picks
|
||||
Kvitt's proxy host by name on the same port. Verify it exists: ASUS router web UI, WAN, Virtual Server / Port
|
||||
@@ -528,38 +514,38 @@ forwarded to the Kvitt Pi: remove a rule to `<kvitt-pi-address>` if you made one
|
||||
External Port `8443` if you made one for the earlier NPM version (D-120). Then on the phone with Wi-Fi off, open
|
||||
`https://<KVITT_PUBLIC_HOST>/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no).
|
||||
|
||||
### 8. First backup and bootstrap
|
||||
### 6. From outside, first backup, bootstrap
|
||||
|
||||
On the Pi:
|
||||
On the phone with Wi-Fi off (or from the Mac), `https://kvitt.suorra.duckdns.org/health` shows `"status":"ok"`
|
||||
(yes/no). Then on the Pi:
|
||||
|
||||
```bash
|
||||
/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
|
||||
cd ~/kvitt
|
||||
docker compose exec backup /backup.sh # ends with "backup: ok <stamp>" after "uploaded and confirmed on Drive" twice
|
||||
docker compose exec server kvitt-server bootstrap # first install only: prints the admin invite code, link and expiry
|
||||
```
|
||||
|
||||
The invite link starts with `https://<KVITT_PUBLIC_HOST>/i/` (no port). The release app opens such links and connects to
|
||||
that host only once its Android build carries the host name (`kvitt.publicHost`, D-121) and the default port 443
|
||||
(D-124; builds made for port 8443 do not match a link without a port): tell the orchestrator before pairing a
|
||||
release build.
|
||||
release build. The partner's invite, once the admin is paired: `docker compose exec server kvitt-server invite-partner`.
|
||||
|
||||
### 9. Deploying a new version
|
||||
### 7. Deploying a new version
|
||||
|
||||
After the Gitea workflow pushed `v1.0.N` (or `infra/build-images.sh` printed a tag), on the Pi:
|
||||
|
||||
```bash
|
||||
/opt/kvitt/deploy.sh v1.0.N
|
||||
cd ~/kvitt
|
||||
docker compose exec backup /backup.sh # backup first, every deploy: any tag may carry a migration (SPEC §10.10)
|
||||
sed -i 's/^KVITT_TAG=.*/KVITT_TAG=v1.0.N/' .env # or edit .env; note the previous tag for a rollback
|
||||
docker compose pull && docker compose up -d
|
||||
docker compose ps && curl -s localhost:8080/health
|
||||
```
|
||||
|
||||
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`.
|
||||
Continue only when the backup ended with `backup: ok`. The images come over plain HTTP from `gitea.lan` (D-001), so
|
||||
compare the pulled digests (Operations, "Image digests") with the workflow's "Print image digests" step for that
|
||||
run (D-117). Rollback: set the previous tag in `.env` the same way and run `docker compose pull && docker compose
|
||||
up -d`; if a migration had partially applied, restore the pre-deploy backup (`infra/RESTORE.md`).
|
||||
|
||||
### Fallback (SPEC §10.5.3): when NPM is down
|
||||
|
||||
@@ -576,23 +562,24 @@ copying the bundle again (step 4, without the secrets) and running `./install.sh
|
||||
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`
|
||||
### Operations (SPEC §10.10), on the Pi in `~/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` |
|
||||
| Backup now | `docker compose exec backup /backup.sh` (ends with `backup: ok <stamp>`) |
|
||||
| Upgrade | Step 7: backup, set `KVITT_TAG` in `.env`, `docker compose pull && docker compose up -d` |
|
||||
| Roll back | Set the previous `KVITT_TAG` in `.env`, `docker compose pull && docker compose up -d` |
|
||||
| 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) |
|
||||
| Partner invite | `docker compose exec server kvitt-server invite-partner` |
|
||||
| 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 step 7) | Open `https://<KVITT_PUBLIC_HOST>/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 |
|
||||
| Disk | `df -h ~` and `du -sh ~/kvitt/*`; SQLite a few MB, pictures under 1 MB, APKs about 10 MB each since R8, 5 kept |
|
||||
| Image digests | `docker compose images` names the images; `docker image inspect --format '{{.RepoDigests}}' gitea.lan/jacobh2/kvitt-server:<tag>` (and `kvitt-backup`) prints the `sha256:` digest to compare with the workflow's |
|
||||
| Client IPs are real (once, after step 5) | Open `https://<KVITT_PUBLIC_HOST>/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
|
||||
@@ -611,16 +598,18 @@ Backup encryption (security review M-1) is decided: no rclone `crypt`, the `driv
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
* Step 6 check or `deploy.sh` says /health through NPM failed: "Could not resolve host" or "SSL certificate problem":
|
||||
* The step 4 check says /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; a `502 Bad Gateway`:
|
||||
NPM cannot reach `<kvitt-pi-address>: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.lan` is missing from
|
||||
`/etc/docker/daemon.json` (`install.sh` adds it; check with `docker info | grep -A3 'Insecure Registries'`).
|
||||
* Pull fails with "unauthorized": the packages are private; set `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN`
|
||||
in `~/kvitt-pi/.env` and run `./install.sh` again.
|
||||
* `install.sh` says `/opt/kvitt/secrets/<file>` is a directory: Docker created it because the file was missing at a
|
||||
start; `sudo rmdir` it, put the file in `~/kvitt-pi/secrets/`, run `./install.sh`.
|
||||
* Pull fails with "http: server gave HTTP response to HTTPS client": `gitea.lan` is missing from `insecure-registries`
|
||||
(step 1; check with `docker info | grep -A3 'Insecure Registries'`).
|
||||
* Pull fails with "unauthorized": the packages are private; `docker login gitea.lan` once (step 1).
|
||||
* `docker compose up` says "bind source path does not exist" for a file in `secrets/`: the file was not copied;
|
||||
copy it (step 2) and run `docker compose up -d` again.
|
||||
* The server or backup log says "Permission denied" or "readonly database" on `/data` or `/backups`: the folder was
|
||||
created by Docker as root, or `KVITT_UID`/`KVITT_GID` differ from the folder's owner: `ls -ln ~/kvitt` shows the
|
||||
owner, `id -u` / `id -g` yours; `sudo chown -R "$(id -u):$(id -g)" ~/kvitt/data ~/kvitt/backups ~/kvitt/import` once.
|
||||
|
||||
Reference in New Issue
Block a user