Smaller, Safer Images

Ship production dependencies only, run the process as the node user, and know what the alpine base saves and what it costs.

SyntaxRUN npm ci --omit=dev && npm cache clean --force

Image size is not vanity. A smaller image is pulled faster on every deploy, and it carries fewer programs an attacker could use if they ever get a shell inside the container.

Leave the build behind

npm ci --omit=dev installs the dependencies from package-lock.json and skips devDependencies. Setting ENV NODE_ENV=production has the same effect on later installs, and many libraries also switch off development-only checks when they see it. The older spelling --only=production still works but is deprecated; prefer --omit=dev.

Stop running as root

By default the process inside a container runs as root, the user with no restrictions. The official Node images ship a user named node with uid 1000 for exactly this reason. Add USER node after the last instruction that needs to write, and everything from that point runs unprivileged.

What alpine costs

node:24-alpine is built on Alpine Linux, which uses the musl C library rather than glibc. It is the smallest official variant, but packages with native code often publish prebuilt binaries for glibc only. On musl they are compiled from source during npm ci, which needs a toolchain in the build stage, or they fail outright. The Node project treats musl builds as an experimental support tier. When a dependency fights you, node:24-slim is Debian-based, glibc, and still far smaller than the full image.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Cut deploy pull time by leaving TypeScript and test tooling out of the shipped image.
  • Satisfy a security review that requires containers not to run as root.
  • Decide between alpine and slim after a native dependency fails to install.
  • Keep a leaked local .env or build cache out of the image you push to a registry.

More examples

A runtime stage that runs unprivileged

The order matters more than the contents. Installs happen while the build is still root, then USER node takes effect for the process that actually serves traffic. Use this as the second stage of any multi-stage build.

Example · dockerfile
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app

# Install first, as root, because /app is owned by root at this point.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

# --chown makes the app's own files writable by the user it runs as.
COPY --chown=node:node . .

# Everything below this line runs as uid 1000, not root.
USER node
CMD ["node", "server.js"]

Which base image to pick

Run this once and the numbers stop being abstract. node:24 carries a full Debian userland and build tools; slim drops most of it; alpine is the smallest. Reach for the apk line when npm ci fails on alpine with a node-gyp error, and keep it out of the final stage.

Example · bash
# Pull three variants of the same Node version and compare.
docker pull node:24
docker pull node:24-slim
docker pull node:24-alpine
docker images node --format '{{.Tag}}\t{{.Size}}'

# A dependency with native code needs a toolchain on musl.
# In the BUILD stage only, never the runtime stage:
#   RUN apk add --no-cache python3 make g++

Pinning the base image by digest

node:24-alpine means whatever the latest 24.x alpine build is today, so two builds a month apart can differ. Pinning the digest makes the base image reproducible, at the cost of updating the line yourself when you want security patches. Teams that deploy on a schedule usually pin; teams that rebuild weekly usually do not.

Example · dockerfile
# A tag can be re-pointed at a new build; a digest cannot.
FROM node:24-alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000

# Get the real digest for the image you tested with:
#   docker inspect --format '{{index .RepoDigests 0}}' node:24-alpine

Discussion

  • Be the first to comment on this lesson.