NORA Registry Protocol Compatibility
September 19, 2026 · View on GitHub
This document describes which parts of each registry protocol are implemented in NORA.
Legend: Full = complete implementation, Partial = basic support with limitations, Stub = placeholder, — = not implemented
Docker (OCI Distribution Spec 1.1)
| Endpoint | Method | Status | Notes |
|---|---|---|---|
/v2/ | GET | Full | API version check |
/v2/_catalog | GET | Full | List all repositories |
/v2/{name}/tags/list | GET | Full | List image tags |
/v2/{name}/manifests/{ref} | GET | Full | By tag or digest |
/v2/{name}/manifests/{ref} | HEAD | Full | Check manifest exists |
/v2/{name}/manifests/{ref} | PUT | Full | Push manifest |
/v2/{name}/manifests/{ref} | DELETE | Full | Delete manifest |
/v2/{name}/blobs/{digest} | GET | Full | Download layer/config |
/v2/{name}/blobs/{digest} | HEAD | Full | Check blob exists |
/v2/{name}/blobs/{digest} | DELETE | Full | Delete blob |
/v2/{name}/blobs/uploads/ | POST | Full | Start chunked upload |
/v2/{name}/blobs/uploads/{uuid} | PATCH | Full | Upload chunk |
/v2/{name}/blobs/uploads/{uuid} | PUT | Full | Complete upload |
Namespaced {ns}/{name} | * | Full | Two-level paths |
Deep paths a/b/c/name | * | — | Max 2-level (org/image) |
| Token auth (Bearer) | — | Full | WWW-Authenticate challenge |
| Cross-repo blob mount | POST | — | Not implemented |
| Referrers API | GET | — | OCI 1.1 referrers |
Proxy cache and tag freshness
- Digest references (
sha256:…) are content-addressed: cached forever, never revalidated. - Tag references on proxied images are revalidated against the upstream on
every pull by default (
docker.metadata_ttl = -1). A positivemetadata_ttl(seconds) is the staleness bound: within the window the cached tag is served without an upstream round trip; after it, the next pull revalidates.0or negative always revalidates. - If every configured upstream fails and
docker.serve_stale = true(default), the last cached manifest is served withx-nora-stale: true. - Locally pushed (hosted) images are authoritative: a pushed tag is served as-is and never revalidated against an upstream. Pushing to a name that would otherwise proxy pins that tag to the pushed copy — to keep a tag tracking the upstream, do not push to it.
Known Limitations
- Max 2-level image path:
org/image:tagworks,org/sub/path/image:tagreturns 404 - Large monolithic blob PUT (>~500MB) may fail even with high body limit
- No cross-repository blob mounting
- Registry-mirror caching depends on Docker's image store / distribution client, not the storage driver. Docker applies the wrong credentials to mirror endpoints — it sends the upstream registry's credentials to the mirror (and on the containerd path clobbers per-mirror
hosts.tomlcredentials), so an authenticated mirror pull fails and Docker falls back to the upstream, skipping the cache. Both the legacy and containerd code paths are affected (moby/moby#30880, #42022; upstream fix in progress in #52532). An unauthenticated mirror avoids it — set[auth] docker_anon_pull = trueto serve pulls without a login while still requiring auth on push (this is the Docker-specific switch; the generalanonymous_readdoes not open/v2/). For pull-through caching of non-Docker-Hub registries, the containerd image store additionally tags mirror requests with the?ns=upstream namespace (containerd hosts.md). (#578)
npm
| Feature | Status | Notes |
|---|---|---|
| Package metadata (GET) | Full | JSON with all versions |
Scoped packages @scope/name | Full | URL-encoded path |
| Tarball download | Full | SHA256 verified |
| Tarball URL rewriting | Full | Points to NORA, not upstream |
Publish (npm publish) | Full | Immutable versions |
| Unpublish | — | Immutable; use quarantine/blocklist to disable a version |
Dist-tags (latest, next) | Partial | Read from metadata, no explicit management |
Search (/-/v1/search) | — | Not implemented |
Audit (bulk npm7 / audits/quick npm6) | Full | Proxy repos: forwarded to upstream verbatim; internal-namespace names stripped/refused; anonymous-read eligible. Proxied packages only (no local advisory DB). (#597) |
| Upstream proxy | Full | Configurable TTL |
Maven
| Feature | Status | Notes |
|---|---|---|
| Artifact download (GET) | Full | JAR, POM, checksums |
| Artifact upload (PUT) | Full | Any file type |
| GroupId path layout | Full | Dots → slashes |
| SHA1/MD5 checksums | Full | Stored alongside artifacts |
maven-metadata.xml | Partial | Stored as-is, no auto-generation |
| SNAPSHOT versions | — | No SNAPSHOT resolution |
| Multi-proxy fallback | Full | Tries proxies in order |
| Content-Type by extension | Full | .jar, .pom, .xml, .sha1, .md5 |
Known Limitations
maven-metadata.xmlnot auto-generated on publish (must be uploaded explicitly)- No SNAPSHOT version management (
-SNAPSHOT→ latest timestamp)
Cargo (Sparse Index, RFC 2789)
| Feature | Status | Notes |
|---|---|---|
config.json | Full | dl and api fields |
| Sparse index lookup | Full | Prefix rules (1/2/3/ab/cd) |
| Crate download | Full | .crate files by version |
cargo publish | Full | Length-prefixed JSON + .crate |
| Dependency metadata | Full | req, package transforms |
| SHA256 verification | Full | On publish |
| Cache-Control headers | Full | immutable for downloads, max-age=300 for index |
| Yank/unyank | — | Not implemented |
| Owner management | — | Not implemented |
| Categories/keywords | Partial | Stored but not searchable |
PyPI (PEP 503/691)
| Feature | Status | Notes |
|---|---|---|
| Simple index (HTML) | Full | PEP 503 |
| Simple index (JSON) | Full | PEP 691, via Accept header |
| Package versions page | Full | HTML + JSON |
| File download | Full | Wheel, sdist, egg |
twine upload | Full | Multipart form-data |
| SHA256 hashes | Full | In metadata links |
| Case normalization | Full | My-Package → my-package |
| Upstream proxy | Full | Configurable TTL |
| JSON API metadata | Full | application/vnd.pypi.simple.v1+json |
| Yanking | — | Not implemented |
| Upload signatures (PGP) | — | Not implemented |
Go Module Proxy (GOPROXY)
| Feature | Status | Notes |
|---|---|---|
/@v/list | Full | List known versions |
/@v/{version}.info | Full | Version metadata JSON |
/@v/{version}.mod | Full | go.mod file |
/@v/{version}.zip | Full | Module zip archive |
/@latest | Full | Latest version info |
| Module path escaping | Full | !x → X per spec |
| Immutability | Full | .info, .mod, .zip immutable after first write |
| Size limit for .zip | Full | Configurable |
$GONOSUMDB / $GONOSUMCHECK | — | Not relevant (client-side) |
| Upstream proxy | — | Direct storage only |
Raw File Storage
| Feature | Status | Notes |
|---|---|---|
| Upload (PUT) | Full | Any file type; body streams to disk (O(frame) memory), so upload size is bounded by raw.max_file_size alone — server.body_limit_mb does not apply |
| Download (GET) | Full | Content-Type by extension |
| Delete (DELETE) | Full | |
| Exists check (HEAD) | Full | Returns size + Content-Type |
| Max file size | Full | Configurable raw.max_file_size (default 100MB), enforced incrementally mid-stream |
Conditional overwrite (If-Match) | Full | ETag-based, returns 200 on success |
Create-only (If-None-Match: *) | Full | Returns 412 if resource exists |
| Directory listing | — | Not implemented |
| Immutability | Full | Default; re-upload returns 409 unless conditional headers used |
RubyGems
Caching proxy for rubygems.org. Immutable gem/gemspec caching with TTL-based index refresh.
| Feature | Status | Notes |
|---|---|---|
Compact index (/info/{name}) | Full | TTL-cached |
Gem download (/gems/{name}-{ver}.gem) | Full | Immutable cache |
Gemspec (/quick/Marshal.4.8/...) | Full | Immutable cache |
Full index (specs.4.8.gz) | Full | TTL-cached |
Latest index (latest_specs.4.8.gz) | Full | TTL-cached |
| Gem push | — | Proxy-only (read) |
Client: bundle config mirror.https://rubygems.org http://nora:4000/gems/
Terraform
Caching proxy for registry.terraform.io. Provider binaries are immutably cached; metadata uses TTL.
NORA serves both Terraform protocols against the same upstream: the Registry Protocol
(origin-registry, service-discovery based) and the Network Mirror Protocol (what
network_mirror speaks).
| Feature | Status | Notes |
|---|---|---|
Service discovery (.well-known/terraform.json) | Full | Registry protocol; points to NORA |
| Provider versions list | Full | TTL-cached |
| Provider download metadata | Full | download_url rewritten to NORA |
| Provider binary download | Full | Immutable cache |
| Module versions list | Full | TTL-cached |
| Module download | Full | X-Terraform-Get header pass-through |
Network mirror — index.json | Full | Mirror protocol; list versions (#801) |
Network mirror — {version}.json | Full | Mirror protocol; archives via NORA + zh: hash (#801) |
| Provider publish | — | Proxy-only (read) |
Client (network mirror — Terraform requires an https: URL, trailing slash):
provider_installation { network_mirror { url = "https://nora.example.com/terraform/" } }
Limitations (accepted):
- Single upstream — the
{hostname}in a mirror request is validated but not routed; only the configuredterraform.proxyupstream's providers resolve. - Mirror-mode integrity is weaker than registry mode — in
network_mirrormode Terraform does not run the origin-registry GPG check, and NORA does not itself verifySHA256SUMS.sig. Archives carryzh:<sha256>from upstream metadata (fail-closed: a platform without a resolvable shasum is omitted). Upgrade path: verifySHA256SUMS.sigagainst a pinned HashiCorp key at ingest.
Ansible Galaxy (v3 API)
Caching proxy for galaxy.ansible.com. Collection tarballs are immutably cached.
| Feature | Status | Notes |
|---|---|---|
| API discovery | Full | /ansible/ and /ansible/api/ |
| Collection list | Full | Short v3 and Pulp-style paths |
| Collection detail | Full | URL rewriting |
| Collection versions | Full | Paginated |
| Version detail | Full | Curation checks |
| Tarball download | Full | Immutable cache, both /download/ and /artifacts/ paths |
| Tarball curation | Full | Blocklist/allowlist, integrity verification |
| Collection publish | — | Proxy-only (read) |
Namespace and collection name validation follows Galaxy spec ([a-z0-9_]+).
Client: ansible-galaxy collection install ns.name -s http://nora:4000/ansible/
NuGet (v3 API)
Caching proxy for api.nuget.org. Service index URLs are rewritten to point through NORA.
| Feature | Status | Notes |
|---|---|---|
Service index (/v3/index.json) | Full | @id URLs rewritten to NORA |
| Registration index | Full | TTL-cached |
| Version list (flat container) | Full | TTL-cached |
.nupkg download | Full | Immutable cache |
.nuspec download | Full | Immutable cache |
| Package push | — | Proxy-only (read) |
| Search | — | Not implemented |
Client: dotnet nuget add source http://nora:4000/nuget/v3/index.json -n nora
Pub (Dart/Flutter)
Caching proxy for pub.dev. Package archives are immutably cached with SHA256 verification.
| Feature | Status | Notes |
|---|---|---|
Package search (/api/packages?q=) | Full | Response URL rewriting |
Package metadata (/api/packages/{name}) | Full | archive_url rewritten to NORA |
| Version metadata | Full | Cached |
| Security advisories | Full | Cached |
Archive download (.tar.gz) | Full | Immutable cache, SHA256 verified |
| Package publish | — | Proxy-only (read) |
Client: export PUB_HOSTED_URL=http://nora:4000/pub && dart pub get
Conan (C/C++)
Caching proxy for ConanCenter (center2.conan.io). Recipe and package files are immutably cached (scoped to revision hashes). Metadata uses TTL-based caching.
| Feature | Status | Notes |
|---|---|---|
Ping (/v2/ping) | Full | Returns X-Conan-Server-Capabilities: revisions |
| Recipe search | Full | Proxied to upstream |
| Recipe latest revision | Full | TTL-cached |
| Recipe revision list | Full | TTL-cached |
| Recipe file list | Full | Immutable cache (revision-scoped) |
| Recipe file download | Full | Immutable cache |
| Package latest revision | Full | TTL-cached |
| Package revision list | Full | TTL-cached |
| Package file list | Full | Immutable cache (revision-scoped) |
| Package file download | Full | Immutable cache |
| Recipe/package upload | — | Proxy-only (read) |
| Authentication | — | Anonymous read only |
Client: conan remote add nora http://nora:4000/conan
RPM (yum/dnf)
Hosted repositories with server-generated repodata (createrepo-style). Each
/rpm/{repo}/ path is an independent repository; publishing or deleting a
package regenerates repodata/ (repomd.xml + sha256-named
primary/filelists/other.xml.gz). Package headers are parsed server-side — no
createrepo_c needed on the client.
Pull-through proxy repositories: map a repo name to one upstream yum repo
([rpm.proxies] fedora = "https://…/Everything/x86_64/os"). A proxied repo is
read-only (writes → 409); upstream metadata (repodata/, including the
upstream's signatures) is served verbatim within rpm.metadata_ttl seconds
(default 300), packages are cached forever, and a stale cache is served with
x-nora-stale: true when the upstream is down. nora mirror rpm --repo <name> pre-fetches every package for fully offline (air-gapped) clients.
| Feature | Status | Notes |
|---|---|---|
repodata/repomd.xml | Full | Regenerated on publish/delete, Cache-Control: no-cache |
primary.xml.gz | Full | name/evr/arch, provides/requires/conflicts/obsoletes with flags, primary file subset, header-range |
filelists.xml.gz | Full | All files with dir/ghost types |
other.xml.gz | Full | Changelogs (last 10 per package, configurable) |
Package publish (PUT {repo}/{name}.rpm) | Full | Header parsed and validated; invalid RPMs rejected |
Package delete (DELETE {repo}/{name}.rpm) | Full | Repodata regenerated |
| Package download | Full | Byte-identical, sha256 pkgid in repodata |
GPG-signed repodata (repomd.xml.asc) | Full | Signed on every regeneration; verify with repo_gpgcheck=1. Object-store backends (S3/GCS) require signing.key_path — without it signing is disabled with a startup warning |
Public key (repodata/repomd.xml.key) | Full | Armored, for gpgkey=; key auto-generated at first boot |
Package signatures (gpgcheck=1) | — | Packages are stored as uploaded; NORA signs metadata, not packages |
Reconcile (POST {repo}/-/reindex) | Full | Heals out-of-band storage changes: drops orphan metadata, adopts added packages, rebuilds + re-signs repodata |
| Upstream proxy | Full | Per-repo pull-through via [rpm.proxies]; digest quarantine via [curation.rpm] |
| Offline mirror | Full | nora mirror rpm --repo <name> [--arch x86_64,noarch] warms the full package set |
sqlite metadata (*_db) | — | XML metadata only (all modern dnf/yum versions) |
Delta RPMs (prestodelta) | — | Not generated |
Module metadata (modules.yaml) | — | Not generated |
Publish: curl -u user:pass -T pkg.rpm http://nora:4000/rpm/myrepo/pkg.rpm
Client .repo file:
[nora-myrepo]
name=NORA myrepo
baseurl=http://nora:4000/rpm/myrepo
enabled=1
gpgcheck=0
repo_gpgcheck=1
gpgkey=http://nora:4000/rpm/myrepo/repodata/repomd.xml.key
gpgcheck=0 stays: NORA signs the repository metadata, not the packages
themselves. With signing disabled (signing.enabled = false), also set
repo_gpgcheck=0.
Debian (APT)
Hosted repositories with server-generated indexes, in either apt layout
(chosen per package at publish): flat (indexes at the repo root, sources
line deb <url>/deb/{repo} ./) or structured (dists/{distribution}/
suites with components, sources line deb <url>/deb/{repo} jammy main).
Each /deb/{repo}/ path is an independent repository; publishing or
deleting a package regenerates every affected index. Package control
paragraphs are parsed server-side from the .deb
(ar → control.tar.{,gz,xz,zst}) — no dpkg-scanpackages needed.
Pull-through proxy repositories: map a repo name to one upstream apt repo
([deb.proxies] debian = "https://deb.debian.org/debian"). A proxied repo is
read-only (writes → 409); upstream indexes (dists/, including the upstream's
InRelease/Release.gpg) are served verbatim within deb.metadata_ttl seconds
(default 300), packages are cached forever, and a stale cache is served with
x-nora-stale: true when the upstream is down. nora mirror deb --repo <name> --dist <dist> pre-fetches every package for fully offline
(air-gapped) clients.
| Feature | Status | Notes |
|---|---|---|
Packages / Packages.gz | Full | Verbatim control paragraphs + Filename/Size/MD5sum/SHA1/SHA256 |
Release (flat repo) | Full | MD5Sum + SHA256 sections, Cache-Control: no-cache |
dists/ layout (suites/components) | Full | PUT ...?distribution=<dist>[&component=<comp>]; per-dist Release + {component}/binary-{arch}/Packages{,.gz}; arch-all packages folded into every concrete arch index; upload path is free-form (Filename is repo-root-relative, pool/ not required) |
Package publish (PUT {repo}/{name}.deb) | Full | Control parsed and validated; invalid debs rejected; decompression bombs bounded |
Package delete (DELETE {repo}/{name}.deb) | Full | Indexes regenerated; emptied distributions are removed |
| Package download | Full | Byte-identical |
InRelease (clearsigned) / Release.gpg (detached) | Full | Signed on every regeneration (per distribution in the structured layout); verify via signed-by |
Public key (pubkey.gpg) | Full | Armored, for the signed-by keyring; key auto-generated at first boot |
Reconcile (POST {repo}/-/reindex) | Full | Heals out-of-band storage changes: drops orphan metadata, adopts added packages (into the flat layout — placement is upload-time intent), rebuilds + re-signs indexes |
| by-hash | — | Not generated; index files are served Cache-Control: no-cache |
| Translations / Contents indexes | — | Not generated (proxied repos pass them through) |
| Upstream proxy | Full | Per-repo pull-through via [deb.proxies]; digest quarantine via [curation.deb] |
| Offline mirror | Full | nora mirror deb --repo <name> [--dist <dist>] [--component main] [--arch amd64] warms the full package set |
Publish (flat): curl -u user:pass -T pkg.deb http://nora:4000/deb/myrepo/pkg.deb
Publish (structured):
curl -u user:pass -T pkg.deb "http://nora:4000/deb/myrepo/pool/pkg.deb?distribution=jammy&component=main"
Client setup:
curl -fsSL http://nora:4000/deb/myrepo/pubkey.gpg -o /etc/apt/keyrings/nora.asc
# flat
echo "deb [signed-by=/etc/apt/keyrings/nora.asc] http://nora:4000/deb/myrepo ./" \
> /etc/apt/sources.list.d/nora.list
# structured
echo "deb [signed-by=/etc/apt/keyrings/nora.asc] http://nora:4000/deb/myrepo jammy main" \
> /etc/apt/sources.list.d/nora.list
With signing disabled (signing.enabled = false), use [trusted=yes]
instead of [signed-by=...].
CPAN
Caching proxy for www.cpan.org. Distribution tarballs are immutably cached; the package index uses TTL-based caching.
| Feature | Status | Notes |
|---|---|---|
Package index (02packages.details.txt.gz) | Full | TTL-cached |
Module list (03modlist.data.gz) | Full | TTL-cached |
Author directory (01mailrc.txt.gz) | Full | TTL-cached |
Distribution download (authors/id/*) | Full | Immutable cache |
| Distribution publish | — | Proxy-only (read) |
The web UI groups cached releases by parsing archive filenames with a local
adaptation of CPAN::DistnameInfo. It supports modern Dist-1.23 names and
legacy underscore, dot, and directly-attached version forms. .meta and
.readme sidecars are not fetched or parsed for grouping. Consequently, the
distribution page lists versions already cached by NORA; unparseable archive
names are omitted from the grouped author view. The exact rules and examples
are documented in the CPAN proxy design.
Client:
cpanm --from http://nora:4000/cpan Module::Name
Helm OCI
Helm charts are stored as OCI artifacts via the Docker registry endpoints. helm push and helm pull work through the standard /v2/ API.
| Feature | Status | Notes |
|---|---|---|
helm push (OCI) | Full | Via Docker PUT manifest/blob |
helm pull (OCI) | Full | Via Docker GET manifest/blob |
Helm repo index (index.yaml) | — | Not implemented (OCI only) |
Cross-Cutting Features
| Feature | Status | Notes |
|---|---|---|
| Authentication (Bearer/Basic) | Full | Per-request token validation; see OIDC note below |
| Anonymous read | Full | NORA_AUTH_ANONYMOUS_READ=true |
| Rate limiting (429 + Retry-After) | Full | tower_governor, per-IP, documented in OpenAPI |
| 405 Method Not Allowed + Allow | Full | RFC 9110 §15.5.6, multi-method routes return Allow header |
| Prometheus metrics | Full | /metrics endpoint |
| Health check | Full | /health |
| Swagger/OpenAPI | Full | /api-docs |
| S3 backend | Full | AWS S3, Ceph RGW. Basic storage works on any S3-compatible; multi-replica write-serialization has a caveat — see note below. |
| GCS backend | Full | Native Google Cloud Storage (storage.mode = "gcs"): Workload Identity / service-account JSON / ambient credentials; endpoint override for emulators and Private Google Access. Same single-writer caveat as S3 for rpm/deb publishing (in-process publish lock). Hash-pinning (at-rest integrity verification) works on every backend: the pin is the sha256 object metadata on S3/GCS. |
| Local filesystem backend | Full | Default, content-addressable |
| Activity log | Full | Recent push/pull in dashboard |
| Backup/restore | Full | CLI commands |
| Mirror CLI | Full | nora mirror for npm/pip/cargo/maven/docker/rpm/deb |
OIDC authentication transport
OIDC JWT tokens are validated only on the Bearer authentication path.
Docker, twine, and Maven clients transmit credentials via HTTP Basic, and the
Basic-auth handler recognises htpasswd passwords and opaque API tokens (nra_…)
but does not attempt OIDC JWT validation on the password field. To use OIDC
workload identity from CI, send the JWT as a Bearer token (e.g.
Authorization: Bearer <jwt>) rather than as a docker login password. For
docker login specifically, use an opaque API token created via the UI or
/api/tokens. (#853)
Storage backend notes
- Multi-replica write-serialization needs conditional-write/CAS. NORA's write lock
(
publish_lock) is safe under a single writer — one replica, or an RWO volume single-mounted so only one pod writes. Serializing concurrent writes across multiple replicas requires an object store with atomic conditional-write / compare-and-swap: AWS S3 and Ceph RGW provide it; Garage (no consensus layer) and SeaweedFS (immature) do not — on those, run single-writer. Not every "S3-compatible" backend is equivalent here.