all posts

Private npm and PyPI Mirrors in Front of a Sandbox Fleet

Ajay Kumar··11 min read

Here is a number that tends to arrive as a surprise. A fleet that creates three thousand sandboxes a day, each running an install step against a modest JavaScript project, will make somewhere north of a million requests to registry.npmjs.org and pull a few terabytes down. Not because anything is wrong, but because every guest is fresh by construction, and fresh means empty, and empty means every single dependency is a cache miss. The registry does not know or care that it has served you the same tarball two thousand times this morning.

The failure mode when this bites is rarely a bill. It is a deploy that fails at 11am on a Tuesday with a 429, or an install step that takes ninety seconds instead of nine because a public registry is having a bad day, or a build that breaks because a maintainer unpublished something. All three have the same fix, and it is not the one people reach for first.

Why this is worse for a sandbox fleet than for a CI fleet

A conventional CI fleet has long-lived runners. Runner 7 has a warm ~/.npm and a warm ~/.cache/pip, and the second job on that box installs mostly from local disk. It is an accidental cache, it is inconsistent between runners, and it occasionally causes a heisenbug — but it works, and it hides the problem well enough that most teams never look at it.

An ephemeral microVM fleet deliberately destroys that. On PandaStack every create is a restore of a baked template snapshot, so guest number four thousand comes up in the same state as guest number one: same kernel, same filesystem, same absence of a package cache. That determinism is most of why you chose the model. It also means there is no accidental cache to lean on, and no runner-local disk that quietly amortises the download across jobs.

  • Every install is a cold install. Not slower on average — cold every time, with no distribution of warm and cold to hide behind.
  • Concurrency amplifies it. Twenty parallel creates in the same second are twenty simultaneous resolutions of the same dependency tree, all from one egress IP. Public registries have opinions about that shape of traffic.
  • You look like one client. Behind NAT, a fleet of a hundred guests presents to npm as a single very enthusiastic address. Rate limits are usually per-IP.
  • Failure is correlated. When the upstream registry is slow, it is slow for every in-flight deploy at once, so your p99 build time and your failure rate move together rather than independently.
  • The install step usually dominates. For a typical Node app deploy, dependency resolution and download is a larger share of wall-clock than the actual build — which means your deploy latency is mostly a number set by somebody else's CDN.
Measure before you build anything. Add a timestamp either side of your install command and log the delta for a week. If installs are 8% of your deploy time, a mirror is premature. If they are 45%, which is the common answer for JavaScript, everything below is worth doing in order.

Fix one: bake the dependencies into the template

Start here, and do not skip to the mirror because it sounds more like infrastructure. Baking is the largest single win available and it needs no new service to run, monitor, or patch.

The mechanism is straightforward. A PandaStack template is a Docker build that gets flattened into a rootfs and then booted once, snapshotted, and restored on every create afterwards. Anything you install during that Docker build is on disk in the snapshot. A guest restored from it wakes up with node_modules or site-packages already present, at no runtime cost — the install already happened, once, when you baked.

# A template that carries the dependency closure your fleet actually uses.
# Built server-side by PandaStack from this Dockerfile plus a small context
# (the lockfiles), so you need an API key and nothing else.
FROM node:22-bookworm-slim

# A template rootfs is flattened and booted as a microVM, not run by Docker,
# so two things are non-negotiable regardless of what you start FROM:
# /sbin/init (without it the kernel falls through to /bin/sh and nothing
# boots) and sshd, the host-to-guest bridge the agent uses for exec and fs.
# The agent injects its authorized_keys at create time, so you only need the
# binaries present.
RUN apt-get update && apt-get install -y --no-install-recommends \
      systemd systemd-sysv openssh-server ca-certificates git \
 && rm -rf /var/lib/apt/lists/*

# Registry config, baked. /etc/environment is read by PAM for EVERY guest
# session, including the non-login `sh -c` sessions the deploy pipeline uses.
# /etc/profile.d only covers login shells, which is why it is not enough here.
RUN printf 'NPM_CONFIG_REGISTRY=https://npm.mirror.internal/\nPIP_INDEX_URL=https://pypi.mirror.internal/simple/\n' \
      >> /etc/environment

# Guest exec sessions run as root, so /root/.npmrc is the config npm reads.
COPY npmrc /root/.npmrc
COPY pip.conf /etc/pip.conf

# Warm the dependency closure. Copy ONLY the manifests, so this layer is
# invalidated by a lockfile change rather than by every source edit.
WORKDIR /warm
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

Two things make this cheaper than it looks. The rootfs is cloned per sandbox with a reflink rather than copied, so a 6 GiB template with a fat node_modules costs metadata per create, not gigabytes — the blocks are shared until something writes to them. And because every guest on a host reads the same backing file, the host page cache serves those reads once and then serves them from RAM, which is why the tenth restore on a host feels faster than the first.

from pandastack import Client

c = Client()  # reads PANDASTACK_API_KEY

# The Dockerfile above plus its context (lockfiles, npmrc, pip.conf).
# size_mb is the rootfs size -- a baked node_modules needs headroom.
build = c.templates.build_from_dockerfile(
    "warm-node",
    "./template/Dockerfile",
    context_dir="./template",
    size_mb=8192,
)

for line in c.templates.build_logs(build.id):
    print(line)

# From here every create restores a snapshot that already has the deps.

What baking costs you, said honestly

Baking trades a runtime cost for a staleness problem, and anyone telling you otherwise has not run it for six months.

  • The bake goes stale the moment a lockfile changes. If your repos move fast, a template baked on Monday is missing Thursday's dependencies and the install step downloads them anyway. You need a rebake cadence — nightly, or on lockfile change — and that is a pipeline someone owns.
  • It only helps the shared part of the closure. Ten repos with genuinely disjoint dependency trees get very little from one baked template. The win is proportional to the overlap, and the overlap is usually large for a monorepo or a fleet of similar services, and small for a general-purpose code-execution product where users bring arbitrary code.
  • Guest RAM is a template property, not a create parameter. Firecracker cannot resize memory at snapshot restore, so a template's baked RAM is what every guest from it gets. On PandaStack the `base` template bakes 4 GiB precisely because Next and Vite production builds spike to 2.5-4 GiB and OOM'd at 2. If you bake a heavier template, size it for the build, not for the steady state.
  • A rebake invalidates every snapshot derived from it. That is correct behaviour — you do not want a guest restoring into a memory image that disagrees with its disk — but it means a rebake is a fleet-wide event, not a local change.
  • Never bake credentials. A registry auth token in a baked layer is a token in every guest, forever, including guests running code you did not write. Put the token in the environment at create or deploy time and read it from there.

Fix two: a pull-through cache inside your network

Baking handles the stable core of your dependency graph. The mirror handles everything else: the package a developer added this morning, the transitive bump a lockfile picked up, the long tail you cannot predict. A pull-through cache is a registry-shaped service that answers from local disk when it can and fetches from upstream once when it cannot, then keeps the bytes.

The choice matters less than people think. Verdaccio if you want a small Node process and a config file. devpi if you are Python-first and want the index semantics done properly. Nexus or Artifactory if you already run one for Java and would rather add two more repository types than another service. A plain caching HTTP proxy in front of the public registries works too and is under-rated for a read-only fleet, because registry tarball URLs are immutable and content-addressed by integrity hash, which is exactly the traffic shape an HTTP cache is good at.

# verdaccio config.yaml -- the mirror half, which is the only half a
# sandbox fleet needs. Publishing is disabled outright.
storage: /var/lib/verdaccio/storage

uplinks:
  npmjs:
    url: https://registry.npmjs.org/
    # Cache tarballs on disk. Without this you proxy every byte forever
    # and have built a latency amplifier rather than a cache.
    cache: true
    timeout: 30s
    maxage: 2m          # metadata freshness; tarballs are immutable
    max_fails: 3
    fail_timeout: 5m

packages:
  # Your own scope resolves LOCALLY ONLY. This line is the dependency
  # confusion defence: an attacker publishing @acme/anything to the public
  # registry can never be reached, because we never ask upstream for it.
  '@acme/*':
    access: $authenticated
    publish: $authenticated
    proxy:              # deliberately empty -- no upstream fallthrough

  '**':
    access: $all
    publish: $nobody     # a sandbox fleet is a read path, full stop
    proxy: npmjs

log: { type: stdout, format: pretty-timestamped, level: warn }

The Python equivalent with devpi is the same shape: a root/pypi index that mirrors upstream, a private index that inherits from it, and clients pointed at the private one. The important detail is the same in both ecosystems and it is worth stating on its own line.

Use `index-url`, never `extra-index-url`. With `extra-index-url`, pip queries both indexes and takes the highest version it finds anywhere — so anyone who publishes `acme-internal 99.0.0` to public PyPI wins against your private 1.4.2. This is the mechanism behind essentially every dependency-confusion incident that has been written up. One index, with your private packages inside it, is the only configuration that is safe by construction. npm's equivalent is a per-scope registry line, as in the Verdaccio config above.

Getting the config into the guest, three ways

A mirror nobody points at is a server you pay for and never use. There are three places the config can come from, and they differ in who controls it.

; /root/.npmrc -- guest exec sessions run as root, so this is the file npm reads
registry=https://npm.mirror.internal/
@acme:registry=https://npm.mirror.internal/
//npm.mirror.internal/:_authToken=${NPM_TOKEN}
; `npm audit` calls a bulk-advisory endpoint most mirrors do not implement;
; leave it off here and run the audit as its own deliberate step elsewhere.
audit=false
fund=false

; ---------------------------------------------------------------------
; /etc/pip.conf
[global]
index-url = https://pypi.mirror.internal/root/pypi/+simple/
; NOT extra-index-url. See above.
timeout = 30
retries = 3
  1. Baked into the template. Strongest option: the config exists before any user code runs, so there is no window in which a guest resolves against the public registry. Use it when the mirror is a platform decision rather than a per-tenant one. Put the env vars in /etc/environment, because PAM reads it for every session while /etc/profile.d only covers login shells — and the deploy pipeline's build steps are non-login `sh -c` sessions.
  2. Written at create time through the filesystem API. Right when the mirror differs per tenant or per job, or when the token is short-lived. You create the sandbox, write the file, then run the install. It costs one round trip and it keeps the token out of every artefact.
  3. Passed as an environment variable. npm reads NPM_CONFIG_REGISTRY, pip reads PIP_INDEX_URL, and for a git-driven app on PandaStack the app's env is written into the guest and sourced by the install and build steps. Least intrusive, and the easiest for a user to override — which is either a feature or a hole depending on your threat model.
import os
from pandastack import Sandbox

sbx = Sandbox.create(template="warm-node", ttl_seconds=900)

# Route 2: write the config after create, before the install. The token comes
# from the caller's environment and is never baked into a template.
sbx.filesystem.write(
    "/root/.npmrc",
    "registry=https://npm.mirror.internal/\n"
    "@acme:registry=https://npm.mirror.internal/\n"
    f"//npm.mirror.internal/:_authToken={os.environ['NPM_TOKEN']}\n"
    "audit=false\nfund=false\n",
)

# Prove the mirror is actually being used before you trust the numbers.
print(sbx.exec("npm config get registry", timeout_seconds=30).stdout)

res = sbx.exec("cd /app && pnpm install --frozen-lockfile", timeout_seconds=600)
print(res.exit_code)

sbx.kill()
One ordering trap in the PandaStack app-deploy pipeline: `mise install` — which fetches language runtimes — runs before the app's env file is written into the guest. So an NPM_CONFIG_REGISTRY set as app env reaches your install and build commands, but does not reach mise's own runtime downloads. If you need those mirrored too, bake the config into the template rather than passing it as app env. Baking is the route that has no ordering to get wrong.

Fix three: a full mirror with an allowlist

The third rung is qualitatively different. Here the guest has no route to the public internet at all, and the mirror is not a cache in front of upstream but the only source of truth — populated deliberately, with a reviewed set of packages, and frozen at a known state. This is the regulated and air-gapped case, and it is a programme rather than a config change: someone has to run the ingestion process, review what enters, and answer for the package that a developer needs on Friday afternoon and is not in the index.

Be clear-eyed about where the boundary is enforced. On PandaStack's managed fleet, guest egress is NAT'd with a denylist — link-local metadata addresses and the well-known Stratum mining ports are dropped at the host FORWARD chain — but there is no per-sandbox egress allowlist you can set through the API. That means a mirror is enforced by configuration inside the guest, not by the network, and configuration inside a guest running untrusted code is advisory. If you need the network itself to be the enforcement point, that is a self-hosted or on-premise deployment, where you own the host firewall and can default-deny.

The cache is also a security control

This is the part that justifies the mirror even when the bandwidth argument does not. A pull-through cache is a single place every dependency in your organisation passes through, and a chokepoint is worth a great deal more than a bandwidth saving.

  • You can freeze. Set the upstream to read-only during an incident and your fleet keeps building from bytes you already have, at a state you already trust, while you work out whether the thing everyone is talking about affects you.
  • You survive an unpublish. A maintainer yanking a version breaks every fresh install everywhere — unless you already hold the tarball. This is not hypothetical; it has taken down large fleets twice in living memory.
  • Scoped names cannot be hijacked. A private scope that never falls through to an upstream cannot be shadowed by a public package of the same name, which closes dependency confusion structurally rather than by policy.
  • You get a real inventory. One log of every package version that entered your organisation, which is the input to an SBOM you can actually defend, rather than one reconstructed from lockfiles after the fact.
  • Scanning gets a place to live. Scan on ingest, once per version, rather than on every install in every guest — which is both cheaper and the only version of the check that a developer cannot skip.
  • Typosquats have a smaller window. A new package name entering the organisation for the first time is an event you can notice and, if you want, hold for a human. The cost is a queue; the benefit is that `reqeusts` never gets installed silently.

None of that removes the need to isolate the install itself. A pull-through cache serves you the same arbitrary postinstall script the public registry would have — faster, and with a record of it. The mirror is the inventory and the chokepoint; the microVM is the blast radius. They are complementary controls and neither substitutes for the other.

What this actually saves, in money and in seconds

A correction to a thing you will read elsewhere, including in earlier drafts of our own material. PandaStack meters network transmit bytes for visibility, but egress is not charged on any plan today — the pricing endpoint reports it with status `not_billed`. And a registry download is inbound to the guest anyway. So the honest framing is that cutting registry traffic is not a line item on your invoice.

It shows up indirectly, and smaller than you might hope. Compute is billed per second at $0.054 per vCPU-hour and $0.0162 per GiB-hour, but CPU is charged on active CPU-seconds actually burned rather than on the vCPUs allocated — and a guest blocked on a socket burns almost none. So the meter that keeps running while you wait for npm is mostly the memory term. On a 4 GiB template that is about a tenth of a cent per minute of waiting. Ten thousand deploys a month each spending a minute less on installs is roughly ten dollars. That is not the argument.

The real return on a registry mirror is not bandwidth. It is that your build stops depending on somebody else's uptime, and that you have somewhere to stand when a package disappears.

The order I would actually do this in

  1. Measure the install step as a fraction of deploy wall-clock for a week. Everything below is conditional on this number.
  2. Bake the stable dependency closure into a template and rebake it nightly. Largest win, no new service, and it is reversible in an afternoon.
  3. Stand up a pull-through cache and point the template at it. Now the long tail is local too, and you have a chokepoint.
  4. Make your private scope local-only with no upstream fallthrough, and switch every pip config from extra-index-url to index-url. This is a ten-minute change that closes the most commonly exploited supply-chain hole in either ecosystem.
  5. Add scanning and an ingest gate at the mirror, once per package version rather than once per install.
  6. Only then consider a full allowlisted mirror with no upstream, and only if a compliance requirement is driving it — because from here the cost is process, not software.

Most teams get eighty per cent of the benefit from steps two and four and never need the rest. That is a perfectly good place to stop.

Frequently asked questions

Should I bake dependencies into the template or run a registry mirror?

Bake first, then mirror — they solve different halves of the problem and the order matters. Baking removes the download entirely for the dependencies you can predict, at zero runtime cost, because a snapshot-restored guest wakes with the packages already on disk and the rootfs is reflinked rather than copied per sandbox. A mirror handles everything you could not predict: the package added this morning, the transitive bump, the repo that is not like your other repos. The reason to do baking first is that it needs no new service to run and monitor, and it is the larger win when your dependency graphs overlap. The reason not to stop there is that a bake goes stale the instant a lockfile moves, so without a mirror behind it your install step still reaches the public registry for the delta — which is precisely the traffic that gets rate-limited during an incident.

Does a pull-through cache protect against dependency confusion attacks?

Only if you configure it to, and the default configuration of most mirrors does not. The protection comes from making your private namespace resolve locally with no upstream fallthrough at all: in Verdaccio that is a packages entry for your scope with an empty proxy list, in devpi it is an index that does not inherit from root/pypi for those names. Get that right and a package published to the public registry under your scope is simply unreachable, which is a structural defence rather than a policy one. Get it wrong — most commonly by using pip's extra-index-url, which queries both indexes and takes the highest version found anywhere — and you have built the vulnerability rather than the fix. Use index-url with a single index that contains your private packages, and audit that setting specifically, because it is the one line that decides the outcome.

How do I point a PandaStack sandbox at a private npm registry?

Three routes, in decreasing order of how hard they are to bypass. Bake it: put the registry config in /root/.npmrc and the env vars in /etc/environment inside the template Dockerfile, so the config exists before any user code runs — /etc/environment is the right file because PAM reads it for every session, including the non-login sh -c sessions the build pipeline uses, whereas /etc/profile.d only covers login shells. Write it at create time: create the sandbox, call sbx.filesystem.write to drop /root/.npmrc, then run the install — best when the token is short-lived or the mirror differs per tenant. Or pass it as an environment variable: npm reads NPM_CONFIG_REGISTRY and pip reads PIP_INDEX_URL, and for a git-driven app the app's env is written into the guest and sourced by the install and build steps. One caveat on the third route: mise installs language runtimes before the app env file is written, so runtime downloads will not use your mirror unless the config is baked.

Does PandaStack charge for the bandwidth my sandboxes use pulling packages?

No. Network transmit bytes are metered for visibility, but egress is not charged on any plan — the public pricing endpoint reports egress with status not_billed — and a package download is inbound traffic anyway. Nor is the compute saving as large as it first looks. Rates are $0.054 per vCPU-hour and $0.0162 per GiB-hour billed per second, but CPU is charged on active CPU-seconds actually burned rather than on allocated vCPUs, and a guest blocked waiting for a registry burns very little CPU. What keeps ticking is the memory term, which on a 4 GiB template is around a tenth of a cent per minute of waiting. Do not build a mirror for the invoice. Build it for throughput, for the deploys that currently fail on a 429 and get retried, and for the chokepoint it gives you over what enters your dependency graph.

Can I force sandboxes to use only my mirror and block the public registry?

Not through the API on the managed fleet, and it is worth being precise about why. Guest egress is NAT'd with a denylist — link-local metadata addresses are dropped so a guest can never reach the host's cloud credentials, and the well-known Stratum mining ports are dropped at the host FORWARD chain — but there is no per-sandbox egress allowlist you can set. So a mirror is enforced by configuration inside the guest, and configuration inside a guest that runs untrusted code is advisory rather than binding: code that wants to talk to registry.npmjs.org directly can. If the network itself has to be the enforcement point, which is the usual requirement in a regulated or air-gapped environment, that means a self-hosted or on-premise deployment where you own the host firewall and can default-deny outbound.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.