Shipping ItLesson 11 of 74

Contents

Shipping It

Lesson 11 of 74. Sign in to keep your place.

Module 2 · Making a deploy boring

The same build, everywhere

9 min read

Image size is mostly base imagenode:221100 MBnode:22-slim200 MBnode:22-alpine130 MBSlim removes the compilers and documentation you never use. Alpine goes smaller again by swappingglibc for musl, which is why native modules like sharp or bcrypt sometimes fail to build on it.
Slim removes the compilers and documentation you never use. Alpine goes smaller again by swapping glibc for musl, which is why native modules like sharp or bcrypt sometimes fail to build on it.

Figure

The claim you want to be able to make

“The thing running in production is byte-for-byte the thing I tested.”

Almost nobody can make that claim, and the gap is where “works on my machine” lives. There are only two disciplines needed: pin everything that can drift, and build the artifact once.

Lockfiles, and the command that respects them

Your package.json says "express": "^4.18.0". The caret means “any 4.x above this”. Your lockfile records the exact version and every transitive dependency underneath it.

  • npm install may update the lockfile to satisfy the manifest.
  • npm ci deletes node_modules, installs exactly what the lockfile says, and fails if the lockfile and manifest disagree.

That failure is the feature. Use npm ci in CI and in your image build; use npm install only when you are deliberately changing dependencies. The same distinction exists everywhere: pip install -r requirements.txt against a pinned file or uv sync, bundle install --deployment, cargo build --locked.

Pin the runtime too. .nvmrc, an engines field, a python version in pyproject.toml, a specific base image tag. “Node 20” is not a version; a minor release changed OpenSSL defaults once and broke a great many builds on a Tuesday.

Containers, briefly and honestly

A container image is a filesystem plus a command. It solves one problem well: the machine’s own state (system libraries, locales, that imagemagick you installed in 2023) stops being part of your program.

It is not free. You now maintain a Dockerfile, and image builds have their own failure modes. For a static site or a simple app on a managed platform, you may never need one. For anything with native dependencies, it earns its keep quickly.

Four things worth knowing before you write one:

Layer order decides your build time. Each instruction is a cached layer, invalidated by the first change. Copy your lockfile and install dependencies before copying your source, or every one-character edit reinstalls the world.

Dockerfile
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

Image size is mostly base image. node:22 is around 1.1 GB. node:22-slim is around 200 MB. node:22-alpine is around 130 MB, and Alpine uses musl instead of glibc, so native modules like sharp or bcrypt may fail to build, or build and then behave differently. Use -slim unless you have measured a reason.

A .dockerignore is not optional. Without one you copy node_modules, .git and your .env into the image. The last one is a leaked secret sitting in a layer that people can pull.

Architecture is a real trap. Building on Apple Silicon produces an arm64 image. Deploying that to an x86 server gives exec format error, which reads like a corrupt binary. Build with --platform linux/amd64, or build in CI, which is x86 by default.

Docker Desktop needs a paid licence at larger companies. The free paths are identical in practice: Podman, Colima, Rancher Desktop, or the plain Docker engine on any Linux box.

Build in CI, not on your laptop

A laptop accumulates. Global installs, an old cache, an environment variable exported months ago in a shell profile, an authentication token that only exists there. CI starts from nothing every time, which is precisely why it catches things you cannot.

The one that stings is case sensitivity. macOS filesystems are case-insensitive by default; Linux is not. import Button from "./button" works forever on your Mac and fails in production with a bare module-not-found. There is no warning locally, ever. A Linux CI build finds it the first time.

Promote the artifact, do not rebuild it

The pattern to aim for:

  1. Build once from a tagged commit. Produce a container image or a bundle.
  2. Test that artifact.
  3. Deploy that same artifact to staging.
  4. Deploy that same artifact to production.

If staging and production each build from source, you have two builds, two dependency resolutions and two opportunities to differ, and your staging test proved something about a program you are not shipping.

Tag the artifact with the git commit SHA, and print that SHA on a /version endpoint. When somebody asks “is the fix live?”, the answer becomes a URL rather than an argument.

Every lesson here is open. Your progress is recorded once you pass the Agents and Automation exam.

Check your understanding

Your app builds and runs on your Mac. The build in CI, on Linux, fails with “Cannot find module ‘./button’”, and the file src/Button.tsx is definitely there and definitely committed. What has happened?

Pick the one you would defend. Your answer stays private.

Next lesson · 8 minWhich commit is live right now

Stuck on something?Ask Tri, your AI tutor

How to cite: Addaly (2026) ‘The same build, everywhere’, in Shipping It. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/cloud-and-shipping/build-once-run-anywhere (Accessed: 13 October 2026).