From 874f33a63c7540c7b5da2f4d732c45e3723f4a7d Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Thu, 29 Oct 2026 14:53:03 +0100 Subject: [PATCH] docs: decide that our container images stay public (ADR 0004) --- docs/CONTEXT.md | 2 +- .../decisions/0004-public-container-images.md | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0004-public-container-images.md diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 8423217..543656c 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -106,7 +106,7 @@ Both the API and the dashboard follow it; changes to it go through a PR first. | Database | `postgres:18.6` StatefulSet, volume mounted at `/var/lib/postgresql`, our own StorageClass `local-path-retain` (the default deletes data) | Neon / Supabase (brief: everything on our own VPS) | | Orchestration | k3s v1.36.5 (Traefik, metrics-server, local-path, secrets encryption on) | Docker Compose | | HTTPS | cert-manager v1.21.2 + Let's Encrypt, staging first; HTTP → HTTPS redirect in Traefik with `allowACMEByPass` | certbot by hand | -| Images | GHCR (public package), tag = git SHA | Docker Hub | +| Images | GHCR, public package ([decision 0004](decisions/0004-public-container-images.md)), tag = git SHA | Docker Hub, a private package with a pull secret | | CI/CD | GitHub Actions: test → build → push → deploy over SSH as a `deploy` user with a forced command (port 6443 stays closed), then check `/version` | Self-hosted runner, opening 6443, Argo CD | | Observability | Slim kube-prometheus-stack + Loki + Alloy (Promtail is end-of-life); Grafana Cloud free tier as fallback | Datadog | | Backups | Nightly CronJob: `pg_dump` → **Amazon S3** (eu-north-1), one timed restore | Volume snapshots, R2, B2 | diff --git a/docs/decisions/0004-public-container-images.md b/docs/decisions/0004-public-container-images.md new file mode 100644 index 0000000..72d6975 --- /dev/null +++ b/docs/decisions/0004-public-container-images.md @@ -0,0 +1,47 @@ +# 0004. Our container images on GHCR are public + +- **Status:** Accepted +- **Date:** 2026-10-29 +- **Decided by:** Devinder Singh and Abdikhani Mohamed (asked for in the review of #12) + +## Context + +At every deploy, k3s on our VPS downloads ("pulls") our images from GHCR. A private image +needs a login: a GitHub token stored in the cluster. Our repository is public, so the code, +the Dockerfile and the lockfile that go into the image are already visible to everyone. + +Checked on 29 Oct: the package `relay-api`, published by our pipeline from this public repo, +was public from the start. An anonymous pull of `relay-api:` answers 200, while a +package that doesn't exist answers 403. + +## Decision + +Our images (`relay-api`, later `relay-dashboard`) stay **public**, and the cluster pulls them +without credentials. + +**The rule that comes with it:** an image may only contain what is already public in the repo: +our code, its dependencies and the base image. Never secrets, `.env` files, keys, or config +with passwords. Settings arrive at runtime, as environment variables and Kubernetes Secrets. +`.dockerignore` keeps `.env*` and the tests out, and only CI builds images, from a reviewed +commit on `main`. + +| Not chosen | Why | +|---|---| +| A private image + an `imagePullSecret` | It needs a GitHub token with `read:packages` stored as a Secret in namespace `relay` and referenced by every pod. That's a long-lived credential to rotate and protect, and the `deployer` account can't create Secrets, so an admin would manage it by hand. It would hide nothing that the public repo doesn't already show | +| Docker Hub | Anonymous pulls are rate-limited. GHCR sits next to our repo, and the pipeline logs in with the built-in `GITHUB_TOKEN` | + +## Consequences + +- No pull secret in the cluster: one credential fewer to create, rotate or leak. +- Anyone can pull and inspect our images. That's fine only while they contain nothing secret, + so the rule above is a hard rule: every Dockerfile change is reviewed against it. +- If an image ever has to contain something that isn't public, this decision is revisited for + that image (private, with a pull secret). +- Check that an image is still public (200 = public, 401 or 403 = private): + +```bash +TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:devinder-dev/relay-api:pull&service=ghcr.io" | jq -r .token) +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json" \ + https://ghcr.io/v2/devinder-dev/relay-api/manifests/ +```