Skip to main content

API Endpoints Reference

Angos implements the OCI Distribution Specification v1.1 plus extension endpoints.


OCI Distribution API

Base path: /v2/

Comma-separated Accept header values are parsed and ordered by quality (q) before Angos uses them for upstream pull-through requests.

API Version Check

GET /v2/

Returns 200 OK if the registry is available. Used for authentication challenges.

Blobs

HEAD /v2/{namespace}/blobs/{digest}
GET /v2/{namespace}/blobs/{digest}

Check existence or download a blob by digest. A blob is visible only within namespaces that own it; a digest that exists in storage but is not linked to the requested namespace returns BLOB_UNKNOWN.

GET supports a single byte range through the Range header:

  • Range: bytes=<start>-<end> returns 206 Partial Content.
  • Range: bytes=<start>- returns from <start> through the end of the blob.
  • Range: bytes=-<suffix-length> returns the final <suffix-length> bytes.
  • If <end> is beyond the blob length, Angos clamps it to the final byte.
  • If <suffix-length> is longer than the blob, Angos returns the full blob as 206 Partial Content.
  • Multiple ranges are not supported; Angos ignores them and returns the normal full 200 OK response.
  • A range whose start is at or beyond the blob length, or a zero-length suffix range, returns 416 Range Not Satisfiable.
  • For an empty blob, Angos ignores a syntactically valid range and returns the normal 200 OK empty body.

Range requests for pull-through repositories are supported only after the blob is available locally. A range request for an uncached pull-through blob returns 416; request the full blob first to populate the cache.

DELETE /v2/{namespace}/blobs/{digest}

Delete a blob owned by the namespace. If the digest is still referenced by manifest metadata in that namespace, Angos returns DENIED and leaves the blob unchanged. After those references are removed, deleting the blob removes that namespace's ownership; the underlying blob data is removed only when no namespace references the digest.

Blob Upload

POST /v2/{namespace}/blobs/uploads/

Start a new blob upload. Returns 202 Accepted with a Location header pointing at the upload session, or 201 Created when the blob is already available (see digest / mount below).

Query parameters:

  • digest - Return the existing blob (returns 201 Created) only when the requested namespace already owns it; otherwise start a new upload session.

  • mount (with optional from) - Cross-repository blob mount. ?mount={digest} requests that an existing blob be referenced by the target namespace with no body transfer:

    • With from: the mount succeeds when the blob exists, is held by {repository}, and the caller is authorized to read it from there.
    • Without from (automatic content discovery): the mount succeeds when the blob exists and the caller is authorized to read it from a namespace that already references it.

    On success the server returns 201 Created with the blob Location. When the blob cannot be mounted (absent, not held by the named source, or not readable by the caller), the server falls back to a normal upload session (202 Accepted). A mount request never fails for this reason. The session fall-back covers unsatisfiable mounts only: a syntactically malformed ?digest=, ?mount=, or ?from= value returns 400 Bad Request.

    Authorization. A mount only grants a reference to a blob the caller could already read: the server evaluates the caller's read access (get-blob) against the source namespace (the from repository, or for a from-less mount a namespace that references the blob) and falls back to a normal upload session when none is readable, so a mount never hands over a blob the caller could not otherwise pull. A mount is also its own route and CEL action, mount-blob, distinct from start-upload, so you can additionally restrict who may mount at all with a request.action == 'mount-blob' rule (denying it rejects the mount; Angos's replication falls back to a normal upload). Container clients send ?mount= opportunistically on push, so a default-deny policy should grant mount-blob alongside start-upload or those pushes fail. See Restrict cross-repository blob mount.

GET /v2/{namespace}/blobs/uploads/{uuid}

Get upload status.

PATCH /v2/{namespace}/blobs/uploads/{uuid}

Upload a chunk. Use Content-Range for the chunk range and Content-Length for the exact chunk size. A missing Content-Length is accepted as a chunked upload streamed to EOF; an invalid Content-Length returns 400 Bad Request. An upload whose cumulative size exceeds global.max_blob_size is rejected with BLOB_UPLOAD_INVALID (HTTP 413).

PUT /v2/{namespace}/blobs/uploads/{uuid}?digest={digest}

Complete the upload with final digest. A final chunk sent with a Content-Length uses that chunk size; a final chunk sent chunked (a body without Content-Length) is streamed to EOF. With no body, the upload is treated as zero length.

DELETE /v2/{namespace}/blobs/uploads/{uuid}

Cancel an upload.

Manifests

HEAD /v2/{namespace}/manifests/{reference}
GET /v2/{namespace}/manifests/{reference}

Check existence or download a manifest. {reference} can be a tag or digest.

PUT /v2/{namespace}/manifests/{reference}

Push a manifest. Manifest bodies larger than global.max_manifest_size are rejected with MANIFEST_INVALID. When global.allow_missing_manifest_references = false, config, layer, and child manifest digests referenced by the manifest must already exist and be readable in the namespace, and missing references are rejected with MANIFEST_BLOB_UNKNOWN. By default (allow_missing_manifest_references = true) the push is accepted, but a referenced digest the namespace does not already own is not made readable: it resolves as unknown on a later pull (BLOB_UNKNOWN for a blob, MANIFEST_UNKNOWN for a child manifest) until its content is pushed. Subject digests used for referrers are not required to exist in either mode.

When pushing by digest, one or more ?tag= query parameters create the listed tags pointing at the pushed manifest, for example PUT /v2/{namespace}/manifests/{digest}?tag=1.2.3&tag=latest. Each value must be a valid tag; a value that is not a valid tag is rejected at routing with a generic 400, like any other malformed reference. The response carries an OCI-Tag header listing the accepted tags, comma and space separated. Tag query parameters on a by-tag push are ignored.

DELETE /v2/{namespace}/manifests/{reference}

Delete a manifest by tag or digest. Deleting by tag removes only that tag. Deleting by digest also removes tags pointing at the digest and removes the manifest body when no remaining namespace references it. Config and layer blobs remain owned by the namespace until they are deleted through the blob endpoint or scrubbed as orphans.

Replication request header

Manifest PUT and DELETE accept an optional replication header, set automatically by Angos when mirroring a change to a configured downstream (it is not used by ordinary clients):

HeaderValuePurpose
X-Angos-Source-Timestampevent timestamp (RFC 3339)Last-writer-wins: the receiver compares it against the creation time of the affected tags and rejects the write with 409 REPLICATION_SUPERSEDED when the local copy is strictly newer.

Last-writer-wins applies only when X-Angos-Source-Timestamp is present and parses as RFC 3339, and is always evaluated against tag creation times. A tag PUT or DELETE is compared against the local tag's recorded creation time. A DELETE by digest cascades to every tag pointing at the revision, so it is guarded through those tags: when any pointing tag is strictly newer than the incoming timestamp, the whole delete is rejected with 409 REPLICATION_SUPERSEDED: the newer tag, and the revision it still references, must not be dropped by the older delete. A PUT by digest is content-addressed (there is nothing to resolve) and is not LWW-guarded. A missing, empty, or malformed timestamp simply disables LWW for that request: the write is applied as an ordinary client write rather than failing. A local tag with no recorded creation time is treated as oldest and never blocks the incoming write.

A future-dated timestamp is clamped to the receiver's current time, so a client cannot pin a permanent last-writer-wins victory. A backdated timestamp is accepted and persisted verbatim as the tag's creation time, where it weakens that write in later LWW races and feeds age-based retention and the top_pushed ranking with the supplied date. The header is honored from any identity allowed to push and cannot be gated separately from put-manifest, so a push-capable identity can backdate a tag far enough to make it eligible for pruning on the next scrub --retention run, an indirect delete even without a delete-manifest grant. Treat push on a replicated repository as trust over created_at, and restrict it via the CEL access_policy (see Restrict replication writes).

A 409 REPLICATION_SUPERSEDED is convergence, not failure: the sender treats it as success and completes the replication job. It is distinct on the wire from the immutable-tag 409 CONFLICT, which surfaces so the job retries or dead-letters.

Tags

GET /v2/{namespace}/tags/list

List tags for a namespace.

Query parameters:

  • n - Maximum number of results
  • last - Pagination marker

Catalog

GET /v2/_catalog

List repositories.

Query parameters:

  • n - Maximum number of results
  • last - Pagination marker

The returned names are derived directly from stored content: a namespace is listed exactly when it holds at least one revision or tag, and stops being listed as soon as the last one is deleted.

Referrers

GET /v2/{namespace}/referrers/{digest}

List manifests that reference a subject digest.

Query parameters:

  • artifactType - Filter by artifact type

Extension API (not part of the OCI specification)

Base path: /_ext/

Migration note: These endpoints moved from /v2/_ext/ to the top-level /_ext/ prefix in 1.2.0, so /v2 is reserved for the OCI Distribution API. Clients written against v1.1.1 must update any /v2/_ext/... paths to /_ext/....

List Repositories

GET /_ext/_repositories

List all configured repositories with namespace counts.

Response:

{
"repositories": [
{
"name": "library",
"namespaces": 15,
"is_pull_through": true,
"immutable_tags": true
}
]
}

List Namespaces

GET /_ext/{repository}/_namespaces

List namespaces within a repository.

Response:

{
"namespaces": [
{
"name": "nginx",
"manifests": 25,
"uploads": 0
}
]
}

List Revisions

GET /_ext/{namespace}/_revisions

List all manifest revisions with tags and parent relationships.

Response:

{
"revisions": [
{
"digest": "sha256:abc123...",
"media_type": "application/vnd.oci.image.index.v1+json",
"tags": ["latest", "1.25.0"],
"parent": null,
"pushed_at": 1703123456,
"last_pulled_at": 1703200000
}
]
}

List Uploads

GET /_ext/{namespace}/_uploads

List blob uploads in progress.

Response:

{
"uploads": [
{
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"size": 1048576,
"started_at": 1703123456
}
]
}

List Jobs

GET /_ext/_jobs

List pending and in-flight jobs on a durable job queue (see Enable Durable Cache Jobs, which introduces the queues and this admin API).

Query parameters:

  • n - Maximum number of results (default 100)
  • after - Pagination cursor: the next value from the previous page
  • queue - Queue to administer: cache (default) or replication

An unknown queue value, or any malformed query value (for example a non-numeric n), rejects the request rather than silently falling back to the default cache queue: the GET listings return 404 and the retry/delete mutations return 400.

Like the cross-repository blob mount, job administration uses its own CEL actions (list-jobs, list-failed-jobs, retry-job, and delete-job) so it can be gated behind higher privilege than registry reads; queue is exposed to CEL so the replication queue can be gated separately.

Response:

{
"jobs": [
{
"storage_key": "0000019700a1b2c3-123e4567-e89b-12d3-a456-426614174000",
"id": "123e4567-e89b-12d3-a456-426614174000",
"kind": "cache.fetch_blob",
"lock_key": "cache.library/nginx:sha256:abc123...",
"attempts": 1,
"max_attempts": 5,
"created_at": "2026-01-01T12:00:00Z",
"not_before": "2026-01-01T12:05:00Z"
}
],
"next": "0000019700a1b2c3-123e4567-e89b-12d3-a456-426614174000"
}

not_before is the earliest instant a worker may pick the job up, decoded from the storage key's time prefix. next is present only when another page follows; pass it back as after.

List Failed Jobs

GET /_ext/_jobs/failed

List dead-lettered jobs, i.e. jobs that exhausted their retry budget. Same query parameters and rejection rules as GET /_ext/_jobs.

Response:

{
"failed": [
{
"storage_key": "0000019700a1b2c3-123e4567-e89b-12d3-a456-426614174000",
"id": "123e4567-e89b-12d3-a456-426614174000",
"kind": "replication.push_manifest",
"lock_key": "replication.push.backup:library/nginx:latest",
"attempts": 5,
"max_attempts": 5,
"created_at": "2026-01-01T12:00:00Z",
"failed_at": "2026-01-01T12:30:00Z",
"last_error": "..."
}
],
"next": "0000019700a1b2c3-123e4567-e89b-12d3-a456-426614174000"
}

Retry Failed Job

POST /_ext/_jobs/failed/{key}/retry

Requeue a dead-lettered job with its attempt counter reset to zero. {key} is the job's storage_key from the failed listing. Accepts ?queue= like the listings. Returns 204 No Content on success, or 404 when the key no longer exists.

Delete Job

DELETE /_ext/_jobs/failed/{key}
DELETE /_ext/_jobs/pending/{key}

Delete a dead-lettered or pending job by storage_key. Accepts ?queue= like the listings. Returns 204 No Content on success, or 404 when the key no longer exists.


Health and Metrics

Health Check (Liveness)

GET /healthz

Returns 200 OK if the service is running. Use this for Kubernetes liveness probes to detect hung processes.

Readiness Check

GET /readyz

Returns 200 OK if the storage backend is healthy and ready to handle requests. Checks accessibility of the blob store, metadata store, and lock backend.

Use this for Kubernetes readiness probes to detect when a replica is unable to serve traffic.

Success Response:

{"status":"ready"}

Service Unavailable Response (503):

{"status":"not_ready","error":"storage backend not ready: ..."}

Prometheus Metrics

GET /metrics

Returns metrics in Prometheus exposition format.


Web UI

When the UI is enabled, non-API paths serve the web interface.

UI Routes

RouteDescription
/Repository list
/{repository}Namespace list
/{repository}/{namespace}Manifest list
/{repository}/{namespace}:{tag}Manifest details by tag
/{repository}/{namespace}@{digest}Manifest details by digest

UI Configuration

GET /_ui/config

Returns UI configuration.

Response:

{
"name": "My Container Registry"
}

Authentication

All endpoints (except /healthz and /readyz) require authentication when access policies are configured.

Methods

Basic Authentication:

Authorization: Basic base64(username:password)

Bearer Token (OIDC):

Authorization: Bearer <jwt-token>

OIDC via Basic Auth (Docker compatibility):

Authorization: Basic base64(provider-name:jwt-token)

Authentication schemes are parsed case-insensitively, so basic and bearer are accepted the same as Basic and Bearer.

When the username matches an OIDC provider name, the password is validated as a JWT token. This enables Docker clients to authenticate with OIDC tokens:

echo "$OIDC_TOKEN" | docker login registry.example.com \
--username github-actions --password-stdin

mTLS:

Present a client certificate during TLS handshake.

Authentication Flow

  1. Client makes unauthenticated request
  2. Server returns 401 Unauthorized with WWW-Authenticate header
  3. Client retries with credentials
  4. Server validates and processes request

Error Responses

Errors follow OCI Distribution error format:

{
"errors": [
{
"code": "MANIFEST_UNKNOWN",
"message": "manifest unknown",
"detail": "sha256:abc123..."
}
]
}

Error Codes

CodeHTTP StatusDescription
BLOB_UNKNOWN404Blob does not exist
BLOB_UPLOAD_INVALID400Invalid upload
BLOB_UPLOAD_UNKNOWN404Upload session not found
DIGEST_INVALID400Invalid digest format
MANIFEST_BLOB_UNKNOWN404Manifest reference is missing
MANIFEST_INVALID400Invalid manifest content
MANIFEST_UNKNOWN404Manifest does not exist
NAME_INVALID400Invalid repository name
NAME_UNKNOWN404Repository not found
SIZE_INVALID400Size mismatch
TAG_INVALID400Invalid tag
CONFLICT409Write rejected, for example, an immutable tag cannot be overwritten
REPLICATION_SUPERSEDED409Replication write rejected by last-writer-wins (the local copy is strictly newer)
UNAUTHORIZED401Authentication required
DENIED403 or 405Access denied by policy, or blob is still referenced
UNSUPPORTED415Unsupported operation