Artifact Repositories

An artifact repository (also called a binary repository manager or package registry) is the storage system that sits between a build and everything that consumes its output: it holds the compiled jar, the Python wheel, the npm tarball, the Go module zip, the Debian .deb, the RPM, and the container image, and it serves them back — versioned, immutable, and access-controlled — to developers, CI jobs, and production deployers. The two dominant self-hosted products are JFrog Artifactory and Sonatype Nexus Repository, and both organize storage around the same three-way abstraction: repositories you publish to (local/hosted), repositories that cache an upstream (remote/proxy), and repositories that aggregate several behind one URL (virtual/group) (JFrog remote repositories; Sonatype Nexus). The repository is where the CI/CD load-bearing rule build once, promote the artifact becomes physical: the exact bytes a build produced are stored once, addressed by a unique coordinate, and promoted onward unchanged. This note covers the machinery of that storage — repository types, pull-through caching, retention, and immutability; the discipline of never re-tagging over a published version lives in Immutable Artifact Versioning, and the container-specific registry API lives in Container Image Registries.

Mental Model — Three Repository Types

The single most important idea is that “repository” is not one thing. Every serious repository manager distinguishes three kinds, and the whole design of a build’s dependency resolution flows from getting them straight (JFrog local/remote/virtual):

flowchart TD
    DEV["Developer / CI job<br/>points its client at ONE URL"]
    DEV --> V["<b>Virtual repo</b> (JFrog)<br/><b>Group repo</b> (Nexus)<br/>single logical endpoint<br/>aggregates the others"]

    V -->|"1 · resolve"| L["<b>Local repo</b> (JFrog)<br/><b>Hosted repo</b> (Nexus)<br/>YOUR published artifacts<br/>the ones you build"]
    V -->|"2 · resolve"| RC["<b>Remote-cache</b><br/>previously proxied<br/>upstream artifacts"]
    V -->|"3 · resolve"| R["<b>Remote repo</b> (JFrog)<br/><b>Proxy repo</b> (Nexus)<br/>caching proxy of an<br/>UPSTREAM registry"]

    R -.->|"on cache miss,<br/>fetch on demand"| UP["Upstream<br/>Maven Central · npmjs.org<br/>PyPI · proxy.golang.org<br/>Docker Hub · deb/rpm mirrors"]

    style L fill:#1a5,color:#fff
    style R fill:#a51,color:#fff
    style V fill:#25a,color:#fff

What it shows and the insight to take: a client (a developer’s mvn, npm, pip, go, apt, or docker command) is configured with one URL — the virtual (JFrog) or group (Nexus) repository. That aggregator resolves a request by walking its member repositories in configured order: local first (your own builds — fastest and authoritative), then the remote-cache (things already pulled from upstream), then the remote proxy itself, which reaches out to the real upstream only on a miss (JFrog virtual repositories). The insight: developers never point at Maven Central or npmjs.org directly. They point at your aggregator, which gives you a single choke point for caching, access control, and — critically for supply chain — a record of exactly which third-party bytes entered your builds.

The three types map cleanly across the two products, with only the names differing:

RoleJFrog ArtifactorySonatype NexusWhat it does
Publish targetLocal repositoryHosted repositoryStores artifacts your organization produces (Nexus)
Upstream cacheRemote repositoryProxy repositoryCaches an upstream registry, fetching on demand (JFrog)
AggregatorVirtual repositoryGroup repositoryPresents many repos under one URL with a resolution order (JFrog)

Mechanical Walk-through — Pull-Through Caching

The remote/proxy repository is the piece most worth understanding mechanically, because it is a proxy, not a mirror. JFrog states the distinction precisely: “Artifacts are not pre-fetched to a remote repository cache. They are only fetched (pulled) and stored (cached) on demand when requested by a client” (JFrog remote repositories). Nothing is copied ahead of time; the cache fills lazily as builds actually request things. This lazy pull-through is where the term pull-through cache comes from.

sequenceDiagram
    participant CI as CI job (mvn/npm/pip)
    participant V as Virtual repo
    participant RC as Remote-cache
    participant R as Remote repo (proxy)
    participant UP as Upstream (Maven Central)

    Note over CI,UP: First request — cache MISS
    CI->>V: GET junit/junit/4.13.2/junit-4.13.2.jar
    V->>RC: check local + remote-cache
    RC-->>V: not present
    V->>R: resolve via remote proxy
    R->>UP: GET junit-4.13.2.jar
    UP-->>R: 200 + bytes
    R->>RC: store in <repo>-cache
    R-->>CI: 200 + bytes

    Note over CI,UP: Second request — cache HIT
    CI->>V: GET same jar
    V->>RC: check remote-cache
    RC-->>CI: 200 (served from cache, no upstream hit)

What it shows and the insight to take: the first request for a third-party artifact pays a round-trip to the upstream and populates the cache; every subsequent request is served locally at LAN speed and never touches the internet again. The cached copy lives in an internal repository whose name is the remote repository’s name with -cache appended — you can address it directly at http://<host>/artifactory/<remote-name>-cache/<path> to read only what is already cached and skip the upstream freshness check (JFrog remote repositories). (Because -cache is reserved for this, Artifactory forbids remote repository names ending in -cache.) The payoff is threefold: speed (LAN vs WAN), resilience (a build still works when the upstream is down, if the artifact is cached), and governance (every foreign byte is recorded and scannable in one place).

Freshness: how the cache decides when to re-check upstream

A pure pull-through would go stale, so the proxy has to decide when a cached answer is still trustworthy. Immutable release artifacts (a specific junit-4.13.2.jar) never change, so they can be cached forever. But metadata — the index that says “what versions of junit exist” — does change as new versions are published upstream. Artifactory governs this with cache-period settings (JFrog remote repositories):

  • Metadata Retrieval Cache Period — how long metadata files are trusted before Artifactory re-checks upstream for newer versions. Default is 2 hours (7200 s) for most types, 6 hours for Docker/OCI/Helm.
  • Missed Retrieval Cache Period — how long a 404 Not Found is remembered, so a request for a nonexistent artifact does not hammer the upstream on every build. Default 1800 s; 0 disables negative caching.
  • Assumed Offline Period — after a connection failure, how long to wait before probing the upstream again (default 300 s), plus a per-repo Offline flag and a system-wide Global Offline Mode that turns every remote into a cache-only repository for air-gapped operation.

The distinction between caching an immutable file forever and re-validating mutable metadata on a timer is the heart of why proxies work: the thing you cache aggressively (release binaries) is exactly the thing that is guaranteed never to change — the immutability property that Immutable Artifact Versioning is built on.

Per-Format Repositories — One Tool, Many Package Types

A repository manager is format-aware: it does not just store bytes, it speaks each ecosystem’s native protocol and generates each ecosystem’s index metadata. Nexus and Artifactory both support the full spread — Maven, npm, PyPI, Docker/OCI, Go, apt (Debian), yum (RPM), Helm, NuGet, Cargo, Conan, and more (Sonatype Nexus). The point of a single tool is that one aggregator URL serves a polyglot monorepo or a fleet of services in different languages.

EcosystemArtifactClientUpstream proxiedIndex metadata the repo generates
Java/JVM.jar, .pommvn, gradleMaven Centralmaven-metadata.xml
Python.whl, .tar.gzpip, uvPyPIPEP 503 “simple” index HTML
Node.tgz tarballnpm, bun, pnpmregistry.npmjs.orgpackage.json version doc
Gomodule .zip + .info + .modgoproxy.golang.org@v/list, @latest (GOPROXY protocol)
ContainersOCI image (manifest + layers)docker, podmanDocker Hub, GHCROCI distribution /v2/ API
Debian.debaptdeb.debian.org mirrorsPackages, Release files
RHEL.rpmdnf, yummirror.centos.org etc.repodata/repomd.xml

Each row is a different wire protocol, but the repository-type model (local/remote/virtual) is identical across all of them. For Go specifically, the repository is a GOPROXY — the go command downloads immutable module snapshots through it, and the proxy caches them and serves them unchanged (Go modules reference). Container images are the one format important enough to get their own note — see Container Image Registries for the OCI distribution API and digest-addressing that back the Docker row.

Immutability and Retention

An artifact repository is only trustworthy if a coordinate resolves to the same bytes forever. Different ecosystems enforce this with different strictness, and the repository manager typically inherits and enforces the ecosystem’s rule:

  • Maven draws a hard line between release and SNAPSHOT versions. A release version (any version without the -SNAPSHOT suffix) is “unchanging” — once 1.0 is deployed to a release repository it cannot be overwritten, so a consumer of 1.0 always gets identical code. A SNAPSHOT is explicitly the mutable development version and can be re-deployed, with the repository storing timestamped copies (Maven getting started). Repository managers reflect this by configuring a repo to accept either releases or snapshots, rejecting a re-deploy of an existing release.
  • npm treats “registry data” as immutable: once bob@1.1.0 has been published, “no other package can ever be published with that name at that version. This is true even if that package is unpublished” (npm unpublish policy).
  • PyPI forbids filename reuse entirely — a deleted file’s name can never be uploaded again; the API returns “Filename has been previously used.” The stated rationale is that “a given distribution for a given release for a given project will always resolve to the same file, and cannot be surreptitiously changed one day by the project’s maintainer or a malicious party (it can only be removed)” (PyPI help).
  • Go ties immutability to a public checksum database: a module version is “an immutable snapshot,” and the hashes recorded in go.sum (and cross-checked against sum.golang.org) mean any attempt to serve different bytes for the same version is detected as a security error (Go modules reference).

Retention is the counterweight to immutability: because artifacts are never modified, they only accumulate, and storage grows without bound unless something prunes it. Repository managers offer retention / cleanup policies to bound the pile — typically by age (delete artifacts older than N days) or count (keep only the most recent N versions of an artifact). Artifactory’s Docker cleanup, for example, limits the number of tags per image and runs a garbage collector that “automatically removes unreferenced Docker layers — those no longer used by any image after tags are deleted or expire,” done “transparently and automatically, behind the scenes, without any downtime” (JFrog blog). Container storage in particular benefits from this because images share layers (see Container Image Layers and Copy-on-Write): deleting a tag does not free space until every image referencing a given layer blob is gone, so a mark-and-sweep garbage collector — not a simple delete — is required.

flowchart LR
    subgraph Retention decision
    A["Artifact in repo"] --> Q{"Immutable<br/>release?"}
    Q -->|"Yes — never mutate"| K{"Retention<br/>policy"}
    Q -->|"SNAPSHOT / dev"| M["May be overwritten;<br/>old timestamps prunable"]
    K -->|"age &gt; maxDays"| DEL["Mark for deletion"]
    K -->|"version count &gt; maxCount"| DEL
    K -->|"within policy"| KEEP["Keep"]
    DEL --> GC["Layer/blob GC:<br/>free only when<br/>zero references remain"]
    end

Caption: retention operates on top of immutability, not against it — you never edit a release, you only decide whether to keep or delete it wholesale, and shared-blob garbage collection reclaims space only when the last reference disappears.

Failure Modes and Common Misunderstandings

“A remote repository is a mirror.” No — it is a lazy proxy. Nothing is copied until a client requests it. If your upstream disappears and you never cached artifact X, artifact X is gone from your builds too. Teams that assume “we proxy Maven Central, so we’re safe from an outage” discover on the outage day that only the already-requested artifacts survived. If you need a guaranteed-complete offline copy, you must pre-warm the cache or use a true mirroring mode, not a proxy.

Trusting the upstream directly. Pointing pip or npm straight at PyPI/npmjs from CI means every build reaches out to the public internet, you have no record of what entered your builds, and you inherit the upstream’s availability. A proxy repository is a supply-chain control point: it can be scanned, access-controlled, and audited. This is the mechanism side of what DevSecOps and Supply Chain Security MOC governs on the policy side.

Resolution-order surprises. In a virtual/group repo, a name that exists both locally and upstream resolves to whichever member is ordered first — normally local wins (JFrog virtual repositories). This is deliberate and useful (your internal com.acme:widget shadows any public one) but it is also the dependency-confusion attack surface: if an attacker publishes a public package with your internal name and your resolution accidentally prefers the remote, a malicious version can slip in. The defense is scoping internal names and ordering local ahead of remote — a resolution-order decision made in the repository manager.

Re-deploying over a published version. Some repository managers allow overwriting a release by default unless configured otherwise. Doing so silently breaks the “same coordinate → same bytes” guarantee everyone downstream relies on. Why this is a genuine supply-chain footgun, and how immutable-tag settings prevent it, is the whole subject of Immutable Artifact Versioning.

Forgetting negative-cache windows. If a build asks for a version that does not exist yet upstream, the 404 may be cached (Missed Retrieval Cache Period). Publish that version upstream and your build can still see the stale 404 until the window expires — a confusing “it’s published but CI can’t find it” symptom with a one-line explanation.

Alternatives and When to Choose Them

OptionModelChoose when
JFrog ArtifactorySelf-hosted/SaaS, universal (all formats), local/remote/virtualYou want one tool for every language + deep JFrog Platform (Xray scanning, distribution); enterprise scale
Sonatype Nexus RepositorySelf-hosted/SaaS, universal, hosted/proxy/groupSame universal need, strong open-source heritage (Nexus OSS is free); Maven-centric shops
Cloud-native registries (GCP Artifact Registry, AWS CodeArtifact, Azure Artifacts, GitHub Packages)Managed, tightly integrated with the cloud’s IAM/CIYou are all-in on one cloud and want zero ops; IAM-native access control
Language-native only (a bare Go GOPROXY, a private PyPI via devpi, Verdaccio for npm)Single-format, lightweightYou only need one ecosystem and do not want a heavyweight universal manager
Container-only (Harbor, GHCR, Docker Hub)OCI registry, images (+ increasingly any OCI artifact)Your artifacts are overwhelmingly container images — see Container Image Registries

The honest trade-off: universal managers (Artifactory/Nexus) win when you are polyglot and want a single caching/governance choke point; cloud-native registries win when you value zero-ops and cloud-IAM integration over cross-cloud portability; single-format tools win for small, focused setups where a universal manager is overkill.

Production Notes

In practice the highest-value configuration is boring but decisive: make every developer and every CI runner resolve through your aggregator, never the public upstream. That one rule gives you a cache (fast, resilient builds), an audit trail (what third-party code entered which build), and a scanning hook (block known-vulnerable dependencies at the proxy). It is also the precondition for reproducibility — a build that pulls from a controlled, cached proxy is far more likely to produce the same result next month than one pulling live from the churning public internet.

The second high-value practice is separating local repositories by promotion stage or trust level — e.g. a libs-release-local that only ever receives promoted, blessed artifacts, distinct from a libs-staging-local for candidates. This makes Artifact Promotion Across Environments a repository move (or a property/tag change) rather than a rebuild, embodying Build Once Promote the Artifact. Container-image promotion is the same idea expressed with digests instead of file paths.

Finally, treat retention as a first-class policy, not an afterthought. Immutable artifacts accumulate forever; without age/count cleanup and layer garbage collection your storage bill and backup windows grow without bound. The tension to manage: prune aggressively enough to control cost, but never prune something a running production deployment still references — which is exactly why the garbage collector frees a shared layer only when its reference count hits zero (JFrog blog).

Uncertain

Verify: the exact default cache-period values (2 h metadata / 6 h Docker-OCI-Helm / 1800 s missed / 300 s offline) and the reserved -cache suffix behavior are quoted from the JFrog remote-repositories doc as fetched 2026-07-25; JFrog occasionally revises defaults between Artifactory releases. Reason: single-vendor doc, version not pinned on the page. To resolve: confirm against the Artifactory version in use (System → repository configuration UI shows the live defaults). Nexus’s per-format proxy cache TTLs differ and were not separately fetched. #uncertain

See Also