Author SHA1 Message Date
BBergleandClaude Sonnet 5 6b0f28cf74 docs(deploy): note the /data uid/gid-mismatch trap on first start
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 14s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
Hit this deploying to the real Unraid host: the container runs as a fixed
non-root uid/gid (999), not root and not Unraid's usual nobody:users
(99:100). A freshly-created appdata directory is owned by nobody:users with
no write access for anyone else, so the container starts but uvicorn fails
immediately with "unable to open database file" — not obvious from the
error alone, worth documenting once rather than re-debugging it later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 21:08:03 -04:00
BBergleandClaude Sonnet 5 45719f284c feat(deploy): build+push release image on every merge to main
CI / Repo hygiene (pull_request) Successful in 3s
CI / Web (lint, typecheck, build) (pull_request) Successful in 22s
CI / Migrations reversible (pull_request) Successful in 10s
CI / API (lint, types, tests) (pull_request) Successful in 1m4s
Was tag-push-or-manual-dispatch only. Adds a push:main trigger so main stays
continuously deployable without needing a version tag for every change.

Also fixes a real bug this surfaced while testing the D17 registry-TLS fix:
the old tag logic unconditionally retagged :latest on every run, including
manual test dispatches off a feature branch — one such dispatch, done while
verifying the previous commit, silently overwrote :latest with a
feature-branch build. Tag resolution now only moves :latest on an actual
main push or a version tag; a manual dispatch gets its own
manual-<timestamp>-<sha> tag and leaves :latest alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 20:57:52 -04:00
BBergleandClaude Sonnet 5 a114a7d3d8 fix(deploy): push through a TLS-terminating proxy, not raw Gitea HTTP
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 16s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
release.yml's first real run failed: docker/login-action against
192.168.0.3:3000 hit "server gave HTTP response to HTTPS client" — Docker
refuses any non-localhost registry over plain HTTP by default, so this was
never actually a workflow bug.

Rejected insecure-registries in daemon.json after reading this Unraid host's
own rc.docker script: applying it needs a full dockerd restart, and with
Live Restore disabled here, that stops every one of the ~40 other containers
on the box first. Also rejected a real Let's Encrypt cert on a public
bbergle.com subdomain — this host's other subdomains are Cloudflare-proxied,
which would terminate TLS at Cloudflare's edge and never reach our own cert
at all.

Chosen instead, scoped to touch nothing already working: a self-signed cert
for registry.bbergle.com behind a new NPMplus proxy host (found its real
HTTPS port, 9537, by reading `docker port NPMplus` rather than assuming 443,
which is a different nginx process on this box entirely); an /etc/hosts
entry on the Unraid host so only that host needs to resolve the name (no
DNS record, no router/NAT dependency); and its CA dropped into
/etc/docker/certs.d, which Docker's own docs confirm is read per-connection
with no daemon restart required. Also pins buildx to driver: docker instead
of setup-buildx-action's default docker-container driver, which runs an
isolated builder that doesn't see /etc/docker/certs.d and would have quietly
defeated all of the above.

Full record, including what was rejected and why, in docs/DECISIONS.md D17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 20:52:56 -04:00
BBergleandClaude Sonnet 5 32037b1190 fix(deploy): default the Unraid template's host port off 8080
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 13s
CI / Migrations reversible (pull_request) Successful in 5s
CI / API (lint, types, tests) (pull_request) Successful in 54s
8080 is already bound by qBittorrent on the actual Unraid host this gets
deployed to (found while placing the template for real) — defaulted to 8090
instead. Purely a template default; the container's own internal port is
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 20:21:01 -04:00
BBergle 70e0182177 Merge pull request 'chore(deploy): single-container Dockerfile, Caddy, and Unraid template' (#6) from chore/deploy-single-container into main
CI / Repo hygiene (push) Successful in 2s
CI / Web (lint, typecheck, build) (push) Successful in 14s
CI / Migrations reversible (push) Successful in 6s
CI / API (lint, types, tests) (push) Successful in 52s
Release image / Build and push single-container image (push) Failing after 1m42s
Reviewed-on: #6
2026-09-21 15:58:43 -04:00
4 changed files with 118 additions and 16 deletions
+37 -7
View File
@@ -2,6 +2,7 @@ name: Release image
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
@@ -12,31 +13,60 @@ jobs:
steps:
- uses: actions/checkout@v4
# driver: docker (not the action's default docker-container driver) so buildx reuses the
# host's own dockerd instead of spinning up an isolated builder container — the isolated
# one doesn't see the host's /etc/docker/certs.d, which is how the login step below trusts
# the registry's self-signed cert (docs/DECISIONS.md D17). We don't need multi-platform
# builds, so nothing the docker-container driver offers is actually lost here.
- uses: docker/setup-buildx-action@v3
with:
driver: docker
# secrets.GITEA_TOKEN cannot push to the Gitea container registry — a documented Gitea
# limitation, not a misconfiguration (see CLAUDE.md). REGISTRY_TOKEN is a separate PAT with
# package:write, expected to already exist as a repo secret.
#
# registry.bbergle.com:9537, not the raw 192.168.0.3:3000 Gitea talks HTTP on directly —
# Docker refuses any non-localhost registry over plain HTTP by default. This hostname is an
# NPMplus proxy host in front of Gitea's registry, terminating TLS with a self-signed cert;
# the runner host trusts it via /etc/docker/certs.d/registry.bbergle.com:9537/ca.crt (not
# committed here — host-local trust material, docs/DECISIONS.md D17 has the full setup).
- uses: docker/login-action@v3
with:
registry: 192.168.0.3:3000
registry: registry.bbergle.com:9537
username: BBergle
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Resolve image tag
# `latest` should only ever mean "what's actually on main" (or a tagged release) — not
# whatever a manual test dispatch off some feature branch happened to build. Learned the
# hard way: a manual dispatch off this very branch, while verifying the fix above, silently
# overwrote `latest` under the old unconditional-tags logic. Building the full tag list here
# in bash (rather than a conditional expression inline in the tags: block below) means there's
# never a blank line for build-push-action to choke on when latest isn't included.
- name: Resolve image tags
id: tag
run: |
IMG=registry.bbergle.com:9537/bbergle/bike-app
if [ "${{ gitea.ref_type }}" = "tag" ]; then
echo "value=${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
VALUE="${{ gitea.ref_name }}"
UPDATE_LATEST=true
elif [ "${{ gitea.ref_name }}" = "main" ] && [ "${{ gitea.event_name }}" = "push" ]; then
VALUE="main-$(git rev-parse --short HEAD)"
UPDATE_LATEST=true
else
echo "value=manual-$(date -u +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT"
VALUE="manual-$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
UPDATE_LATEST=false
fi
{
echo "tags<<EOF"
echo "$IMG:$VALUE"
[ "$UPDATE_LATEST" = true ] && echo "$IMG:latest"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
192.168.0.3:3000/bbergle/bike-app:latest
192.168.0.3:3000/bbergle/bike-app:${{ steps.tag.outputs.value }}
tags: ${{ steps.tag.outputs.tags }}
+21 -5
View File
@@ -63,6 +63,13 @@ the same values, there's no separate migration-time config anymore (docs/DECISIO
|---|---|
| `/data` | The SQLite database file. Will also hold the content-addressed blob store once Phase 1 builds ingestion. This is the only thing that needs backing up. |
The container runs as a fixed non-root user (uid/gid `999`), not root and not Unraid's usual
`nobody:users` (99:100). If `/data`'s host directory doesn't already exist, Docker/Unraid creates
it owned by `nobody:users` with no write access for other users — the container starts, but
uvicorn fails immediately with `sqlite3.OperationalError: unable to open database file`, since it
can't create the SQLite file inside a directory it can't write to. Fix once, before first start:
`chown -R 999:999 <host path>` (e.g. `/mnt/user/appdata/velodrome` on Unraid).
## Unraid
Import `unraid-template.xml` from the Docker tab's "Add Container" template picker — it exposes
@@ -73,11 +80,20 @@ stays editable by hand afterward regardless of what the template pre-fills.
## Publishing the image
`.gitea/workflows/release.yml` builds this Dockerfile and pushes it to the Gitea container
registry (`192.168.0.3:3000/bbergle/bike-app`) on a `v*` tag push, or on manual
`workflow_dispatch`. It does **not** SSH into the host and recreate the running container —
rolling out a new image on Unraid (pulling it and clicking "Apply" on the container, or via
Unraid's own update-checking) is left as a manual/Unraid-side step, not something CI does
unattended.
registry at `registry.bbergle.com:9537/bbergle/bike-app` on every push to `main` (tagged
`main-<short-sha>`, and `latest`), on a `v*` tag push (tagged with the tag name, and `latest`), or
on manual `workflow_dispatch` (tagged `manual-<timestamp>-<short-sha>` only — a manual dispatch
never moves `latest`, so testing a feature branch can't clobber what's actually deployable). Not
`192.168.0.3:3000` (Gitea's own plain-HTTP address) directly — Docker
refuses any non-localhost registry over plain HTTP by default, so `registry.bbergle.com:9537` is
an NPMplus proxy host in front of Gitea's registry that terminates TLS with a self-signed cert.
See `docs/DECISIONS.md` D17 for the full setup (cert, NPMplus proxy host, `certs.d` trust, and the
buildx driver change this required) — none of it is committed here, since it's host-local trust
material and NPMplus config, not something this repo can or should own.
It does **not** SSH into the host and recreate the running container — rolling out a new image on
Unraid (pulling it and clicking "Apply" on the container, or via Unraid's own update-checking) is
left as a manual/Unraid-side step, not something CI does unattended.
## What's not here yet
+4 -4
View File
@@ -10,15 +10,15 @@
-->
<Container version="2">
<Name>velodrome</Name>
<Repository>192.168.0.3:3000/bbergle/bike-app:latest</Repository>
<Repository>registry.bbergle.com:9537/bbergle/bike-app:latest</Repository>
<Registry>http://192.168.0.3:3000/BBergle/-/packages/container/bike-app</Registry>
<Network>bridge</Network>
<Privileged>false</Privileged>
<Support>https://192.168.0.3:3000/BBergle/bike-app/issues</Support>
<Project>http://192.168.0.3:3000/BBergle/bike-app</Project>
<Overview>Self-hosted cycling app: Bryton Rider 650 ride sync, mileage tracking, spare-parts inventory, and maintenance reminders. One container: Caddy + the FastAPI app + a SQLite database file on the Data path below. See docs/PLAN.md and docs/DECISIONS.md (D15/D16) in the repo for the design.</Overview>
<Overview>Self-hosted cycling app: Bryton Rider 650 ride sync, mileage tracking, spare-parts inventory, and maintenance reminders. One container: Caddy + the FastAPI app + a SQLite database file on the Data path below. See docs/PLAN.md and docs/DECISIONS.md (D15/D16/D17) in the repo for the design.</Overview>
<Category>Productivity:</Category>
<WebUI>http://[IP]:[PORT:8080]/</WebUI>
<WebUI>http://[IP]:[PORT:8090]/</WebUI>
<Icon/>
<ExtraParams/>
<PostArgs/>
@@ -27,7 +27,7 @@
<DonateText/>
<DonateLink/>
<Description>Self-hosted cycling app: Bryton ride sync, mileage tracking, spare-parts inventory, maintenance reminders.</Description>
<Config Name="Web UI Port" Target="8080" Default="8080" Mode="tcp" Description="Container's HTTP port. Put a reverse proxy with TLS in front of this — the container itself only ever serves plain HTTP (docs/PLAN.md 'Service topology')." Type="Port" Display="always" Required="true" Mask="false">8080</Config>
<Config Name="Web UI Port" Target="8080" Default="8090" Mode="tcp" Description="Host port mapped to the container's internal 8080. Defaulted off 8080 since that's already taken by qBittorrent on this host — check for a free port before changing it. Put a reverse proxy with TLS in front of this — the container itself only ever serves plain HTTP (docs/PLAN.md 'Service topology')." Type="Port" Display="always" Required="true" Mask="false">8090</Config>
<Config Name="Data" Target="/data" Default="/mnt/user/appdata/velodrome" Mode="rw" Description="The SQLite database file (and, in a later phase, the raw-file blob store) live here. This is the only thing worth backing up." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/velodrome</Config>
<Config Name="VELODROME_PUBLIC_URL" Target="VELODROME_PUBLIC_URL" Default="" Mode="" Description="The externally-visible URL this instance is reachable at, e.g. https://bikes.example.com. Must match exactly what's in the browser's address bar — it's checked against the Origin header on cookie-authenticated requests to stop cross-site request forgery." Type="Variable" Display="always" Required="true" Mask="false"></Config>
<Config Name="VELODROME_SECRET_KEY" Target="VELODROME_SECRET_KEY" Default="" Mode="" Description="A random secret, 32+ bytes. Generate one with: openssl rand -hex 32. The image ships an insecure development placeholder — always override this before exposing the container to anything." Type="Variable" Display="always" Required="true" Mask="true"></Config>
+56
View File
@@ -233,6 +233,62 @@ image out is a manual/Unraid-side action (pull + Apply, or Unraid's own update c
something CI does unattended — consistent with treating "affects a shared, already-running system"
as something a human triggers, not automation.
### D17 — Registry TLS: self-signed cert behind NPMplus, not `insecure-registries`, not a real domain
**Problem:** `release.yml`'s first real run failed — `docker/login-action` against
`192.168.0.3:3000` (Gitea's plain-HTTP address) hit `server gave HTTP response to HTTPS client`.
Docker refuses TLS-less registries by default; this was never a workflow misconfiguration, it's
expected Docker behaviour for any non-localhost registry.
**Rejected: `insecure-registries` in `daemon.json`.** The obvious fix. Rejected after actually
reading `/etc/rc.d/rc.docker` on the Unraid host rather than assuming: applying a `daemon.json`
change requires a full `dockerd` restart, and (with `Live Restore` disabled on this host) both
Unraid's own restart path *and* a raw `kill` of `dockerd` stop every one of the ~40 other
containers running on the box first, as part of the restart/shutdown sequence — Plex, Home
Assistant, Vaultwarden, everything. Correct fix for the narrow problem, unacceptable blast radius
for this specific host.
**Rejected: a real Let's Encrypt cert on a new `bbergle.com` subdomain routed publicly.** The
user's other NPMplus-fronted subdomains resolve through Cloudflare's proxy (orange-cloud), not
directly to the home IP. A Cloudflare-proxied hostname would have terminated TLS at Cloudflare's
edge with Cloudflare's own cert, never reaching our self-signed cert or NPMplus's own TLS
config at all — the entire trust chain would depend on Cloudflare's origin SSL mode, and likely on
firewall rules restricting port 443 to Cloudflare's IP ranges, neither of which this problem
needed to involve.
**Chosen:** a small, fully self-contained fix, scoped to touch nothing already working:
- A 10-year self-signed cert for `registry.bbergle.com` (SAN-only, no real domain dependency).
- An NPMplus proxy host (`registry.bbergle.com` -> `192.168.0.3:3000` over plain HTTP internally)
terminating TLS with that cert, on NPMplus's existing HTTPS port (`9537` on this host — found by
reading `docker port NPMplus` rather than assuming 443, which is a *different* nginx process on
this box entirely).
- `/etc/hosts` on the Unraid host mapping `registry.bbergle.com` -> `192.168.0.103` (itself) —
chosen over a real DNS record specifically because the only client that ever needs to resolve
this hostname is the Unraid host's own `dockerd` (Gitea Actions runs in DooD mode against that
same host's Docker socket). This sidesteps Cloudflare, the router's NAT/hairpin behaviour, and
any port-forwarding question entirely — verified separately that hairpin NAT works by default on
this user's UniFi gateway, but it turned out to be unnecessary for this fix regardless.
- `/etc/docker/certs.d/registry.bbergle.com:9537/ca.crt` on the Unraid host, trusting that cert for
that host:port specifically. Confirmed (Docker's own docs) that `certs.d` is read per-connection,
not baked in at daemon start — no `dockerd` restart, no impact on any other container.
- `docker/setup-buildx-action@v3` pinned to `driver: docker` in `release.yml` instead of its
default `docker-container` driver — the default runs BuildKit in an isolated builder container
that does not see the host's `/etc/docker/certs.d`, which would have silently defeated the whole
point of the trust setup above. We don't build multi-platform images, so nothing the
`docker-container` driver offers is actually needed here.
**Not persisted across a reboot, deliberately, for now:** neither the `/etc/hosts` line nor the
`certs.d` file are wired into `/boot/config/go` — both live under `/`, which Unraid rebuilds fresh
from `/boot` on every boot. Raised explicitly rather than assumed: the user was (rightly) wary of
hand-editing anything under `/boot` after an earlier, unrelated discussion of what a broken `go`
script could do to boot. Persisting this is a five-minute follow-up (append two lines to `go`) once
they're ready to make that call deliberately, not bundled into this fix.
**What's unaffected:** Gitea's own web UI, git remote, and API — all still plain
`http://192.168.0.3:3000`, exactly as CLAUDE.md documents. NPMplus's existing public proxy hosts
and certs (`vaultwarden.bbergle.com` etc.) — untouched, new proxy host only. No other container on
the Unraid host was restarted, reconfigured, or otherwise touched to make this work.
---
## Deliberately deferred