A glossy ultralight backpack in front of a dark mountain ridge, rain and red light on one side and calm cyan light on the other, illustrating how the smallest Docker base image trades saved weight for risk.

The Alpine Gotcha: Dockerizing a pnpm Monorepo

I did the responsible thing. When it came time to Dockerize this website—a pnpm monorepo with an Astro app inside it—I reached for node:alpine, the smallest official Node base image, because that’s what most tutorials, half of Stack Overflow, and the “Docker best practices” listicles say to do. Smallest image wins. That choice cost me an afternoon.

The short version, in case you’d rather get back to your afternoon: Alpine Linux uses a different C standard library (musl instead of glibc, the one nearly all prebuilt Linux software is built against), and it ships almost no tooling. Both are fine until something in your dependency tree needs to compile native code, or until a tool—Cloudflare’s Wrangler, for example, whose runtime is a glibc binary—flatly refuses to run. My fix was boring: a glibc-based node:24-bookworm-slim build image, the newest active long-term-support (LTS) Node pinned consistently everywhere, and a Dockerfile that copies the lockfile first so the dependency layer caches between builds. The rest of this post is why, plus the one case where Alpine is still the right call.

Why Alpine Is Tempting

The pitch for Alpine is legitimate. The compressed image sizes make the case at a glance: node:24-alpine runs around 55 MB, node:24-slim (Debian with the fat trimmed) around 80 MB, and the full node:24 image around 400 MB. Smaller images pull faster, deploy faster, and carry less software that could show up in a CVE (a publicly cataloged security vulnerability). None of that is wrong, which is why the advice gets repeated so confidently.

Alpine is the ultralight backpacking of base images. The ultralight crowd saws the handle off their toothbrush and carries a tarp instead of a tent, and on a sunny weekend they glide past you on the trail looking smug and unburdened. Then it rains sideways, and they’re improvising tent poles out of trekking poles at 9 PM while you zip your heavy, boring tent shut. The weight they saved didn’t disappear—it moved into risk. Alpine works the same way. It ships very little, so the moment your build needs a compiler, Python, or any tool the image left out, you’re hauling that gear in yourself, at the trailhead, in the rain.

What the listicle leaves out is that the savings that matter come from going full-image to slim (roughly 400 MB down to 80). The extra step from slim to Alpine saves about 25 MB—less than a single RAW photo off my camera—and that’s the step where the musl sharp edges in this post live.

Collision 1: Native Dependencies Want a C Toolchain

First collision: pnpm install died partway through, mid-compile on one of the workspace’s native dependencies. I’d tell you which one, but I didn’t write it down—I was too busy being wrong about whose fault it was.

I want to be fair to pnpm here, because my first instinct was to blame it and that was slander. pnpm is pure JavaScript; it runs on Alpine happily. The problem is the packages it installs. Some npm packages contain native code—C or C++ compiled to a binary during install, node-gyp territory—and they solve this one of two ways. Either they compile on your machine at install time, which requires a C toolchain Alpine doesn’t ship, or they download a prebuilt binary, and whether a musl build of that binary exists depends on the package—esbuild and sharp, for example, publish one; plenty of smaller ones only publish glibc.

That second failure mode is the sneaky one. musl and glibc are metric and imperial sockets. A prebuilt glibc binary on a musl system is a bolt machined to the other standard—it looks right, it even seats partway, and then it strips. The binary downloads fine, sits in node_modules looking perfectly healthy, and fails at runtime with a linker error that mentions none of this. The docker-node maintainers are upfront that musl builds of Node are a lower support tier; you’re off the paved road.

There are two roads out:

  • Stay on Alpine and apk add --no-cache python3 make g++ so compilation works. This is a legitimate move, but notice what happened—you just carried the tent poles in anyway. The build stage of your image is now hauling a full toolchain, and you’ve also kept musl, so any dependency that only ships glibc prebuilds is still a problem.
  • Switch to a glibc base like node:24-bookworm-slim, where glibc prebuilds load without ceremony and anything that still has to compile is one apt-get install build-essential away from a normal Linux world.

One more tripwire while you’re in here: pnpm 10 and later block dependency install scripts by default. A native module’s compile step won’t even run until you approve it with pnpm approve-builds (or list it under onlyBuiltDependencies). So on a fresh pnpm 10 setup you can hit two snags in sequence—first the script is blocked, then, once approved, Alpine has no compiler to run it with. That’s a fun hour.

I took the second road—a 25 MB size delta wasn’t worth building on the experimental tier.

Collision 2: Wrangler Can’t Run Here at All

The second collision was worse, because there was no toolchain I could install to fix it.

This monorepo deploys to Cloudflare, so Wrangler (Cloudflare’s command-line tool for Workers) is in the dependency tree alongside the Astro Cloudflare adapter. Wrangler’s local dev mode runs workerd, the actual Workers runtime, as a native binary on your machine. And Cloudflare’s supported-platforms list is blunt about it: Linux distros that support glibc 2.35. workerd is a glibc binary. There is no musl build. On Alpine, you’ll find wrangler dev doesn’t complain about your Node version—it simply cannot execute.

I’d assumed my problem was “Wrangler wants a newer Node,” and that’s half true—Wrangler requires Node 20 or newer, and its policy is to support only Current, Active, and Maintenance release lines. However, the deeper issue was the C library under the Node. You can’t apk add your way out of a binary that was never compiled for your libc (Alpine’s gcompat shim covers simple glibc binaries; a runtime that wants glibc 2.35 specifically isn’t one of them). Same stripped bolt, except this time the metric version isn’t manufactured at all.

The Node half still needed sorting, though, and the LTS lineup shifted recently, so a quick update on where things stand: Node 24 is the Active LTS (supported into 2028), Node 22 is already in maintenance mode, and Node 20 hit end-of-life in April 2026. My standing rule—adopted the hard way, see the next section—is to pin the newest active LTS everywhere the runtime is declared, so all three of these agree:

  • The Dockerfile base image (node:24-bookworm-slim)
  • engines.node in package.json
  • packageManager for the exact pnpm version

(Full disclosure: as I write this, the website repo’s engines.node still reads >=22.12.0, and the Dockerfile below lives in this post ahead of the tree. The rule is the target; the repo is catching up to it.)

When those three drift apart, local dev, CI (the continuous-integration pipeline that builds on each push), and production are really running three different environments, and the bug reports will read like three people describing three different apps.

The Corepack Key-ID Gotcha

The mistake that produced the standing rule happened a few weeks back, in a different project—a little SvelteKit side project. My Docker build started failing with corepack (the Node-bundled tool that fetches the pnpm version your packageManager field pins) throwing Cannot find matching keyid. The Dockerfile pinned node:22.11-alpine, the build had worked for months, and I’d changed nothing.

What had happened: npm rotated the registry’s package-signing keys in January 2025. Corepack verifies downloads against a baked-in list of trusted keys, and any corepack older than 0.31.0 has the old list. Old corepack is a vending machine rejecting the redesigned twenty-dollar bill. The bill is genuine; the treasury really issued it; the machine’s firmware just predates the redesign. And the fix is to update the machine—not to tape the bill acceptor open.

The tape, in this case, is setting COREPACK_INTEGRITY_KEYS=0, which you’ll find suggested in plenty of GitHub threads. That switch makes the error vanish by disabling signature verification entirely, meaning corepack will now happily install anything the network hands it. Hard pass. The correct fix is a base image whose corepack knows the new key—I bumped that project from Node 22.11 to Node 24 and the error evaporated, no security switched off.

One caveat before you copy RUN corepack enable anywhere: corepack’s days are numbered. Node’s steering committee voted to stop shipping it, and Node 25+ doesn’t include it. Node 24 keeps it (still marked experimental) through its LTS life, so the pattern in this post is fine on node:24 images—but when Node 26 becomes the LTS this October, plan on npm install -g pnpm@<version> or installing corepack explicitly instead.

A Sane Dockerfile for a pnpm Monorepo

With the base image settled, the remaining job is making the build fast on repeat, and the trick is pnpm fetch, which downloads the packages into pnpm’s store by reading only pnpm-lock.yaml—it never looks at your package.json files. Docker caches layers based on what’s been copied in so far, so if you copy just the lockfile before fetching, that expensive download layer survives each rebuild until the lockfile (or another file in that same COPY line) changes.

The lockfile-first fetch is mise en place, straight from a restaurant kitchen. The prep cook chops the onions and portions the sauces once, in the morning, and dinner service assembles from prepped containers. The cook doesn’t re-chop an onion because one table ordered a different entrée. Re-prepping happens only when the recipe changes—and in this kitchen, the lockfile is the recipe. Copy your source code too early and you’ve forced a full re-prep on each code change, which is what the naive COPY . .-then-install Dockerfile does.

The shape, filtered to this repo’s website package, looks like this:

FROM node:24-bookworm-slim AS build
RUN corepack enable
WORKDIR /app
# The recipe: fetch re-runs only when these three files change
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm fetch
# Now the source, then an offline install from the warm store
COPY . .
RUN pnpm install -r --frozen-lockfile --offline
RUN pnpm --filter @kdd/website build
FROM nginx:alpine AS runtime
COPY --from=build /app/apps/website/dist /usr/share/nginx/html

Let’s walk the load-bearing lines:

  • corepack enable reads the packageManager field from the root package.json (which is why that file rides along in the first COPY) and activates the exact pinned pnpm—pnpm@10.34.1 here, hash and all.
  • pnpm fetch before COPY . . is the mise en place. Change a component, rebuild, and you’ll see Docker skip straight past the download.
  • pnpm install -r --frozen-lockfile --offline links the packages from the already-fetched store. --frozen-lockfile makes the build fail loudly if the lockfile and manifests disagree, instead of resolving something new behind your back.
  • The runtime stage is just a web server. This site’s production build is fully static—no adapter, plain HTML in dist/—so all the final image needs to do is serve files. If your Astro app builds a server instead (the Node adapter’s dist/server/entry.mjs), your runtime stage is a Node image running that entry—and note that Astro’s own Docker recipe copies node_modules in alongside dist, because the server entry imports its dependencies rather than bundling them. Copy only dist and it will crash on the first missing import.
  • Multi-stage means the runtime image carries the built output alone—no store, no source, no compilers.

And yes, that runtime stage says alpine, two thousand words into a post about Alpine costing me an afternoon. Hold that thought.

One footnote that’s bitten me in Compose stacks: if your runtime stage is a Node image, remember that Node’s slim variants ship neither wget nor curl, and the Alpine ones only carry BusyBox’s stripped-down wget, so a healthcheck that shells out to curl will fail forever. Use the runtime you already have—node -e "fetch('http://localhost:4321/').then(r => process.exit(r.ok ? 0 : 1))" does the job with zero extra packages. (The nginx image also has BusyBox wget built in, and for a static site that’s all a healthcheck needs. The one time the tiny image comes with the tool.)

When Alpine Is Right

Now, the thought you were holding: that nginx:alpine runtime stage is the rule, not hypocrisy. Static HTML has no dependency tree, so there is no compile step and no linking against a C library, and musl never gets a vote. The problems in this post live in the build stage—the stage that runs pnpm, compiles native modules, and executes glibc-only tools like workerd. Once the artifact is plain files, Alpine’s sharp edges have nothing to cut.

And that side project from the corepack section? Still entirely on Alpine. node:24-alpine, multi-stage, pinned pnpm, non-root user, and the app builds and runs without a single complaint—even the build stage, because that project’s only platform-specific binaries (Vite’s esbuild and Rollup) ship musl builds, and no package in the tree reaches for a compiler. The sunny-weekend hike really is fine with the ultralight pack. Packing light was never the mistake. Packing light without checking the forecast was.

So the decision rule I use now:

  1. Does anything in the tree ship or compile native code? Grep for node-gyp, look at what pnpm approve-builds asks about, check for platform-specific binary downloads. Any yes means start on node:<LTS>-bookworm-slim.
  2. Does the toolchain include a glibc-only binary like workerd? Then there’s no tradeoff to weigh—Alpine is off the table.
  3. All clear? Alpine’s fine. Enjoy the 25 MB.

And if you’re unsure, start on slim anyway—it’s a pretty safe default. Going slim-to-Alpine later is an optimization you make once things work. Going Alpine-to-slim is a debugging session you make before they do.

The Lesson Worth Keeping

Two collisions, one afternoon: a native dependency with no compiler to build it and no musl binary to fall back on, and a Cloudflare toolchain that’s glibc-or-nothing. One fix: node:24-bookworm-slim, the newest active LTS pinned in the Dockerfile—and, once I finish the housekeeping, identically in engines and packageManager—plus a lockfile-first build so the fix stays fast. And one rule from the corepack detour: when a stale image fails a security check, bump the image—never disable the check.

The bigger lesson is about the advice itself. “Use the smallest base image” is a best practice in the same way “pack light” is—correct on average, and completely indifferent to your actual trip. The smallest image was a proxy for fast pulls and a small attack surface, not the goal itself, and a slim glibc image gets you nearly all of both without the fragility. When a best practice arrives without its reasoning attached, weigh it against your actual stack before you inherit it.

Check the forecast, then pack.