# 11 — Container Registry (Gitea) ## Summary We host our own container registry on Gitea at `gitea.treytartt.com`. Every image push and pull goes there, not Docker Hub or GHCR. The Gitea instance runs outside this k3s cluster (on its own VPS) and is available at `https://gitea.treytartt.com` with public HTTPS. Image pulls are authenticated via a Personal Access Token stored as a Kubernetes `dockerconfigjson` Secret. ## Why Gitea ### Decision matrix | Option | Cost | Auth model | Pros | Cons | |---|---|---|---|---| | **Gitea built-in registry** | $0 (already running Gitea) | Gitea PAT | Self-hosted, integrated with code | Another service to maintain | | GHCR (GitHub Container Registry) | Free for public, $0 for private with paid plan | GitHub PAT | Popular, reliable | Uses GitHub; vendor dependency | | Docker Hub | Free tier limited; paid $5-7/mo | Docker Hub account | Ubiquitous | Rate limits on anonymous pulls | | AWS ECR | ~$1/mo for small use | IAM | Integrates with AWS workloads | AWS account required | | Harbor (self-hosted) | $0 | Many options | Best enterprise features | Heavy to operate | Gitea won primarily because **the operator was already running Gitea for code hosting**. Container registry is built into Gitea 1.17+ as a free feature. One fewer service to set up. Side benefits: - Code and images live together (one backup policy, one access model) - PATs are scoped and rotatable via the same UI - No external vendor to worry about for this critical piece of the deploy pipeline Rejected alternatives: - **Docker Hub** — rate limits on unauthenticated pulls would bite us if nodes pull the same image repeatedly during rolling updates - **GHCR** — fine but adds GitHub dependency we don't otherwise have - **Harbor** — massive overkill; we're not a 100-team enterprise ## Layout Images live under the authenticated user's namespace: ``` gitea.treytartt.com/admin/honeydue-api:237c6b8 gitea.treytartt.com/admin/honeydue-worker:237c6b8 gitea.treytartt.com/admin/honeydue-admin:237c6b8 ``` `admin` is the Gitea user that owns the images. Images are **private** by default. ### Image tagging strategy Tags are git short SHAs (e.g., `237c6b8`). Not `:latest`. Not semantic version. Rationale: - `:latest` is ambiguous — which build? Rolling updates should roll a *specific* tag so rollbacks are deterministic. - `:v1.2.3` works for released libraries but our app rolls forward continuously; versioning per deploy is unnecessary overhead. - Git SHAs are unique, immutable, and tie each image to the exact commit that built it. `PUSH_LATEST_TAG=false` is set in `deploy/cluster.env`. When we rebuild and push, only the SHA tag gets pushed. The `latest` tag is never created by our deploy pipeline. ## Authentication ### Creating the PAT At , we created a token with scopes: - `read:package` - `write:package` No other scopes. This token can only interact with package registry; it can't read repo contents, create issues, or touch account settings. ### PAT on the operator workstation Stored in `deploy/registry.env`: ``` REGISTRY=gitea.treytartt.com REGISTRY_NAMESPACE=admin REGISTRY_USERNAME=admin REGISTRY_TOKEN= ``` This file is `.gitignore`d in `deploy/.gitignore`. If it ever gets committed accidentally, rotate the PAT immediately. ### PAT in the cluster Stored as the `gitea-credentials` Secret (type `dockerconfigjson`) in the `honeydue` namespace. See Chapter 10. Kubelet reads this Secret when a pod needs to pull from the Gitea registry. ## The build pipeline ### Dockerfile multi-stage `honeyDueAPI-go/Dockerfile` has three target stages: - `api` — compiled Go binary + static assets for the HTTP API - `worker` — compiled Go binary for the background worker - `admin` — Next.js standalone build of the admin panel A single Dockerfile keeps build-cache sharing efficient (the Go builder stage produces binaries for both api and worker; admin reuses its own Node builder stage). ### Multi-arch cross-compilation The operator workstation is **arm64** (Apple Silicon). The Hetzner nodes are **x86_64**. A naive `docker build` on arm64 produces arm64 images that won't run on the nodes (`exec format error`). The deploy pipeline uses `docker buildx`: ```bash docker buildx build \ --platform linux/amd64 \ --target api \ -t gitea.treytartt.com/admin/honeydue-api:$SHA \ --push \ /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go ``` - **`--platform linux/amd64`** — cross-compile to x86_64 - **`--target api`** — which Dockerfile stage to build - **`--push`** — push directly to the registry (skip local image cache) The Go stages use the `TARGETARCH` build arg to produce the right architecture binary. Node stages use QEMU emulation (which is slower but acceptable for our ~1 min admin build). ### Buildx builder We use a named buildx builder to keep state out of Docker's default environment: ```bash docker buildx create --name honeydue-builder --use docker buildx inspect --bootstrap ``` The `honeydue-builder` is a docker-container driver — spawns a BuildKit container when building, tears it down when idle. Supports multi-platform and caches layers across builds. ## From local file to cluster — the full path ```mermaid flowchart LR subgraph dev[Operator workstation] Code[Source code] Dockerfile Buildx[docker buildx] end subgraph Gitea[gitea.treytartt.com] Reg[Package registry] end subgraph K8s[k3s cluster] Kubelet Containerd Pod end Code --> Dockerfile Dockerfile --> Buildx Buildx -- push --> Reg Reg -- pull --> Kubelet Kubelet --> Containerd Containerd --> Pod ``` ### End-to-end 1. **Operator pushes code**: commits to `main` locally 2. **Operator builds + pushes image**: `docker buildx build --push ...` from the repo root. Build takes 1–3 minutes first time, seconds on warm cache. 3. **Image lands in Gitea**: visible at `https://gitea.treytartt.com/admin/-/packages/container/honeydue-api` 4. **Operator updates Deployment**: `kubectl set image deployment/api api=gitea.treytartt.com/admin/honeydue-api:$NEW_SHA -n honeydue` 5. **K8s begins rolling update**: creates new ReplicaSet with new image 6. **Kubelet on target node** sees a pod with an image it doesn't have 7. **Kubelet calls containerd**: "pull this image using these creds" 8. **Containerd authenticates** to Gitea registry using the PAT from `gitea-credentials` Secret, downloads the image 9. **Containerd starts the container** with the new image 10. **Readiness probe passes**: new pod joins the Service endpoints 11. **Kubelet tears down** an old pod ## Pushing manually If you need to push a one-off image (e.g., testing a fix): ```bash # Login (once per session) set -a; source deploy/registry.env; set +a printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin # Build + push cd honeyDueAPI-go SHA=$(git rev-parse --short HEAD) docker buildx build \ --platform linux/amd64 \ --target api \ -t "gitea.treytartt.com/admin/honeydue-api:${SHA}" \ --push . # Logout (don't leave creds in ~/.docker/config.json) docker logout gitea.treytartt.com ``` ## Image sizes Current images: | Image | Size | Layers | |---|---|---| | `honeydue-api` | ~53 MB | Alpine base + Go binary | | `honeydue-worker` | ~50 MB | Alpine base + Go binary | | `honeydue-admin` | ~150 MB | Node 20 alpine + Next.js standalone | The Go binaries are statically compiled, CGO_ENABLED=0. Alpine is the base for smallest footprint. ## Image retention Gitea does **not auto-prune** images. Every `:` tag accumulates forever. The package page at `https://gitea.treytartt.com/admin/-/packages/container/honeydue-api` lists them all. At current pace (deploys ~few/week, images ~50-150 MB each), this grows ~10 GB/year. Not critical; 80 GB node disk can take years. **TODO**: Add a monthly cleanup: delete all but last 30 tags per image. Can be a cron job or a manual quarterly cleanup. ## Image verification — not yet We do not sign images or verify signatures. An attacker who compromised Gitea could push a malicious image under an existing tag (though Gitea should prevent tag reuse if immutable tags are configured). **TODO** (Chapter 20): Add [cosign](https://github.com/sigstore/cosign) for signing at build time + `Kyverno` or `Connaisseur` policy to verify at pull time. ## Gitea registry itself The Gitea instance runs outside this k3s cluster on its own VPS (operator's existing infrastructure). It's **not** part of the honeyDue deployment — it's adjacent infrastructure. If the Gitea host goes down: - Currently-running pods keep working (they already pulled their images) - New deployments/scale-ups fail at the image-pull step - No impact on existing user traffic This is an acceptable external dependency. Gitea host has its own uptime story. ## Cost **$0/mo.** Gitea registry is included in the Gitea install we already pay the VPS for (not accounted to honeyDue's cost). If we ever switched to GHCR, cost would still be $0 for public images or bundled with our (nonexistent) GitHub Team subscription. ## What we don't have - **Image scanning** (Trivy, Snyk) — scan images for known CVEs on push - **Image signing** (cosign) - **Multi-region replication** — only hosted in one place - **High availability** — Gitea is single-instance For our scale, none of these are needed. TODO (Chapter 20) if the operator appetite increases. ## Operator cheat sheet ```bash # List packages via API curl -sS "https://gitea.treytartt.com/api/v1/packages/admin?type=container" \ -H "Accept: application/json" | jq . # Browse in UI # https://gitea.treytartt.com/admin/-/packages # Delete a specific tag via API curl -X DELETE \ -H "Authorization: token $GITEA_PAT" \ "https://gitea.treytartt.com/api/v1/packages/admin/container/honeydue-api/237c6b8" # Login from kubectl side (refresh the Secret) kubectl create secret docker-registry gitea-credentials -n honeydue \ --docker-server=gitea.treytartt.com \ --docker-username=admin \ --docker-password= \ --dry-run=client -o yaml | kubectl apply -f - # After rotating PAT, restart pods that use it for pulls kubectl rollout restart -n honeydue deploy/api deploy/admin deploy/worker ``` ## References - [Gitea Container Registry][gitea-cr] - [Docker buildx multi-platform][buildx] - [Kubernetes image pull secrets][pull-secrets] - [cosign][cosign] [gitea-cr]: https://docs.gitea.com/usage/packages/container [buildx]: https://docs.docker.com/build/buildx/ [pull-secrets]: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ [cosign]: https://github.com/sigstore/cosign