6.8 KiB
📦 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'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 5 Docker hosts converted; the largest has 36 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. 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, 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 36
pinned_manual 3
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 |
| 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. 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, docs/orchestration.md, docs/sso-and-identity.md · Stacks: stacks/platform/gitea/, stacks/platform/kestra/
