Container Registry Integrations
The container registry integrations (quay, ghcr, atcr, gar) are
session-mint integrations: exe.dev holds a durable registry credential —
a robot account, a personal access token, an app password, or a service
account key — and your VM exchanges it for a short-lived registry token
without ever seeing it.
They all front the same thing: the standard OCI distribution token
flow, the
same dance docker login performs. The only exe.dev-specific part is the
hostname; scope grammar, response shape, and error codes are the vendor's.
How it works
-
GETthe registry's token realm through the integration hostname, choosing your own scope:curl "http://<name>.int.exe.xyz/v2/auth?service=quay.io&scope=repository:<org>/<repo>:pull" -
The proxy injects your stored credential as HTTP Basic. The realm path is the only path the integration proxies (path-gated), so the credential can only ever be presented to the registry's own token endpoint.
-
The vendor's response comes back to you verbatim. The
tokenfield is a short-lived registry token. -
Use it directly against the registry — those requests do not go through the integration:
curl -H "Authorization: Bearer $TOKEN" https://quay.io/v2/<org>/<repo>/manifests/latest -
When the token expires (the registry answers 401), mint a fresh one. React to the 401 rather than tracking TTLs.
The token realms are GET-only (POST returns 405) — this is the OCI
registry token endpoint, not an RFC 6749 OAuth token endpoint.
Clients
The minted token is a standard OCI registry bearer token; what varies is how each client accepts it:
-
curlwithAuthorization: Bearer $TOKEN(as above), -
skopeowith--registry-token $TOKEN(or--src-registry-token/--dest-registry-tokenforskopeo copy), -
crane/oras/ anything built on go-containerregistry or containerd resolvers: put it in a Docker config file asregistrytoken(below), -
dockeritself, via the little-knownregistrytokenfield — unlikeauths.auth(Basic) oridentitytoken, this field is sent asAuthorization: Bearerand skips the token dance entirely:{ "auths": { "quay.io": { "registrytoken": "<minted JWT>" } } }Point
DOCKER_CONFIGat a directory holding thatconfig.json(or merge into~/.docker/config.json) anddocker pull quay.io/<org>/<repo>works with nodocker login.
Two caveats with registrytoken: it is static — docker will NOT re-mint
when the token expires or when it needs a scope the token lacks, you get a
plain 401 and must refresh the file yourself (mint with every scope you
need: the scope parameter repeats). And credential helpers cannot supply
it — it lives only in the config file.
Alternatively, outside the integration entirely: docker login <registry>
with your own credentials on the VM. That places the durable credential on
the VM — exactly what the integration exists to avoid — but restores
docker's automatic re-mint behavior.
quay.io (quay)
- Credential: a robot account (
myorg+ci/ token), created under Organization or User Settings → Robot Accounts. Your own quay.io username and password also work. - Token realm:
/v2/auth. The minted token is an RS256 JWT, observed TTL 3600s, response fieldtoken. - Bad credentials are rejected 401
Invalid Username or Password. - Path-gate caveat: the
/v2/authprefix also matches data-plane paths of repositories whose namespace starts withauth(e.g./v2/authzed/...). Such requests carry the credential to quay.io itself — never to a third party — so this is a scope wrinkle, not an exposure. - Self-hosted Red Hat Quay / Project Quay: pass
--base-url. The realm path can differ per install; check theWWW-Authenticateheader returned byGET /v2/on your registry.
GitHub Container Registry (ghcr)
- Credential: a GitHub personal access token. Classic PAT with
read:packages(pluswrite:packagesto push) for private packages; a fine-grained PAT authenticates at the realm and can pull public packages. The realm authenticates by the PAT alone — any non-empty username passes (an empty one is rejected), thoughdocker loginwants your real one. - Token realm:
/token. - Important deviation: for PAT-authenticated mints, ghcr's
tokenis your PAT base64-encoded, not a short-lived scoped JWT. Treat every minted token as equivalent to the PAT itself and scope the PAT accordingly:read:packagesonly, noreposcope, with an expiry. (Anonymous scoped mints for public images do return opaque short-lived pull tokens.) - Bad credentials are rejected 403 (not 401).
atcr.io (atcr)
- Credential: your ATProto handle + a dedicated app password (create one in your PDS/Bluesky settings; never your account password).
- Token realm:
/auth/token. The response carries bothtokenandaccess_token(identical values) plusexpires_in/issued_at. - The JWT lives about 45 seconds — use it immediately and expect to re-mint often.
- The realm does not accept a minted JWT as the Basic password (401); only the app password authenticates.
Google Artifact Registry (gar)
-
Credential: a service account JSON key. The username is always the literal
_json_key(the integration hardcodes it — you supply only the key). Grant the SAroles/artifactregistry.readerto pull,roles/artifactregistry.writerto push. -
Paste the key as a single line: the downloaded key file is multi-line JSON whose newlines are only formatting, so flatten it losslessly with
tr -d '\n' < key.json(or pass--key=-and pipe the file on stdin from the CLI). -
Token realm:
/v2/token. Minted tokens are opaque (base64 JSON, not a JWT), observed TTL 43200s (12h). -
Regions: the default target is
us-docker.pkg.dev. For any other region pass--base-url— the multi-regionseurope-docker.pkg.dev/asia-docker.pkg.dev, or a regional host such asus-central1-docker.pkg.dev/europe-west1-docker.pkg.dev. One integration per region host; the image path is alwaysPROJECT/REPOSITORY/IMAGE. -
Pull (stock docker, zero config, no login):
docker pull <name>.int.exe.xyz/PROJECT/REPOSITORY/IMAGE:TAG -
Push works the same way — tag with the integration hostname and push; docker re-runs the token dance with
push,pullscope automatically:docker tag myimage <name>.int.exe.xyz/PROJECT/REPOSITORY/IMAGE:TAG docker push <name>.int.exe.xyz/PROJECT/REPOSITORY/IMAGE:TAGBlob uploads ride through the integration (GAR answers upload legs with relative
Locationheaders, which the client resolves against the integration hostname). -
Anonymous-mint caveat: GAR's realm answers unauthenticated mints with HTTP 200 and a degraded token (usable only for public repos). A 200 from the realm therefore does not by itself prove the key worked — the integration's verify sends the key and relies on GAR rejecting bad keys (401 for malformed, 404
NAME_UNKNOWNfor well-formed-but-unknown), both non-2xx. -
The realm also accepts
oauth2accesstoken:<OAuth2 access token>as Basic — the integration does not use this form (access tokens expire hourly; the_json_keyform is the durable one).