Zot
OCI-native container registry providing pull-through cache and private image storage.
Quick Reference
| Property | Value |
|---|---|
| URL | https://registry.ops.eblu.me |
| Local Port | 5050 |
| Data | ~/zot |
| Config | ~/.config/zot/config.json |
| LaunchAgent | mcquack.eblume.zot (unit nix-managed — indri §Maintenance Notes) |
Namespace Convention
| Path | Source |
|---|---|
registry.ops.eblu.me/docker.io/* | Cached from Docker Hub |
registry.ops.eblu.me/ghcr.io/* | Cached from GHCR |
registry.ops.eblu.me/quay.io/* | Cached from Quay |
registry.ops.eblu.me/blumeops/* | Private images |
Pull-Through Cache
When the k3s cluster pulls an image, containerd checks zot first. If cached, returns immediately. If not, zot fetches from upstream, caches it, then returns.
Security Model
OIDC authentication via authentik, with API key support for CI.
| Role | Permissions | Use case |
|---|---|---|
| Anonymous | read | Pull images without auth |
ci-artifacts group (ci-zot) | read, create | The base CI push identity — CI push for build-container (new tags only, no overwrite/delete); eblume/blumeops#1039 step 2b |
admins group | read, create, update, delete | Break-glass admin access |
ci-zot-talos, ci-zot-horkos | create, update on their own image path only | per-repo release-CI push keys (horkos#17 step 3; ci tier of eblume/blumeops#1039) |
horkos-zot | create, update on blumeops/cv only | the horkos publisher’s push identity for cv tarballs (horkos#17 step 4; horkos tier of eblume/blumeops#1039 step 2b), consumed by the horkos deployment via ESO, not by any repo CI |
CI authenticates with a zot API key generated from the ci-zot service account’s OIDC session. The key is stored in the Forgejo Secrets 1Password item (field ci-zot-api) and mirrored to blumeops-ci/ci-zot (api-key); workflows op read it at job time with BLUMEOPS_CI_OP_TOKEN — it is not a Forgejo Actions secret.
The per-repo ZOT_PUSH_API_KEY Actions secrets are declared in forge/actions-secrets.json and re-PUT from the same master fields by the values half of mise run forge-reconcile, so after a mise run zot-apikey-rotate of a per-repo identity, the next human mise run forge-reconcile re-syncs the secret the rotation already wrote (a no-op, not a second key).
The per-repo identities exist because a repo’s Forgejo Actions secrets are readable by anyone who can push to that repo, so each release CI’s key is scoped by zot accessControl to create+update on its own image path only. zot’s accessControl uses longest-match, so the per-path blumeops/talos, blumeops/horkos and blumeops/cv entries restate the base ** policies verbatim rather than inheriting them.
API Key Rotation
Every CI key expires after 90 days. Rotation is a command, not a browser session:
mise run zot-apikey-rotate ci-zot # or ci-zot-talos, ci-zot-horkos, horkos-zot
mise run zot-apikey-rotate ci-zot --dry-run # just prove the current key still workszot’s key-management endpoints accept an API key as basic-auth credentials,
so a live key mints its own successor. The identity table is the roster:
every principals.yaml entry carrying a credential: {kind: zot-apikey}
block (its master op path + sink) is an identity the task knows —
adding or retiring a zot identity is a roster edit, not an edit to the
script.
CI-tier cutover (complete, 2026-09-14). The per-repo release-CI push
identities moved through the tier-first scheme of eblume/blumeops#1039 to
the ci tier (authorized by merging to main): ci-zot-talos and
ci-zot-horkos, each verified by a release push under the new identity
(talos#224, horkos#32). The intermediate tier-first identities
(talos-zot / horkos-zot) are retired by this change: their blueprint
entries, the talos-artifacts group entry and its zot-app policy binding,
the accessControl grants, and the zot-apikey-rotate entries are gone from
the repo here. The live Authentik users and group and the talos-zot-api /
horkos-zot-api master fields are deleted by the ceremony after sync —
the worker stops managing them, but the deletes themselves are UI acts. The
horkos-artifacts group stays — it is the seat of the horkos publisher
identity horkos-zot, added by this PR: the blueprint user, the
blumeops/cv accessControl grant, the zot-apikey-rotate entry, and the
horkos ESO re-point to the horkos-zot-api master field (re-created by the
first rotation — until the ceremony mints it the ESO cannot refresh and the
pod keeps the previously synced key, so the pod must not be recycled in
that window). The cv-tier zot identity is retired by this change: its
blueprint user, the cv-artifacts group entry and its order-4 zot-app
policy binding, the blumeops/cv cv-artifacts accessControl grant, and
the zot-apikey-rotate entry are gone from the repo here; the live
user, group, and the identity’s zot API-key master field were deleted by
the ceremony after sync, with the identity’s zot API keys revoked first
(see the new “Retiring an identity” section).
ci-zot cutover (done, 2026-09-17). ci-zot is now the base CI push
identity: the flip PR (eblume/blumeops#1156, commit c2aba52f) moved
build-container.yaml to it, the ceremony renamed the consumer item and
rotated (build-container run 4359 pushed prowler as ci-zot), and this
cleanup retires the previous identity.
The task reads the current key from the Forgejo Secrets item (blumeops
vault; fields ci-zot-api, ci-zot-talos-api, ci-zot-horkos-api,
horkos-zot-api), mints a new one, proves the new key authenticates, writes the
master copy back, writes the consumer copy — blumeops-ci/ci-zot
(api-key) for ci-zot, the ZOT_PUSH_API_KEY Actions secret on
eblume/talos / eblume/horkos for the per-repo push identities
(ci-zot-talos / ci-zot-horkos) — and then revokes every other key the
identity holds
(--keep-others to skip). Any failure before the writes leaves the old key
valid and its consumer untouched. Key material never touches argv or the
terminal.
For horkos-zot the consumer is the master field itself: the horkos
deployment’s ESO reads Forgejo Secrets horkos-zot-api directly.
Because the horkos deployment has no reloader, the pod must be recycled
after a rotation to pick up the new key.
Rotate before expiry, not after: an expired key cannot mint, and the chain has to be re-seeded in the browser.
Bootstrap (first key, or a broken chain)
Needed once per new identity, or when a key expired before anyone rotated it.
This is the one step that stays in the browser: Authentik’s OIDC authorize
view requires a login event on the session, and a session created through
API-token impersonation (POST /api/v3/core/users/<pk>/impersonate/) has
none, so it bounces to the login flow. Browser impersonation works because it
rides on your own logged-in session.
The identity’s user must exist first — the blueprint worker creates it on ArgoCD sync. Check for it in the Authentik admin UI, not with --dry-run: for a fresh identity the master field in 1Password does not exist until the first rotation, so --dry-run fails on a missing field and says nothing about whether the user exists.
- In the Authentik admin UI, impersonate the identity (
ci-zot,ci-zot-talos,ci-zot-horkosorhorkos-zot) - Visit
https://registry.ops.eblu.meand click “SIGN IN WITH OIDC” - Navigate to
https://registry.ops.eblu.me/user/apikey, generate a key (any expiry — it is about to be retired), copy it - Stop impersonating, then hand the key to the task from the clipboard:
pbpaste | mise run zot-apikey-rotate ci-zot-talos --key-stdinpbpasteis macOS (gilbert). On ringtail the equivalent isnix shell nixpkgs#wl-clipboard -c wl-paste -n | mise run zot-apikey-rotate <identity> --key-stdin. That verifies the pasted key, mints the real one, stores it, syncs the consumer and revokes the pasted bootstrap key in one step.
Retiring an identity. zot keys its API-key table by the identity
string, not by the IdP user: deleting the Authentik user leaves its minted
keys in zot, and a new user with the same username re-attaches them (this
surfaced 2026-09-15 when the new horkos-zot publisher inherited the
deleted step-2 user’s 2026-09-13 key, a live credential whose 1Password
field was already gone). A retirement now runs through the principals
deletion queue (argocd/manifests/authentik/retiring.yaml): the entry’s
keys_revoked: true is the ordering rule — revoke the identity’s zot API
keys before deleting the Authentik user — enforced at delete time by
mise run reconcile-authentik-principals --delete --apply, which refuses a
zot identity without it (manage-authentik-principals). Two ways to take the key
listing: mise run zot-apikey-rotate <identity> --dry-run, but only before
the retire PR merges — that PR is what removes the identity from the rotate
table — or at any time against the raw endpoint with the stored key (curl -su '<identity>:<key>' https://registry.ops.eblu.me/zot/auth/apikey, then
DELETE .../zot/auth/apikey?id=<uuid> per key; this is what the 2026-09-16
cv-tier identity retirement used). Any future reuse of a username must start with a
key listing.
Related
- forgejo - Container build CI
- Cluster - Registry consumer
- authentik - OIDC identity provider
- harden-zot-registry - Security hardening guide
- service-versions - Version tracking for deployed services