Same music-assistant strategy change fixed in the KB earlier this session (dockerfile -> pinned_manual, avoiding auto-update-triggered audio-analysis invalidation): stack count corrected 36->35, added to the Deliberate Holdouts table with its own real reason. Also found a second gap: 'All 5 Docker hosts converted' should be 6 - NetBird now has its own dockerfile-strategy stack (hawser), confirmed live in its docker_stacks.yml, and was missing from this count.
78 lines
7.4 KiB
Markdown
78 lines
7.4 KiB
Markdown
<!-- verified: 2026-09-19
|
|
ansible@24e1bf0 : host_vars/rocky-docker/docker_stacks.yml, host_vars/netbird/docker_stacks.yml, roles/container_update, playbooks/container_update.yml
|
|
iac@f770e1c : ansible/roles/secure_vault/vault_agent/defaults/main.yml
|
|
trilium : dcRUArk5dWH8, bW84ccRoAJ5i, hWiIGZIlvQiN
|
|
-->
|
|
|
|
# 📦 Self-Hosted Registry & Build Pipeline
|
|
|
|
> [!NOTE]
|
|
> Gitea's own built-in container registry hosts every image this fleet builds — no separate registry service to operate, no dependency on Docker Hub or ghcr at runtime for the stacks that have opted in. This is the mechanism that makes [`docs/iac-pipeline.md`](iac-pipeline.md)'s "own everything in the stack" philosophy actually true at the container-image layer, not just the OS layer.
|
|
|
|
| | |
|
|
|---|---|
|
|
| **Registry** | Gitea's own built-in container registry |
|
|
| **Trigger** | Weekly schedule via Kestra, not push-triggered |
|
|
| **Rollout** | All 6 Docker hosts converted; the largest has 35 of 41 stacks on this pattern |
|
|
|
|
## Why Self-host The Registry Instead Of Just Using Docker Hub
|
|
|
|
Pulling directly from Docker Hub/ghcr at runtime was the status quo before this existed, and it was replaced for a concrete reason: every container pull became a runtime dependency on an external service staying up and staying reachable through the firewall model in [`docs/firewall-and-network.md`](firewall-and-network.md). Building images in-fleet and pushing to a registry that's already inside the trust boundary means a redeploy never depends on an external registry's availability, and the exact image contents are fully within this fleet's own control rather than whatever an upstream maintainer pushed as `:latest` today.
|
|
|
|
Pushing to the registry uses the same Vault-backed secrets pattern as everything else in [`docs/secrets-and-pki.md`](secrets-and-pki.md), not a token pasted into a CI config somewhere: a Gitea Personal Access Token scoped to `write:package` only, rendered by Vault Agent to a `0600` file on each building host and piped straight into `docker login --password-stdin` — never typed, never sitting in a config file with broader scope than it needs.
|
|
|
|
This is the real registry, not a claim about it — every one of these is a genuine self-built container, not a Docker Hub pull:
|
|
|
|

|
|
|
|
## The Cheap-check-first Build Decision
|
|
|
|

|
|
|
|
> [!TIP]
|
|
> Most stacks use a minimal `FROM <upstream-image>:${VERSION}` wrapper Dockerfile rather than a full clone-and-build-from-source setup — simpler, builds near-instantly, can't break from upstream build tooling changing underneath it, while still achieving the real goal: an in-fleet registry with no direct Docker Hub/ghcr dependency at runtime. A handful of multi-build-image stacks (the ones with more than one real upstream component, like Paperless-ngx, Ghost, Ollama, and the Grafana stack) skip the cheap check entirely, since a single "upstream digest" comparison doesn't make sense when there's more than one upstream involved.
|
|
|
|
## Deliberate Holdouts
|
|
|
|
Not every stack is on this pattern, and that's a decision, not an oversight — knowing when *not* to apply a pattern uniformly is as much a design skill as building the pattern in the first place. This is the real breakdown, not a rounded estimate:
|
|
|
|
```
|
|
$ python3 -c "import yaml; d=yaml.safe_load(open('docker_stacks.yml'))['stacks']; ..."
|
|
total: 41
|
|
dockerfile 35
|
|
pinned_manual 4
|
|
latest 2
|
|
```
|
|
|
|
| Stack | Why it's held out |
|
|
|---|---|
|
|
| Authentik, Immich, tdarr | Complex multi-service upstream builds — not worth replicating a build process the upstream project already maintains well; the maintenance cost of tracking their own build changes would exceed the benefit |
|
|
| Music-assistant | Was on this pattern, deliberately reverted — auto-updating `:latest` kept bumping its `smart_fades`/`loudness_analysis` `analysis_version`, mass-invalidating ~86k tracks' audio-analysis backlog each time it moved; now pinned by hand, bumped only after checking release notes |
|
|
| Kodi's own app image | Genuinely still a direct Docker Hub dependency — low enough stakes that the extra build step isn't worth it |
|
|
| Nextcloud's own `nextcloud`/`euro-office` services | Pre-existing custom Dockerfiles, left as-is beyond adding `image:` references — its own db/redis still float on Docker Hub tags directly, a conscious "don't touch what already works" call |
|
|
|
|
Two more things that look like holdouts but aren't: `docker-socket-proxy` and Kodi's own `mariadb` service both run on `strategy: latest`, but neither actually depends on Docker Hub at runtime — each floats a `:latest` pull against a small dedicated mirror stack (`docker-socket-proxy-mirror`, `mariadb-latest`) that *is* built in-fleet via the normal pattern. It's a deliberate variant for lightweight consumer containers: one real build feeds every stack that needs that base image, instead of each one maintaining its own near-identical Dockerfile. This is the real entry, not a description of the pattern:
|
|
|
|
```
|
|
$ grep -A6 "^ mariadb-latest:" docker_stacks.yml
|
|
mariadb-latest:
|
|
strategy: dockerfile
|
|
upstream_image: lscr.io/linuxserver/mariadb:latest
|
|
notes: >-
|
|
Not a real database - nothing connects to it directly. Exists purely
|
|
to be built and pushed so every stack needing a MariaDB (bookstack,
|
|
firefly iii, kodi) pulls from our registry instead of Docker Hub.
|
|
```
|
|
|
|
## Trigger Discipline
|
|
|
|
> [!WARNING]
|
|
> This is schedule-triggered, not push-triggered — a deliberate choice, not a missing feature. Editing a stack's own Dockerfile doesn't by itself trigger a rebuild; it waits for the next weekly sweep to find the upstream base image has actually changed, or for someone to force a manual run via [`docs/orchestration.md`](orchestration.md). The tradeoff: a simpler mental model and no rebuild storm on every unrelated commit, at the real cost of same-day propagation for an urgent base-image CVE fix — accepted because a weekly cadence matches this fleet's actual risk tolerance, not because push-triggering would have been harder to build.
|
|
|
|
## Housekeeping
|
|
|
|
Unused local images get pruned automatically at the end of every build pass — for build-and-push-only stacks, the registry is the source of truth, not whatever happens to be sitting on local disk, so nothing is lost by aggressively reclaiming local space immediately. A volume prune runs right after it, for a less obvious reason: build-and-push-only database stacks declare no `volumes:` block of their own, but the base image's own `VOLUME` directive still leaves an anonymous volume behind on every single build regardless — always safe to reclaim immediately, since the prune only ever removes volumes with zero remaining container references.
|
|
|
|
---
|
|
Related: [`docs/iac-pipeline.md`](iac-pipeline.md), [`docs/orchestration.md`](orchestration.md), [`docs/sso-and-identity.md`](sso-and-identity.md) · Stacks: [`stacks/platform/gitea/`](../stacks/platform/gitea/), [`stacks/platform/kestra/`](../stacks/platform/kestra/)
|