---
title: "Why I Switched to pnpm (Sorry, Laurie)"
date: "2026-06-03"
approved: true
publish_date: "2026-06-03"
excerpt: "I wanted my builds to pull their packages from a server in my own house, not the public internet. Getting npm to actually do that — rather than just pretend to — is the story of how I ended up switching package managers and apologising to one of npm's founders."
tags: ["pnpm", "npm", "nexus", "ci", "nodejs"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-06-03-why-i-switched-to-pnpm"
repos:
  - name: "nakostat"
    url: "https://github.com/nakomis/nakostat"
---

# Why I Switched to pnpm (Sorry, Laurie)

![A warehouse of identical parcels being replaced by mirror portals reflecting a single master shelf](./images/why-i-switched-to-pnpm-header.png)

A while ago I decided I wanted my package downloads to come from a machine in my own house rather than from the public internet. Not for any dramatic reason — mostly speed, a cache that survives a registry having a bad day, and the small satisfaction of seeing exactly what my projects pull in. An on-prem package server. The home-lab equivalent of growing your own veg.

Setting that up for most ecosystems was painless. Setting it up for npm — *genuinely*, not just in theory — turned out to be the thread that, when pulled, unravelled into a different package manager entirely and a quiet apology to one of the people who built npm.

## A box in my house

The server in question sits on a shelf and is called Leia (her brother, inevitably, is called Luke). Among other things, Leia runs [Sonatype Nexus](https://www.sonatype.com/products/nexus-repository) — a caching proxy that sits in front of the public package registries. When I `npm install`, the request goes to Leia; Leia checks whether it already has that exact tarball; if so I get it off my own LAN, and if not Leia fetches it from npmjs, keeps a copy, and hands it on. Faster reinstalls, a local cache, one place to see what's coming through.

To stop the whole world hammering a server in my house, Leia's Nexus is gated by **mutual TLS** — you need a client certificate signed by my own private CA to talk to it. My laptop has that certificate. My laptop is happy.

GitHub's CI runners do not have that certificate. GitHub's CI runners are about to become very unhappy, and it takes me an embarrassingly long time to understand why.

![Editorial tech illustration of a small home server blinking on a wooden shelf in a cosy house, guarded by a glowing padlock and a wax certificate seal, a courier turned away at the front door, warm domestic lighting, muted retro palette](images/why-i-switched-to-pnpm-1.png)

## What npm writes down

Here's the mechanism I'd never bothered to understand. When npm installs a package, it doesn't just record *which* version it locked — it records the exact **URL** it downloaded the tarball from, baked into `package-lock.json`:

```json
"node_modules/zod": {
  "version": "4.4.3",
  "resolved": "https://packages.home.nakomis.com/repository/npm-proxy/zod/-/zod-4.4.3.tgz",
  "integrity": "sha512-…"
}
```

Look at that `resolved` field. Because *my* machine fetched it through Leia, the lockfile now says — permanently, for everyone who ever uses it — "get this from `packages.home.nakomis.com`."

Then CI runs `npm ci`, which faithfully obeys the lockfile. It tries to fetch `zod` from my home server. My home server, presented with a runner holding no client certificate, says 401. Build fails.

The moment you understand this, the obvious stop-gap writes itself. After generating the lockfile I could just rewrite my home server's address back to the public one before committing:

```bash
sed -i '' 's|https://packages.home.nakomis.com/repository/npm-proxy/|https://registry.npmjs.org/|g' package-lock.json
```

It works. Nexus is a byte-for-byte mirror of npmjs, so the tarball at the rewritten URL is identical and the integrity hashes still match. But it's a manual correction to a generated file, needed every time a dependency changes — exactly the kind of step that's fine until the one time you forget it, and then it's a red cross on a pull request. It's a patch over the symptom. It told me nothing about *why* my on-prem server and my CI couldn't agree on a lockfile.

## The fix that wasn't

The obvious next thought: surely npm has a setting for this. It does. It's called `replace-registry-host`, and it exists for exactly this "I'm behind a mirror" situation. I set it to `always` — "whatever host is in the lockfile, replace it with the configured registry" — and ran the install in a clean room to watch what it actually did.

```
npm error 404 https://registry.npmjs.org/repository/npm-proxy/zod/-/zod-4.4.3.tgz
```

Look closely. It swapped the *host* — `packages.home.nakomis.com` became `registry.npmjs.org` — but it kept Nexus's path, `/repository/npm-proxy/`. npmjs has no such path, so instead of a 401 I now had a 404. The setting replaces the hostname and only the hostname. My sed worked precisely because it rewrote the host *and* the path in one go. The official knob couldn't do the thing the dirty hack did.

That was the moment I stopped looking for a smaller patch and started asking a bigger question.

## The thing I didn't want to notice

Every *other* Node project I have was fine. None of them had this problem. So I went and looked at why, fully expecting to find some clever bit of config that past-me had set up and forgotten.

I found a one-line file. Committed into every one of those repos was an `.npmrc` containing:

```
registry=https://registry.npmjs.org/
```

That line tells npm to ignore Leia entirely and go straight to the public registry. Every project I owned had it. The reason they never hit the 401 is that they never touched my home server in the first place.

I can reconstruct how it spread. I must have hit this wall once, long enough ago to have forgotten it entirely, dropped that line into the offending project to turn CI green, and moved on. And then every project since inherited it the way most of my boilerplate actually propagates — not by decision, but by clipboard. Copy the last repo's `.npmrc`, paste it into the new one, never read it again. Version control by muscle memory. The workaround didn't spread because I kept choosing it; it spread because I never noticed I was carrying it.

Which meant the thing I thought I'd built — an on-prem npm cache, packages served from a machine in my house — *had never actually been switched on.* For some time, "I run a local package server" had been true for Maven, for Python, for Cargo, for Docker — and silently false for npm, because past-me had hit this exact wall once, pinned npm to the public registry to get past it, committed the workaround, and forgotten. The one project *without* that `.npmrc` was the only one routing npm through Leia at all — and so the only one telling me the truth. I'd been treating it as the broken one.

That's a strange thing to learn about your own infrastructure. The transition to on-prem I was quietly pleased with had, for npm specifically, quietly never happened.

## The 401, fully understood

While I was down there, I finally read the nginx config in front of Nexus, because I'd always vaguely assumed the 401 was npm's doing. It wasn't. It's mine:

```nginx
ssl_verify_client optional;
...
location / {
    if ($ssl_client_verify != "SUCCESS") {
        return 401;
    }
    proxy_pass http://nexus:8081;
}
```

The TLS handshake is allowed to complete without a client certificate (`optional`), and *then* a deliberate `return 401` turns away anything that didn't present a valid one. CI was reaching my house perfectly well — the connection succeeded — and being shown the door on the threshold. The 401 wasn't a network failure. It was my own server doing precisely what I'd told it to.

## pnpm doesn't write down the address

Here's the bit that actually made on-prem npm work. The whole saga exists because npm pins an absolute URL — host and path — into the lockfile. pnpm doesn't.

A `pnpm-lock.yaml` entry looks like this:

```yaml
zod@4.4.3:
  resolution: {integrity: sha512-…}
```

An integrity hash and a version. No host. No path. No URL at all. pnpm decides *where* to fetch from at install time, by reading your config then — not by replaying wherever the lockfile was born.

![Editorial tech illustration of two identical parcels with deliberately blank address labels, one being routed to a small home server on a shelf and the other to a vast public warehouse, clean flat vector style, muted retro palette](images/why-i-switched-to-pnpm-2.png)

So the same lockfile means two different things depending on who's holding it. On my laptop, my config points at Leia, so pnpm pulls from my home server and caches there — the thing I'd wrongly believed I was already doing. In CI, there's no such config, so pnpm pulls from npmjs. **One committed lockfile, two registries, no rewriting**, because there's nothing written down to rewrite.

I didn't want to take that on faith, having just been burned by a faith-based belief about this very system. So I resolved the entire dependency tree — all 607 packages — *through* Leia, with mutual TLS, the works, and then grepped the resulting lockfile for my home server's address:

```
$ grep -c 'packages.home.nakomis.com' pnpm-lock.yaml
0
```

Zero. Generated entirely against my private proxy, and the lockfile is completely portable. The sed is gone — not automated, not hidden behind a smarter setting, *gone*, because the problem it patched no longer exists. And for the first time, npm packages on that project genuinely come off the shelf in my house.

## Sorry, Laurie

I feel slightly bad about all this, because I know one of the people who built npm. Not well — enough to wave at on the internet, not enough to send "hey, why does the lockfile pin the registry host?" and expect an answer. And to be completely fair to npm: pinning the resolved URL is a *defensible* choice. It records exactly where every byte came from. It's auditable. In a world without transparent caching proxies on home servers gated by client certificates, it's arguably the more honest design.

It's just not the design that works for a man who keeps a package server on a shelf behind a TLS handshake. pnpm and Yarn both went the other way — version and integrity in the lockfile, registry resolved from config at install — and that other way is the one that fits the slightly unusual physical fact of my setup. So I switched. Sorry, Laurie. It really was, in the end, about a box in my house.

## What I actually learned

- npm's lockfile pins the **full resolved URL** of every package — host and path. Generate it behind a private mirror and it stops working anywhere that can't reach that mirror, and no amount of `replace-registry-host` fixes it, because that setting only swaps the host and leaves the path.
- pnpm (and Yarn) keep the lockfile **registry-agnostic** — integrity and version only — and choose the registry at install time. The identical lockfile resolves from a private proxy locally and the public registry in CI. That's what makes an on-prem npm cache actually viable.
- A workaround smooth enough to forget is more dangerous than one that hurts. My committed `.npmrc` redirects had been silently *not using* the infrastructure I thought they were using, and the only project that told me the truth was the one I'd labelled broken.
- When something built to be transparent stops being transparent, go and read its actual config. My 401 wasn't npm, wasn't GitHub, wasn't TLS being mysterious — it was four lines of nginx on my own server, doing exactly what I'd written.

---

*Martin Harris writes about connecting real hardware to abstract cloud infrastructure. If you've ever soldered something to an API Gateway, you're in the right place.*

{{donate}}
