{"course":{"slug":"cloud-and-shipping","title":"Shipping It","url":"https://addaly.com/learn/cloud-and-shipping","level":"intermediate","track":"build"},"lesson":{"slug":"build-once-run-anywhere","title":"The same build, everywhere","url":"https://addaly.com/learn/cloud-and-shipping/build-once-run-anywhere","position":11,"of":74,"minutes":9,"answer":"Build one artifact from a locked dependency tree and move that exact thing between environments, because anything rebuilt per environment is a different program.","keyIdea":"Build one artifact from a locked dependency tree and move that exact thing between environments, because anything rebuilt per environment is a different program."},"body":{"format":"markdown","text":"## The claim you want to be able to make\n\n\"The thing running in production is byte-for-byte the thing I tested.\"\n\nAlmost 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.\n\n## Lockfiles, and the command that respects them\n\nYour `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.\n\n- `npm install` may update the lockfile to satisfy the manifest.\n- `npm ci` deletes `node_modules`, installs exactly what the lockfile says, and **fails** if the lockfile and manifest disagree.\n\nThat 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`.\n\nPin 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.\n\n## Containers, briefly and honestly\n\nA 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.\n\nIt 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.\n\nFour things worth knowing before you write one:\n\n**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.\n\n```dockerfile\nFROM node:22-slim\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci --omit=dev\nCOPY . .\nCMD [\"node\", \"server.js\"]\n```\n\n**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.\n\n> Figure: Image size is mostly base image. Bars (MB): node:22 1100; node:22-slim 200; node:22-alpine 130. 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.\n\n**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.\n\n**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.\n\nDocker 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.\n\n## Build in CI, not on your laptop\n\nA 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.\n\nThe 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.\n\n## Promote the artifact, do not rebuild it\n\nThe pattern to aim for:\n\n1. Build once from a tagged commit. Produce a container image or a bundle.\n2. Test that artifact.\n3. Deploy that same artifact to staging.\n4. Deploy that same artifact to production.\n\nIf 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.\n\nTag 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."},"check":{"question":"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?","options":[{"text":"The lockfile is out of date, so CI installed a different version of the bundler","correct":false,"tempting":"Reasonable instinct, but a dependency mismatch would fail on a package name, not on a relative path to your own file."},{"text":"The import uses different capitalisation from the filename, which the case-insensitive macOS filesystem forgives and the case-sensitive Linux filesystem does not","correct":true},{"text":"CI checked out a shallow clone that omitted the file","correct":false,"tempting":"Shallow clones limit history depth, not which files are present at the checked-out commit."},{"text":"The `.dockerignore` or `.gitignore` is excluding the file from the build context","correct":false,"tempting":"Would mean the file is not committed at all, which contradicts it being in the repository, and would show up as missing in a plain checkout too."}]},"prev":{"slug":"secrets-and-env","title":"The key you must not commit","url":"https://addaly.com/learn/cloud-and-shipping/secrets-and-env"},"next":{"slug":"what-is-actually-running","title":"Which commit is live right now","url":"https://addaly.com/learn/cloud-and-shipping/what-is-actually-running"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"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).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"The same build, everywhere","courseTitle":"Shipping It","module":{"index":2,"slug":"making-a-deploy-boring","title":"Making a deploy boring"},"answer":null,"keyIdea":"Build one artifact from a locked dependency tree and move that exact thing between environments, because anything rebuilt per environment is a different program.","hero":{"kind":"figure","figure":{"t":"figure","label":"Figure","figure":{"type":"bars","unit":"MB","title":"Image size is mostly base image","series":[{"label":"node:22","value":1100},{"label":"node:22-slim","value":200,"highlight":true},{"label":"node:22-alpine","value":130}],"caption":"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."},"alt":"Figure: Image size is mostly base image. Bars (MB): node:22 1100; node:22-slim 200; node:22-alpine 130. 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."}},"blocks":[{"t":"h2","id":"the-claim-you-want-to-be-able-to-make","runs":[{"text":"The claim you want to be able to make"}]},{"t":"p","runs":[{"text":"“The thing running in production is byte-for-byte the thing I tested.”"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"lockfiles-and-the-command-that-respects-them","runs":[{"text":"Lockfiles, and the command that respects them"}]},{"t":"p","runs":[{"text":"Your "},{"text":"package.json","code":true},{"text":" says "},{"text":"\"express\": \"^4.18.0\"","code":true},{"text":". The caret means “any 4.x above this”. Your lockfile records the exact version and every transitive dependency underneath it."}]},{"t":"ul","items":[[{"text":"npm install","code":true},{"text":" may update the lockfile to satisfy the manifest."}],[{"text":"npm ci","code":true},{"text":" deletes "},{"text":"node_modules","code":true},{"text":", installs exactly what the lockfile says, and "},{"text":"fails","b":true},{"text":" if the lockfile and manifest disagree."}]]},{"t":"p","runs":[{"text":"That failure is the feature. Use "},{"text":"npm ci","code":true},{"text":" in CI and in your image build; use "},{"text":"npm install","code":true},{"text":" only when you are deliberately changing dependencies. The same distinction exists everywhere: "},{"text":"pip install -r requirements.txt","code":true},{"text":" against a pinned file or "},{"text":"uv sync","code":true},{"text":", "},{"text":"bundle install --deployment","code":true},{"text":", "},{"text":"cargo build --locked","code":true},{"text":"."}]},{"t":"p","runs":[{"text":"Pin the runtime too. "},{"text":".nvmrc","code":true},{"text":", an "},{"text":"engines","code":true},{"text":" field, a "},{"text":"python","code":true},{"text":" version in "},{"text":"pyproject.toml","code":true},{"text":", 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."}]},{"t":"h2","id":"containers-briefly-and-honestly","runs":[{"text":"Containers, briefly and honestly"}]},{"t":"p","runs":[{"text":"A container image is a filesystem plus a command. It solves one problem well: the machine’s own state (system libraries, locales, that "},{"text":"imagemagick","code":true},{"text":" you installed in 2023) stops being part of your program."}]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"Four things worth knowing before you write one:"}]},{"t":"p","runs":[{"text":"Layer order decides your build time.","b":true},{"text":" Each instruction is a cached layer, invalidated by the first change. Copy your lockfile and install dependencies "},{"text":"before","i":true},{"text":" copying your source, or every one-character edit reinstalls the world."}]},{"t":"code","lang":"dockerfile","label":"Dockerfile","text":"FROM node:22-slim\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci --omit=dev\nCOPY . .\nCMD [\"node\", \"server.js\"]"},{"t":"p","runs":[{"text":"Image size is mostly base image.","b":true},{"text":" "},{"text":"node:22","code":true},{"text":" is around 1.1 GB. "},{"text":"node:22-slim","code":true},{"text":" is around 200 MB. "},{"text":"node:22-alpine","code":true},{"text":" is around 130 MB, and Alpine uses musl instead of glibc, so native modules like "},{"text":"sharp","code":true},{"text":" or "},{"text":"bcrypt","code":true},{"text":" may fail to build, or build and then behave differently. Use "},{"text":"-slim","code":true},{"text":" unless you have measured a reason."}]},{"t":"p","runs":[{"text":"A ","b":true},{"text":".dockerignore","b":true,"code":true},{"text":" is not optional.","b":true},{"text":" Without one you copy "},{"text":"node_modules","code":true},{"text":", "},{"text":".git","code":true},{"text":" and your "},{"text":".env","code":true},{"text":" into the image. The last one is a leaked secret sitting in a layer that people can pull."}]},{"t":"p","runs":[{"text":"Architecture is a real trap.","b":true},{"text":" Building on Apple Silicon produces an arm64 image. Deploying that to an x86 server gives "},{"text":"exec format error","code":true},{"text":", which reads like a corrupt binary. Build with "},{"text":"--platform linux/amd64","code":true},{"text":", or build in CI, which is x86 by default."}]},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"build-in-ci-not-on-your-laptop","runs":[{"text":"Build in CI, not on your laptop"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"The one that stings is case sensitivity. macOS filesystems are case-insensitive by default; Linux is not. "},{"text":"import Button from \"./button\"","code":true},{"text":" 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."}]},{"t":"h2","id":"promote-the-artifact-do-not-rebuild-it","runs":[{"text":"Promote the artifact, do not rebuild it"}]},{"t":"p","runs":[{"text":"The pattern to aim for:"}]},{"t":"ol","start":1,"items":[[{"text":"Build once from a tagged commit. Produce a container image or a bundle."}],[{"text":"Test that artifact."}],[{"text":"Deploy that same artifact to staging."}],[{"text":"Deploy that same artifact to production."}]]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"Tag the artifact with the git commit SHA, and print that SHA on a "},{"text":"/version","code":true},{"text":" endpoint. When somebody asks “is the fix live?”, the answer becomes a URL rather than an argument."}]}],"check":{"question":[{"text":"Your app builds and runs on your Mac. The build in CI, on Linux, fails with “Cannot find module ‘./button’”, and the file "},{"text":"src/Button.tsx","code":true},{"text":" is definitely there and definitely committed. What has happened?"}],"options":[{"text":[{"text":"The lockfile is out of date, so CI installed a different version of the bundler"}],"correct":false,"tempting":[{"text":"Reasonable instinct, but a dependency mismatch would fail on a package name, not on a relative path to your own file."}]},{"text":[{"text":"The import uses different capitalisation from the filename, which the case-insensitive macOS filesystem forgives and the case-sensitive Linux filesystem does not"}],"correct":true,"tempting":null},{"text":[{"text":"CI checked out a shallow clone that omitted the file"}],"correct":false,"tempting":[{"text":"Shallow clones limit history depth, not which files are present at the checked-out commit."}]},{"text":[{"text":"The "},{"text":".dockerignore","code":true},{"text":" or "},{"text":".gitignore","code":true},{"text":" is excluding the file from the build context"}],"correct":false,"tempting":[{"text":"Would mean the file is not committed at all, which contradicts it being in the repository, and would show up as missing in a plain checkout too."}]}],"order":[2,1,0,3],"why":null},"prev":{"slug":"secrets-and-env","title":"The key you must not commit","minutes":8},"next":{"slug":"what-is-actually-running","title":"Which commit is live right now","minutes":8}}}