Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
47 changes: 47 additions & 0 deletions docs/decisions/0004-public-container-images.md
Original file line number Diff line number Diff line change
@@ -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:<commit>` 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/<commit sha>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This command is right, but it's fragile in two ways I ran into testing it.

It needs a tag that exists, and most commits don't have one. The push trigger is path-filtered to api/**, so #14, #15 and #17 built no image. Running this against today's main:

relay-api:7187e54…  (current main)  -> 404
relay-api:a8274c14… (#12's merge)   -> 200

Someone following this ADR would naturally pick the newest commit, get 404, and conclude the package had gone private.

404 isn't in the legend. The line above says 200 = public, 401 or 403 = private. But I also tried a package that doesn't exist at all, and that's 404 too — so 404 can't tell "no such tag" from "no such package", and neither case is what we're actually asking about.

The tags endpoint answers the real question without having to guess a tag:

TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:devinder-dev/relay-api:pull&service=ghcr.io" | jq -r .token)
curl -s -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/devinder-dev/relay-api/tags/list

Public answers with the tag list; private answers DENIED. I ran both halves anonymously just now and got:

{"name":"devinder-dev/relay-api","tags":["a8274c145d758708b59fba95011c8b1a1692a0ad"]}

Small related thing: keep the two lines joined with &&. I handed the second line to someone on its own and they got invalid token, because $TOKEN wasn't set in their shell.

```