fix(deploy): push through a TLS-terminating proxy, not raw Gitea HTTP #9

Merged
BBergle merged 3 commits from fix/deploy-registry-tls into main 2026-09-21 22:15:05 -04:00
4 changed files with 116 additions and 14 deletions
+37 -7
View File
@@ -2,6 +2,7 @@ name: Release image
on: on:
push: push:
branches: [main]
tags: ['v*'] tags: ['v*']
workflow_dispatch: workflow_dispatch:
@@ -12,31 +13,60 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - 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 - uses: docker/setup-buildx-action@v3
with:
driver: docker
# secrets.GITEA_TOKEN cannot push to the Gitea container registry — a documented Gitea # 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 # limitation, not a misconfiguration (see CLAUDE.md). REGISTRY_TOKEN is a separate PAT with
# package:write, expected to already exist as a repo secret. # 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 - uses: docker/login-action@v3
with: with:
registry: 192.168.0.3:3000 registry: registry.bbergle.com:9537
username: BBergle username: BBergle
password: ${{ secrets.REGISTRY_TOKEN }} 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 id: tag
run: | run: |
IMG=registry.bbergle.com:9537/bbergle/bike-app
if [ "${{ gitea.ref_type }}" = "tag" ]; then 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 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 fi
{
echo "tags<<EOF"
echo "$IMG:$VALUE"
[ "$UPDATE_LATEST" = true ] && echo "$IMG:latest"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: . context: .
file: Dockerfile file: Dockerfile
push: true push: true
tags: | tags: ${{ steps.tag.outputs.tags }}
192.168.0.3:3000/bbergle/bike-app:latest
192.168.0.3:3000/bbergle/bike-app:${{ steps.tag.outputs.value }}
+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. | | `/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 ## Unraid
Import `unraid-template.xml` from the Docker tab's "Add Container" template picker — it exposes 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 ## Publishing the image
`.gitea/workflows/release.yml` builds this Dockerfile and pushes it to the Gitea container `.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 registry at `registry.bbergle.com:9537/bbergle/bike-app` on every push to `main` (tagged
`workflow_dispatch`. It does **not** SSH into the host and recreate the running container — `main-<short-sha>`, and `latest`), on a `v*` tag push (tagged with the tag name, and `latest`), or
rolling out a new image on Unraid (pulling it and clicking "Apply" on the container, or via on manual `workflow_dispatch` (tagged `manual-<timestamp>-<short-sha>` only — a manual dispatch
Unraid's own update-checking) is left as a manual/Unraid-side step, not something CI does never moves `latest`, so testing a feature branch can't clobber what's actually deployable). Not
unattended. `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 ## What's not here yet
+2 -2
View File
@@ -10,13 +10,13 @@
--> -->
<Container version="2"> <Container version="2">
<Name>velodrome</Name> <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> <Registry>http://192.168.0.3:3000/BBergle/-/packages/container/bike-app</Registry>
<Network>bridge</Network> <Network>bridge</Network>
<Privileged>false</Privileged> <Privileged>false</Privileged>
<Support>https://192.168.0.3:3000/BBergle/bike-app/issues</Support> <Support>https://192.168.0.3:3000/BBergle/bike-app/issues</Support>
<Project>http://192.168.0.3:3000/BBergle/bike-app</Project> <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> <Category>Productivity:</Category>
<WebUI>http://[IP]:[PORT:8090]/</WebUI> <WebUI>http://[IP]:[PORT:8090]/</WebUI>
<Icon/> <Icon/>
+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" something CI does unattended — consistent with treating "affects a shared, already-running system"
as something a human triggers, not automation. 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 ## Deliberately deferred