From a114a7d3d814ec50d8f40130b7418183628b218e Mon Sep 17 00:00:00 2001 From: Benny Date: Mon, 21 Sep 2026 20:51:49 -0400 Subject: [PATCH 1/2] fix(deploy): push through a TLS-terminating proxy, not raw Gitea HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG --- .gitea/workflows/release.yml | 19 ++++++++++-- deploy/README.md | 16 +++++++---- deploy/unraid-template.xml | 4 +-- docs/DECISIONS.md | 56 ++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 66f6ca4..580949a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -12,14 +12,27 @@ 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 }} @@ -38,5 +51,5 @@ jobs: 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 }} + registry.bbergle.com:9537/bbergle/bike-app:latest + registry.bbergle.com:9537/bbergle/bike-app:${{ steps.tag.outputs.value }} diff --git a/deploy/README.md b/deploy/README.md index 8405778..dc30ad4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -73,11 +73,17 @@ 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 a `v*` tag push, or on manual +`workflow_dispatch`. 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 diff --git a/deploy/unraid-template.xml b/deploy/unraid-template.xml index 9194b71..2ea61a8 100644 --- a/deploy/unraid-template.xml +++ b/deploy/unraid-template.xml @@ -10,13 +10,13 @@ --> velodrome - 192.168.0.3:3000/bbergle/bike-app:latest + registry.bbergle.com:9537/bbergle/bike-app:latest http://192.168.0.3:3000/BBergle/-/packages/container/bike-app bridge false https://192.168.0.3:3000/BBergle/bike-app/issues http://192.168.0.3:3000/BBergle/bike-app - 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. + 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. Productivity: http://[IP]:[PORT:8090]/ diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 93ab530..dca3727 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 -- 2.54.0 From 45719f284cdd30a7fd835e04aaeb716e1749ae07 Mon Sep 17 00:00:00 2001 From: Benny Date: Mon, 21 Sep 2026 20:57:52 -0400 Subject: [PATCH 2/2] feat(deploy): build+push release image on every merge to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-- tag and leaves :latest alone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG --- .gitea/workflows/release.yml | 29 +++++++++++++++++++++++------ deploy/README.md | 7 +++++-- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 580949a..c808c49 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,6 +2,7 @@ name: Release image on: push: + branches: [main] tags: ['v*'] workflow_dispatch: @@ -36,20 +37,36 @@ jobs: 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<> "$GITHUB_OUTPUT" - uses: docker/build-push-action@v6 with: context: . file: Dockerfile push: true - tags: | - registry.bbergle.com:9537/bbergle/bike-app:latest - registry.bbergle.com:9537/bbergle/bike-app:${{ steps.tag.outputs.value }} + tags: ${{ steps.tag.outputs.tags }} diff --git a/deploy/README.md b/deploy/README.md index dc30ad4..1d8a844 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -73,8 +73,11 @@ 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 at `registry.bbergle.com:9537/bbergle/bike-app` on a `v*` tag push, or on manual -`workflow_dispatch`. Not `192.168.0.3:3000` (Gitea's own plain-HTTP address) directly — Docker +registry at `registry.bbergle.com:9537/bbergle/bike-app` on every push to `main` (tagged +`main-`, and `latest`), on a `v*` tag push (tagged with the tag name, and `latest`), or +on manual `workflow_dispatch` (tagged `manual--` 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 -- 2.54.0