[1.4.0] - 2026-10-19
Added
- Hugging Face Hub pull-through proxy (#579) — opt-in with
NORA_HF_ENABLED=true([hf] enabled = true);NORA_REGISTRIES_ENABLE=allleaves it off. WithHF_ENDPOINT=http://nora:4000/hf,hf download,hf_hub_downloadandsnapshot_downloadfetch public models through NORA: model info, revisions and tree listings on every request, and files through the Hub's redirects, which NORA follows itself so no client request leaves for the Hub or its CDN — only to the upstream's origin and, over https, to its CDN hosts ([hf] cdn_hosts, defaulthf.coandhuggingface.co); a redirect elsewhere is a 502. Files are cached per repository by the digest the Hub names them by (the sha256 of an LFS file, the git blob sha1 of a regular one): every file is checked against that digest on the way in, with its last chunk held back until it matches, and a weight file that does not change across commits is downloaded once. The Hub's answer that names the commit is asked on every download, somainnever goes stale. Xet is never proxied or advertised:xetHashis removed from metadata and Xet headers and links never reach the client, so downloads stay on plain HTTP. Curation names a download by its repository id and its commit, and an alias (gpt2) by its canonical id too (openai-community/gpt2);curation.min_release_agewith hf enabled is refused at start. Cached files count toward[gc] proxy_cache_max_bytes, and retention rules that namehfprune them (a*rule does not). Measured with huggingface_hub 0.36.0, 1.0.0, 1.33.0, 2.0.0, 2.1.1 and 2.2.0. Not in this release: datasets (501), private and gated repositories (a client's token is never sent upstream), uploads, and serving while the Hub is unreachable.
Changed
- Reproducible builds:
--lockedeverywhere, Swagger UI vendored (#1104) — the Dockerfile dropped thefuzzworkspace member withsed, which rewroteCargo.lockand meant no build could pass--locked, so aCargo.tomlchange merged without its lock update was re-resolved silently at image build time; the fuzz manifest is now copied (corpus stays out) and every cargo build, clippy, test and tarpaulin run in the Dockerfile and CI passes--locked.utoipa-swagger-uinow uses thevendoredfeature: the Swagger UI bundle served at/api-docscomes from a crates.io crate checksummed inCargo.lockinstead of a build-time GitHub download with no digest check. Contributed by @clain23. - Swagger UI served from files in the repository (#1125) —
/api-docsserved Swagger UI throughutoipa-swagger-ui, whose build unpacks the release zip with thezipcrate; on 2026-10-16 everyziprelease but 9.0.3 was yanked, so anything that resolves dependencies afresh failed,cargo install nora-registrywithout--lockedamong them. NORA now serves the same Swagger UI 5.17.14 files itself, byte for byte as theswagger-ui-distpackage ships them, under the same paths and content types;utoipa-swagger-ui,zipand eight more packages leave the build. The source maps and unused bundles embedded before are gone, and the release binary is about 9.5 MB smaller.
Deprecated
curation.modeleft at its default with proxy registries enabled is deprecated (#1135) — such a configuration serves every upstream package unchecked. NORA still starts, but now logs a warning on every start naming the proxy registries, andnora_config_deprecated{reason="curation_mode_implicit"}is 1 so an alert can see it. From 1.5.0 it stops at startup: setcuration.mode(NORA_CURATION_MODEor[curation] mode) tooffto keep serving upstream packages unchecked, or toauditorenforce. An explicitofflogs that upstream packages are served unchecked.
Fixed
- Dashboard uptime shows days (#1028) — a registry up for a week read
171h 12mbecause the uptime formatter only ever printed hours and minutes; it now adds a day component when there is one (7d 3h 12m) and leaves it off below a day (0h 12m), with the day/hour/minute units localized alongside the rest of the dashboard. The 5-second live-refresh script recomputes the same value with the same localized units and the same arithmetic, so the server-rendered and polled displays no longer disagree. Fixed by @0x62ash. - Keep reachable OCI referrers during Docker GC (#1079) — follow repository-scoped index and subject graphs, retain Docker schema 1 and OCI artifact blobs, and skip the Docker sweep when a present manifest cannot be read or parsed (the GC CLI then exits nonzero). A child an index lists but storage does not hold — a platform a pull-through cache never fetched — does not stop collection, and a blob stays protected within the grace window from the moment it is mounted into a repository until its referencing manifest lands. Fixed by @mludolph.
- A per-registry
min_release_ageapplies without a global one (#1098) — the minimum-release-age filter was built only whencuration.min_release_agewas set, so[curation.npm] min_release_age = "3d"(orNORA_CURATION_<REGISTRY>_MIN_RELEASE_AGEfor npm) on its own was dropped without a word: enforce mode refused to start with "no active control is configured", and with any other control NORA started and served packages younger than the configured age. The filter is now built when the global value or any per-registry value is set; the global value is the default for registries without their own, and a registry's own value wins in its registry. At startup a per-registry value is an active control in enforce mode when it applies; on an enabled proxy registry it needs a quarantine of that registry —curation.<registry>.quarantineor the globalcuration.quarantine— since a quarantine on another registry holds none of its downloads. A value that could never apply is refused naming its key: rpm and deb downloads are not checked by name or release date, and cpan downloads carry no release date. A value on a disabled registry is reported as unused and is not counted as a control. Every startup rule onmin_release_age— this one, the global one (#741) and the Hugging Face one — now judges the registries the server mounts, however they are enabled (NORA_REGISTRIES_ENABLE,[registries].enableor a registry's own flag): before, they read the per-registry flags before the enable-set was applied, so a registry left out of the set was judged as enabled and one added by it as disabled, andNORA_REGISTRIES_ENABLE=all,hfwith a globalmin_release_agestarted.NORA_HF_ENABLED,NORA_RPM_ENABLED,NORA_DEB_ENABLEDandNORA_CPAN_ENABLEDbeside an enable-set are now warned about like the other per-registry flags. Configurations that start on 1.3.6 and stop on this release, each naming the key: amin_release_agefor rpm, deb or cpan; a per-registry value on an enabled proxy registry without a quarantine of that registry (1.3.6 ignored such a value without a global one, and beside a global one accepted a quarantine on any registry); a globalmin_release_agewithout a quarantine while an enable-set mounts proxy registries whose own flags are off (NORA_REGISTRIES_ENABLE=allwith[npm] enabled = falseand the like); a global one with Hugging Face mounted byall,hf; andcuration.require_integritywithoutcuration.allowlist_path— the allowlist is its only reader, so it held nothing, and in enforce mode it counted as the one active control. One starts that 1.3.6 refused: a global value with only hosted registries mounted, raw among them, whose uploads it holds by their own date. With only a global value,curation explainand the startup log read as before. Hugging Face has no curation section of its own, so a setting for it —[curation.hf]in the config file, orNORA_CURATION_HF_MIN_RELEASE_AGE,NORA_CURATION_HF_QUARANTINEorNORA_CURATION_HF_QUARANTINE_TTL— stops startup with the reason instead of being ignored; Hugging Face models are held by repository id and commit in the allowlist and blocklist. - A scheduled GC or retention pass that takes files out of the store (orphans, metadata phantoms, files evicted by
[gc] proxy_cache_max_bytes, retention keys) marks the repository indexes stale, so the dashboard and/api/ui/statscount what is on disk right after the pass. They used to count the deleted files until a restart or the next write to that registry. A dry run changes nothing.nora_artifacts_totaland the per-registrynora_storage_byteswere set every 60 seconds from the repository index without rebuilding it, so after a restart they read 0, and after a write or a GC/retention pass they kept the old numbers until someone opened the dashboard or/api/ui/stats. The 60-second update now first rebuilds the index of each enabled registry that a restart, a write or a cleanup pass marked stale — one listing of that registry — and then sets the gauges; a registry nothing touched is read from memory with no storage request. TheGC scheduler: doneline counted only orphans in itsdeletedandbytes freed; it now also names the files and bytes evicted by[gc] proxy_cache_max_bytes, separately. A scheduled retention pass withdry_run = truewrote aretention-applyentry toaudit.jsonlalthough it deleted nothing; it now writesretention-dry-runwith the bytes an applied pass would free, and a pass that deletes still writesretention-apply. A dry GC pass logs what it would free and evict instead of "bytes freed". - Docker tag list no longer shows pull-statistics files as tags (#1132) — once a pulled image's download count had been saved,
GET /v2/<name>/tags/listreturned a phantom tag next to the real one ({"tags":["1.0.0","1.0.0.stats"]}), because the tag listing skipped.meta.jsonfiles but not the.stats.jsonfile that holds the pull count. Every place that lists Docker tags (the tags API, delete by digest, retention, the web UI and the repository index) now asks one check: a file undermanifests/is a tag only if its name is a valid tag and it is not a metadata or statistics file. Pull counts are still read from the statistics file as before; tags such asa.meta.b, which were hidden by the old filter, are now listed, and asha512:digest copy is no longer counted as a version in the UI. - A Hugging Face download no longer decides curation for the same commit twice (#1133) — when a download found the file in the cache just as another download was committing it (the file was already in place but its integrity pin was not yet recorded), NORA correctly fetched the file from the upstream again, but it ran the curation check for that file's commit a second time on the second fetch, so the curation
allowedcounter and the audit log recorded one extra decision for that one download. A commit that curation has already let through for a request is now not decided again during that request; the repository and the commit are each decided once, whether the file is served from the cache, fetched from the upstream, or fetched again after an unreadable cached copy. - npm retention keeps the surviving versions of a proxied package installable (#1139) — a
keep_lastorolder_than_daysrule that removed a cached tarball of a proxied package rebuilt its packument from the per-version documents that only published packages have, so the packument came back withversions: {}; with the upstream unreachable,npm installof the version retention kept failed withENOVERSIONS. Retention now edits the cached upstream packument instead: the versions it removed are dropped, every other version keeps its upstream document (dist.tarball,shasum,integrity), a dist-tag pointing at a removed version is dropped andlatestis re-pointed at the highest version left, and the stored upstream validators are cleared so the next revalidation fetches the full document. A package with published versions is regenerated from them as before, including when it also has tarballs cached from the upstream. - npm release age no longer restarts at every packument refetch (#1140) — with
min_release_ageset and upstream dates not trusted (NORA_TRUST_UPSTREAM_DATES=false, the default), an npm version's age was the modification time of the cached packument, and every refetch afterNORA_NPM_METADATA_TTL(300 s by default) rewrote that file — on a 304 too. A package that clients asked for more often than the minimum age never matured:npm installwas refused with "package is 0h old" indefinitely. NORA now records, per package, when it first saw each version listed in an upstream packument, and the age of a version counts from that moment: a refetch, 200 or 304, keeps it, and a version the upstream adds later starts at zero. The record sits in storage next to the cache (local and S3 alike), survives restarts, and is carried bynora backupandnora migrate; a packument cached before the upgrade starts the versions it lists at its last write time. A version no fetched packument has listed is judged as before, and the quarantine first-seen clock and trusted upstream dates are unchanged. - A delete on local storage no longer strips the pin of a copy written at the same moment (#1144) — a delete removed the file and then, after an await, its integrity pin, so a write of the same key that landed between the two steps lost the pin it had just recorded and the new copy was stored unpinned: the Hugging Face cache fetched it again, and other readers served it without checking it. The delete now holds the key's commit lock across both steps, as a write does. Object storage keeps the pin with the object and was not affected.
- A Docker client prints the reason of a curation refusal (#1151) — on
/v2/a curation refusal answered403with NORA's refusal body only, which Docker clients cannot read:docker pullprintederror parsing HTTP 403 response body: no error details foundand the reason was visible only inside a raw dump. The refusal now also carries the OCI distribution error the protocol requires ({"errors":[{"code":"DENIED","message":"blocked by policy: <reason>","detail":…}]}), sodocker pulland skopeo printdenied: blocked by policy: …. Every field of the refusal body and theX-Nora-*headers stay where scripts read them; other registries' bodies do not change.
Security
- A published crate's sparse index is never replaced by an upstream's (GHSA-5pr2-xx8w-79jc) — with
cargo.proxyset (the default, crates.io), a crate published to NORA had its index fetched from the upstream again once it was older thancargo.metadata_ttl(300 s by default), and the answer cached over it: a crate of the same name on the upstream replaced the published one for every client. A crate with versions published here is now served only from its own entries, and no upstream is asked for it; a download of a version it does not have is404, and nothing is fetched or stored (an upstream's bytes stored beside the published ones were listed as a version, and akeep_lastretention rule could delete the published version in their favour). Copies an older release already stored beside a published crate are left for the operator to delete. NORA keeps its copy of an upstream's index at a key of its own, so publishing a name the upstream also has goes through and never imports the upstream's versions. Action may be needed: a firstcargo publishover a stored index that an older release left without per-version entries beside it — an upstream crate it cached, or a crate published to NORA before per-version entries — is refused with 409 naming the key. If it is a cache, delete the key and publish again; if it is your own crate, setNORA_CARGO_IMPORT_EMBEDDED=<name>(comma-separated): NORA then serves that stored index as the crate's own and imports it at the next publish. Rollback to 1.3.x: published crates keep working; an upstream crate first fetched by 1.4.0 is fetched again by 1.3.x (its copy lives at a key 1.3.x does not read); do not run 1.3.x retention with a Cargo rule on the store, because it deletes the per-version entries (the defect fixed above). -and_name one Cargo crate, as on crates.io (GHSA-5pr2-xx8w-79jc) — withmy-cratepublished to NORA, a dependency writtenmy_cratewas looked up under that exact spelling, found nothing local and was fetched from the upstream, where anyone could have registered it. Another spelling of a published crate is now refused (404on the index and on downloads, naming the published spelling) and never asked of an upstream, and publishing it is a409. NORA records each crate's spelling undercargo/_names/once its first publish succeeds; for crates published before 1.4.0 the records are written at startup, before the server accepts requests (one listing of the published entries), and GC removes a record with its crate. Where both spellings of a name were already published, each keeps serving and accepting its own versions; a record naming a spelling that is no longer published decides nothing, and a published spelling of the name is looked up instead. NORA no longer asks an upstream for the release dates of a name it holds a record for. Multiple replicas on one store: the check that two spellings published at the same moment cannot both pass is per process. Rollback to 1.3.x: the records are ignored; nothing else changes.- Retention and GC no longer delete Cargo data they misread (GHSA-5pr2-xx8w-79jc) — with a
keep_lastrule for Cargo (or for*), retention read the per-version index entries undercargo/index-entries/as versions of a crate namedindex-entriesand deleted all but the newest: published versions dropped out of the sparse index at its next rebuild. Retention now counts only.cratefiles as versions. GC took the.cratefiles of one- and two-letter crate names for orphans (their index sits one directory higher) and deleted them; it now reads every index key of a crate, whatever the name's length. - A pushed Docker tag answers its tag, also after a pull through the proxy (GHSA-5pr2-xx8w-79jc) — a tag that had been pulled through an upstream before a local push kept answering with the upstream's manifest: the proxied copy was read first and the push did not remove it. A push now records itself in the tag's metadata and removes the proxied copy of that tag; a tag with a push record is answered by the pushed manifest only — also while a re-push of it is being written — and curation dates it by the push;
nora migrate-docker-keysleaves a repository with a recorded push where it is. Action may be needed: a tag pushed before 1.4.0 over a tag that was already proxied stays shadowed by the proxied copy until it is pushed again. - A Docker manifest requested by digest has that digest (GHSA-5pr2-xx8w-79jc) — a manifest fetched from an upstream for a digest reference was served and cached under the requested digest without comparing it with the bytes received, and a push by digest stored whatever bytes it carried. An upstream answer with another digest now counts as a failed upstream (the next upstream is tried, nothing is cached), a push by digest with other bytes is refused with
400 DIGEST_INVALID, and a stored copy that does not hash to its digest is neither served nor counted as cached. - Deleting a Docker manifest that cannot be read removes nothing (GHSA-5pr2-xx8w-79jc) — a delete of a pushed tag whose metadata could not be read (a storage error) answered
202, removed the tag and kept the pushed manifest under its digest, still pullable by digest; a delete by digest skipped tags it could not read, which kept serving the deleted manifest. Both now answer500and remove nothing, as a pull does. - A package published to NORA is never replaced by an upstream package of the same name (GHSA-pg2g-wvgf-cxpp) — once
npm.metadata_ttlexpired, the packument of a published package was revalidated from the upstream and replaced by the upstream's document, so a client resolving the name installed whatever the public registry had under it. A published package and a proxied one no longer share a key: a published package's packument, version manifests and dist-tags (/<name>,/<name>/<version>,/<name>/<dist-tag>,-/package/<name>/dist-tags) are answered from its own keys and never ask an upstream; what an upstream serves for a package NORA has not published is cached undernpm/<name>/upstream/. A published packument an older release replaced with an upstream copy is rebuilt from the published versions; a published version whose document cannot be read makes the packument answer 503 instead of silently dropping it. If NORA cannot list a package's published versions, it serves the packument it assembled, or, without one, the upstream's answer for that request without caching it. Other metadata paths, such asnpm search, are answered by the upstream and not cached (search results were cached regardless of the query). A packument an older release cached for an unpublished package is read until it is first revalidated and is never written, so a rollback to 1.3.6 still finds it. Action may be needed: a publish over a packument of unknown origin on a package with no published versions — an upstream package NORA 1.3.3 or older cached, or a package published to NORA 0.9.3 or older — is refused with 409 naming the key; delete the key if it is a cache, or setNORA_NPM_IMPORT_EMBEDDED=<name>([npm] import_embedded) if it is your own package. - An allowlist's pinned integrity holds on the first download and on every way a stored file is served (GHSA-5pr2-xx8w-79jc) — an allowlist entry's
integrity(the SHA-256 pinned for a version) was checked only when the artifact came from NORA's cache; the first download from the upstream was served and cached without the check, and aRangerequest for a stored file (gems, Maven, npm, PyPI) was served before it. It is now checked before the first download is served or cached, in every format that checked it on a cache hit (ansible, cargo, conan, cpan, docker manifests, gems, go, maven, npm, nuget, pub, pypi); a range of a pinned file is served only after the whole file is checked, and the stale-copy fallbacks of CPAN and Maven (a SNAPSHOT served when every upstream fails) check it too. nora curation explainanswers what the server would decide (GHSA-5pr2-xx8w-79jc) — explain built its own copy of the curation engine and, in enforce mode, still gave a decision for a configuration the server refuses to start with (a list that does not parse). It now uses the engine the server builds; when the server would refuse the configuration, explain says so and exits 78.- An empty upstream setting switches the upstream off (GHSA-5pr2-xx8w-79jc) —
NORA_GEMS_PROXY,NORA_NUGET_PROXY,NORA_CONAN_PROXY,NORA_TF_PROXY(Terraform),NORA_ANSIBLE_PROXYandNORA_CPAN_PROXYset to an empty string were read as "no upstream" by the configuration and the startup checks, but the handlers still fell back to the public default upstream (and Terraform provider files to releases.hashicorp.com). These formats now answer from storage alone when their upstream is off — search, metadata, files, and Terraform module sources stored while it was on included: a stored copy of any age is served, anything else is 404.proxy = ""in the config file now means the same as the empty environment variable. - A PyPI upstream's credential goes only to that upstream (GHSA-5pr2-xx8w-79jc) — the Basic credential of a PyPI upstream was sent with every file download, to whatever host the upstream's index named for the file. It now goes only to the upstream's own origin (scheme, host and port), as pip scopes a credential; a file on another host is fetched without it.
- A pub search for an internal name stays local (GHSA-5pr2-xx8w-79jc) — a search whose query string names a package matching
curation.internal_namespaces, in any parameter, case or spelling, now gets an empty result and is not sent to the upstream, as Conan's search already behaves. - PyPI release age reaches clients that read the HTML index (GHSA-5pr2-xx8w-79jc) — without
trust_upstream_dates, a file's date was the time NORA cachedpypi/<name>/dates.json, and only a request for the PEP 691 JSON index cached it, so a client reading the PEP 503 HTML index (pip before 22.2) downloaded with no date at all andmin_release_agelet every such download through. A download now caches the listing itself on a cold miss, so both kinds of client get the same date; a cache hit still never contacts the upstream. The cached listing also dates only the files it names: a release published after NORA cached it used to inherit the listing's age, which made it look old tomin_release_ageand seeded the quarantine with a first-seen time a month back; it now gets no date, and the quarantine records it as first seen now. Files an upstream lists without anupload-time, or from an upstream without PEP 691, are recorded too, so they keep their date. A listing an earlier version cached from such an upstream names no files, and those files get no date until it is removed. - The client address behind a reverse proxy is read from the right of
X-Forwarded-For(GHSA-5pr2-xx8w-79jc) — from a trusted proxy (loopback by default) NORA took the first address inX-Forwarded-Foras the client. A reverse proxy that appends to the header, as nginx does with$proxy_add_x_forwarded_for, keeps whatever the client sent at the left, so a client could choose the address the rate limiters, the failed-login lockout and the token routes keyed it by. NORA now takes the rightmost address that is not a trusted proxy, reading everyX-Forwarded-Forline in order. A proxy that replaces the header (Caddyheader_up X-Forwarded-For {remote}) sees no change. With more than one proxy in front of NORA, list every hop inNORA_AUTH_TRUSTED_PROXIES, or the nearest unlisted hop becomes the client address. - Deleting a Docker manifest on S3 removes it (GHSA-5pr2-xx8w-79jc) — on S3 storage,
DELETE /v2/<name>/manifests/<digest or tag>answered 202 but left the manifest served by digest until GC removed it as an orphan. The manifest of a pushed image is stored under its registry namespace, the delete fell back to the second key only when the first answered "not found", and S3 answers a delete of an absent object with success. A delete now removes every stored copy of the manifest, and a delete of an absent object answers "not found" on S3 as it does on local storage. - A Docker tag may not end in
.metaor.stats(GHSA-5pr2-xx8w-79jc) — a tag's manifest is stored beside its metadata files under those names, so a push, read or delete of tagv1.metareached tagv1's metadata. Such references are refused with 400 on every manifest route.
Install
# x86_64
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.0/nora-linux-amd64
chmod +x nora-linux-amd64
sudo mv nora-linux-amd64 /usr/local/bin/nora
# ARM64 (Apple Silicon, Graviton, Ampere)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.0/nora-linux-arm64
chmod +x nora-linux-arm64
sudo mv nora-linux-arm64 /usr/local/bin/noraDocker
docker pull getnora/nora:1.4.0| Variant | Image | Platforms |
|---|---|---|
| Alpine (default) | getnora/nora:1.4.0
| amd64, arm64 |
| RED OS | getnora/nora:1.4.0-redos
| amd64 |
| Astra Linux SE | getnora/nora:1.4.0-astra
| amd64 |
| GHCR | ghcr.io/getnora-io/nora:1.4.0
| amd64, arm64 |
DEB / RPM
# Debian / Ubuntu / Astra Linux (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.0/nora-amd64.deb
sudo dpkg -i nora-amd64.deb
# RHEL / Fedora / RED OS (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.0/nora-amd64.rpm
sudo rpm -i nora-amd64.rpmChangelog
See CHANGELOG.md