Language package proxies (PyPI and npm)

September 8, 2026 · View on GitHub

Terrapod proxies PyPI and the npm registry, so a run can resolve its dependency closure without reaching the internet. Point a runner at them and pip install and npm install work inside a sealed network.

# The runner's environment
PIP_INDEX_URL: https://terrapod.example.com/api/v1/package-cache/pypi/simple
NPM_CONFIG_REGISTRY: https://terrapod.example.com/api/v1/package-cache/npm/

Why this exists

Terrapod already caches engine binaries, providers and container images, and the promise those add up to is that a run needs no upstream reach. Language dependencies are the gap in that promise, and specifically Pulumi's: a Pulumi program's package.json or pyproject.toml belongs to you and differs per workspace, so unlike an Ansible collection it can never be baked into a runner image. npm install happens at run time and has to reach a registry.

PyPI earns its place twice over. It also serves Ansible collections' Python dependencies, and ansible-builder when an operator builds execution environments inside the sealed network — the image needs nothing at run time, but building it runs pip install.

Using it

Both proxies require authentication, including for reads. Any Terrapod credential works.

pip sends Basic auth, so the credential goes in the index URL or .netrc:

pip install --index-url "https://any:$TERRAPOD_TOKEN@terrapod.example.com/api/v1/package-cache/pypi/simple" flask

npm sends a bearer token, configured in .npmrc:

registry=https://terrapod.example.com/api/v1/package-cache/npm/
//terrapod.example.com/api/v1/package-cache/npm/:_authToken=${TERRAPOD_TOKEN}

A runner's own short-lived token works for both, so a run authenticates as itself rather than carrying a shared credential.

Why reads need a credential

An unauthenticated package proxy is an open bandwidth relay for anyone who finds it, serving strangers' installs from your storage and your egress. The list of what you have cached is also a fair description of what your estate runs.

Integrity

The proxies do not re-hash or re-sign anything, and are deliberately not a trust boundary.

Only the URL in an index is rewritten. Upstream's own integrity metadata — npm's dist.integrity sha512 SRI, PyPI's #sha256= link fragments — is passed through exactly as published. Since Terrapod serves byte-identical content, your client checks our bytes against the digest the package author published, and a corrupted or substituted artifact fails at the client where it should.

Air-gapped operation

Set registry.cache_only: true and Terrapod never attempts an upstream request for anything, these proxies included.

A sealed node serves an index of what it actually holds, rather than upstream's. That distinction is the whole feature: an upstream index lists versions whose files are not present, the client resolves to one of them, and the install dies on an error it can do nothing about. An index restricted to cached artifacts resolves to something installable.

For npm this means the packument is cached alongside the tarballs it describes — a sealed node cannot fetch one, and without it npm has no dependency ranges and cannot resolve at all — and is then filtered to the versions actually held, dist-tags included.

Asking a sealed node for something it does not have returns a 404 that names the setting:

requests is not cached and this node is sealed (registry.cache_only).
Warm it before sealing.

That is deliberate. A bare 404 sends a developer looking for a package that exists, and a timeout sends an operator to look at their firewall.

Warm before you seal. Run the installs you expect to need with cache_only off — pointing at an internal mirror if you have one — then seal.

Configuration

ValueDefaultWhat it does
api.config.registry.package_cache.enabledtrueServe the proxies.
...package_cache.pypi.enabled / .npm.enabledtruePer ecosystem. Disabling 404s the endpoints; cached artifacts stay and reappear if re-enabled.
...package_cache.pypi.upstreamhttps://pypi.orgWhere to pull through to. Point it at an internal mirror to cache that instead.
...package_cache.npm.upstreamhttps://registry.npmjs.orgAs above.
api.config.artifact_retention.package_cache_retention_days30Days since last access before an artifact is eligible for cleanup. Skipped entirely on a sealed node, where an evicted artifact cannot be re-fetched.
api.config.registry.cache_onlyfalseSeals every cache, these included.

The upstream is a single operator-set URL rather than a client-chosen host. A client names a package, never a registry, so this is the whole of the request-forgery surface and you own it.

One setting worth getting right

Set api.config.external_url. npm requires an absolute dist.tarball URL, and without external_url Terrapod has to infer its own address from forwarded headers. That works, but the operator's explicit answer is always better than an inference — and it is the only one a client cannot influence.

PyPI needs no such setting: the simple index resolves relative URLs against the index page, so those links are correct by construction.

What is not here yet

GOPROXY and NuGet. Both are small additions on the same substrate, for Pulumi Go and .NET programs.

A warm-ahead API. Warming today means running the install you expect to need. An explicit "cache these packages" endpoint would make preparing an air-gapped deployment less of a rehearsal.

Warming ahead of a seal

Warming by running the thing you expect to need is a rehearsal: you find out whether you guessed the set right only after sealing, which is the expensive moment to discover a gap.

curl -X POST -H "$AUTH" -H 'Content-Type: application/json' \
  https://terrapod.example.com/api/v1/admin/package-cache/warm \
  -d '{"packages":[{"ecosystem":"pypi","name":"requests"},
                   {"ecosystem":"npm","name":"left-pad","version":"1.3.0"}]}'
# → 202 {"data": {"id": "warm-9f2c...", "links": {"self": "…/admin/warm-jobs/warm-9f2c…"}}}

Submission returns a job id rather than a result: a real dependency closure outlives an HTTP request, and a call that times out halfway leaves you unsure what landed. Poll the job for progress and per-item outcomes — being told "failed" across twenty packages is not something you can act on, and finding the specific gaps is the entire point.

Omit version to warm the newest upstream offers. For PyPI, every file for that version is cached, not one wheel: which wheel pip selects depends on the interpreter and platform doing the installing. For npm the packument is cached alongside the tarball — a sealed node cannot serve an install without it, since the dependency ranges live there and nowhere else.

Re-running is safe and is the intended way to recover from a transient upstream failure: anything already cached is skipped and only the rest is retried.

Warming a sealed node is refused with a 409 naming cache_only, rather than reporting zero successes — which would read as a set of missing packages rather than a configuration that forbids fetching at all.

Container images warm the same way through POST /api/v1/admin/oci/warm with {"images": ["quay.io/ansible/awx-ee:24.6.1"]}, pulling the manifest and every blob it references.

Ansible Galaxy

ansible-galaxy resolves collections from galaxy.ansible.com at install time, so an estate that cannot reach it cannot install a collection at all. Point the client at Terrapod with a galaxy_server entry:

# ansible.cfg
[galaxy]
server_list = terrapod

[galaxy_server.terrapod]
url = https://terrapod.example.com/api/v1/package-cache/galaxy/
token = <a Terrapod API token>

ansible-galaxy collection install community.general then resolves and downloads entirely through Terrapod, and installs again with no route to the internet once the collection is cached.

The endpoints served are the ones the client actually calls, catalogued in the Galaxy CLI surface — captured from a real client rather than read from documentation, which is how four of that document's statements came to contradict the obvious reading of the docs.

Publishing a private collection

ansible-galaxy collection publish acme-widgets-1.0.0.tar.gz publishes to the same server entry. Terrapod reads the coordinates, dependencies and digest out of the archive's own MANIFEST.json — the client sends nothing else — and the collection then installs exactly like a public one, because the version detail it serves is the same shape.

A published version is immutable: republishing the same version is refused rather than replacing it, since a client that has already resolved it and cached its digest would otherwise receive different bytes under the same name.

Published collections are not cache entries and are never evicted by the retention sweep. A cached artifact is a copy of something upstream still has; a published one is the only copy there is.

Signing a published collection

collection publish sends only the tarball, so there is nowhere in that protocol to put a signature. Attach one afterwards:

curl -X PUT \
  -H "Authorization: Bearer $TERRAPOD_TOKEN" \
  --data-binary @manifest.sig \
  "$TERRAPOD/api/v1/package-cache/galaxy/v3/collections/acme/widgets/versions/1.0.0/signature"

The body is a detached OpenPGP signature over the collection's MANIFEST.json. Terrapod verifies it against a public key already registered with the platform (/api/v1/gpg-keys) and refuses a signature from an unregistered key with 422, naming the key. The server never re-signs — the publisher owns the signature, exactly as in the provider registry.

Once verified, the signature appears in the version detail, so ansible-galaxy collection install --keyring … can check it.

What is and is not rewritten. Every URL the client follows — download_url, versions_url, every href — points back at Terrapod, and upstream's pagination cursors are dropped rather than forwarded. A collection's own repository and documentation links are left exactly as published: the client never fetches them, and rewriting them would simply be untrue. artifact.sha256 passes through untouched, because the client checking our bytes against upstream's digest is the whole security model here.

Sealed (registry.cache_only: true), a cached collection still installs and an uncached one fails with a message naming the setting, rather than quietly reaching upstream. The version list is narrowed to versions actually held, so the client is never offered one that would 404 after it has resolved.

Pulumi plugins

pulumi up downloads a resource plugin per provider on first use, from get.pulumi.com. Point the CLI at Terrapod instead:

export PULUMI_PLUGIN_DOWNLOAD_URL_OVERRIDES=".*=https://x:$TERRAPOD_TOKEN@terrapod.example.com/api/v1/package-cache/pulumi"

Credentials go in the URL because the CLI sends no Authorization header of its own; userinfo becomes Basic auth, and the username is ignored.

Do not anchor the pattern. ^random$ never matches, and a pattern that matches nothing makes the CLI fall back to get.pulumi.com silently — the install succeeds, and the failure only appears once someone has no route out. Use .*, or a bare unanchored plugin name. The measured behaviour is in the Pulumi CLI surface.

The whole protocol is a single request for one well-known filename, so unlike the other proxies there is no version list here and nothing that needs a TTL — a plugin at a version is immutable.

Pin your plugin versions. An unpinned program first resolves what "latest" means, and that resolution does not go through the download URL, so it still needs upstream. Pinning is good practice in a restricted estate regardless.

No digest is recorded, because upstream publishes none alongside the tarball. That is weaker than the PyPI and npm proxies, where the client verifies our bytes against a published digest.

Go modules

A Pulumi program written in Go resolves its dependencies from proxy.golang.org. Point the toolchain at Terrapod instead:

export GOPROXY="https://x:$TERRAPOD_TOKEN@terrapod.example.com/api/v1/package-cache/go"

This must be HTTPS. The Go toolchain refuses to send credentials to a plain HTTP URL — refusing to pass credentials to insecure URL — and neither GOINSECURE nor .netrc lifts that. Every real deployment terminates TLS, so this only bites a plain-HTTP development setup.

Five paths, and the split between them is the design: @v/list and @latest say what exists and are mutable, so they are bounded; @v/{version}.info, .mod and .zip are immutable and need no TTL at all, because a module version's bytes cannot change — which is what Go's checksum database exists to guarantee.

Uppercase in a module path is escaped — github.com/BurntSushi/toml is fetched as github.com/!burnt!sushi/toml — and Terrapod passes that form through unchanged, because upstream expects it too.

The toolchain probes parent prefixes while resolving, asking about example.com, example.com/a and example.com/a/b in turn to find the module root. Most of those are misses by design, so a miss is a plain 404 rather than an error; anything else would break resolution for any module path with more than one segment.

NuGet

A Pulumi program written in C# resolves from nuget.org. Add Terrapod as a package source:

<!-- nuget.config -->
<configuration>
  <packageSources>
    <clear/>
    <add key="terrapod"
         value="https://terrapod.example.com/api/v1/package-cache/nuget/index.json" />
  </packageSources>
  <packageSourceCredentials>
    <terrapod>
      <add key="Username" value="x" />
      <add key="ClearTextPassword" value="%TERRAPOD_TOKEN%" />
    </terrapod>
  </packageSourceCredentials>
</configuration>

The service index is built per request rather than stored, because it advertises absolute URLs the client then follows verbatim. Those must carry this deployment's external base and this proxy's path prefix — set external_url and they are correct however many proxies sit in front. A stored index would pin whatever host fetched it first and hand that to everyone afterwards.

Package ids are lowercased in every path, so Newtonsoft.Json and newtonsoft.json are one cache entry. The versions document is mutable and bounded; a .nupkg at a version is immutable and needs no TTL.

dotnet restore warns (NU1803) against an HTTP source. Production is HTTPS, so this only appears in local testing.

Engine gating

These proxies exist to serve Pulumi programs and Ansible collections. PyPI serves both, npm serves Pulumi only and Galaxy Ansible only — so npm goes away with api.config.engines.pulumi.enabled: false, Galaxy with api.config.engines.ansible.enabled: false, and PyPI only once both engines are off. Cached artifacts are never deleted by turning an engine off. See engine gating.

The engine switches sit above the per-capability flags in registry. A capability serves only when its own flag is on and an engine that needs it is enabled, so registry.oci.enabled: true does not bring the registry back once Ansible is off. That is deliberate: switching an engine off should be one decision, not a hunt for every capability that belongs to it.

CapabilityNeeds
Container registry (/v2/)engines.ansible
PyPI proxyengines.ansible or engines.pulumi
npm proxyengines.pulumi
Galaxy proxyengines.ansible
Pulumi plugin proxyengines.pulumi
Go module proxyengines.pulumi
NuGet proxyengines.pulumi

Terraform and OpenTofu's own caches — the provider network mirror, the engine binary cache, the module registry — are not gateable and are unaffected by any of this.