Repository navigation
docs: decide that our container images stay public (ADR 0004) #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ``` | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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'smain: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:
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 gotinvalid token, because$TOKENwasn't set in their shell.