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 oneapt-get install build-essentialaway 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.nodeinpackage.jsonpackageManagerfor 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 buildRUN corepack enableWORKDIR /app
# The recipe: fetch re-runs only when these three files changeCOPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./RUN pnpm fetch
# Now the source, then an offline install from the warm storeCOPY . .RUN pnpm install -r --frozen-lockfile --offlineRUN pnpm --filter @kdd/website build
FROM nginx:alpine AS runtimeCOPY --from=build /app/apps/website/dist /usr/share/nginx/htmlLet’s walk the load-bearing lines:
corepack enablereads thepackageManagerfield from the rootpackage.json(which is why that file rides along in the firstCOPY) and activates the exact pinned pnpm—pnpm@10.34.1here, hash and all.pnpm fetchbeforeCOPY . .is the mise en place. Change a component, rebuild, and you’ll see Docker skip straight past the download.pnpm install -r --frozen-lockfile --offlinelinks the packages from the already-fetched store.--frozen-lockfilemakes 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’sdist/server/entry.mjs), your runtime stage is a Node image running that entry—and note that Astro’s own Docker recipe copiesnode_modulesin alongsidedist, because the server entry imports its dependencies rather than bundling them. Copy onlydistand 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:
- Does anything in the tree ship or compile native code? Grep for
node-gyp, look at whatpnpm approve-buildsasks about, check for platform-specific binary downloads. Any yes means start onnode:<LTS>-bookworm-slim. - Does the toolchain include a glibc-only binary like workerd? Then there’s no tradeoff to weigh—Alpine is off the table.
- 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.