Containerizing Legacy Node.js Apps with Docker and Buildpacks
A five-year-old Node.js app that has always run on a bare VM gets its first Dockerfile written the way most first Dockerfiles get written: FROM node, COPY . ., npm install, CMD node server.js. It builds. It runs locally. It also ships an 850MB image with the entire devDependencies tree, runs as root inside the container, and has no way for an orchestrator to tell the difference between starting up and crashed-but-still-technically-alive.
This guide takes that exact legacy app through three approaches — a naive single-stage Dockerfile, a proper multi-stage build, and Cloud Native Buildpacks — and shows what each one costs and buys you. The running example is a monolithic Express app with a package-lock.json and a handful of native dependencies, something every team has one of. For the container runtime this image will actually run under, see Docker vs Kubernetes, and for the CI pipeline that should build and push it automatically, see CI/CD with GitHub Actions.
Why the first Dockerfile is almost always wrong
A single-stage Dockerfile that copies the whole repo and runs npm install bundles three problems into one image. It installs devDependencies (test frameworks, linters, type packages) that the running app never needs, inflating the image by hundreds of megabytes. It runs the container process as root by default, so a remote code execution vulnerability in any dependency gets root inside the container for free. And it copies the entire build context — .git, test fixtures, local .env files if .dockerignore is missing — into a shippable artifact that may leak secrets or bloat the layer cache with files that never change together.
None of these show up as a build failure. The image builds successfully and the app runs — which is exactly why naive Dockerfiles survive in production for years before someone notices the image is ten times larger than it needs to be, or a security scan flags a root-owned process.
Quick reference
- devDependencies in the shipped image is pure waste — nothing in production imports a test runner or a linter.
- A container running as root by default means any RCE in a dependency runs as root — a USER node line costs one line and closes that gap.
- A missing .dockerignore silently ships .git, node_modules from the host, and any local .env file straight into the image.
- Successfully building and running is not evidence the image is safe or efficient — measure image size and process UID, don't infer them.
Remember this
A Dockerfile that builds successfully has not been checked for the three things that actually matter in production — image size, process privilege, and what accidentally got copied in — those need explicit verification, not inference from a green build.
Multi-stage builds: separate what compiles from what runs
A multi-stage Dockerfile uses one stage to install dependencies and run the build (transpiling TypeScript, bundling assets) and a second, separate stage that only copies the compiled output and production dependencies into a slim runtime base image. The build tools, source maps, and devDependencies never make it into the final image — they existed only in the discarded build stage.
The production-only install distinction matters here: running the full install (with dev dependencies) in the builder stage to get build tools, then a separate production-only install into the runtime stage, keeps the shipped image to roughly what the app actually needs at runtime.
Quick reference
- Install full dependencies (including dev) only in the builder stage; the runtime stage runs a production-only install for a lean node_modules.
- node:22-slim (or a distroless base) as the runtime image drops build toolchains, compilers, and shells the running app never needs.
- USER node (the image's built-in non-root user) removes root privilege from the running process for one line of Dockerfile.
- A HEALTHCHECK that calls a real health endpoint lets an orchestrator distinguish a hung process from a healthy one — a bare CMD never reports that difference.
Remember this
Separating the build stage from the runtime stage means devDependencies and build tools physically cannot leak into the shipped image — there's no flag to forget, because the discarded stage never gets copied forward.
Cloud Native Buildpacks: no Dockerfile at all
Cloud Native Buildpacks (used by pack build and platforms like Google Cloud Run's source deploy) take a different approach: instead of writing a Dockerfile, a buildpack inspects the source (finds package.json, detects the Node version from engines or .nvmrc) and constructs an optimized, layered image automatically. You give up fine-grained control over exactly what's in each layer in exchange for not maintaining a Dockerfile at all — useful for teams standardizing many small services where a hand-tuned Dockerfile per repo is not worth the maintenance cost.
The trade-off surfaces exactly where you'd expect: a legacy app with an unusual native dependency, a non-standard start script, or a build step the buildpack doesn't recognize needs either a project.toml override or falls back to a hand-written Dockerfile. Buildpacks are a strong default for straightforward apps and a poor fit for anything with bespoke build logic.
Quick reference
- pack build my-app --builder gcr.io/buildpacks/builder needs no Dockerfile — it detects Node from package.json.
- Buildpacks apply security patches to the base OS layer independently of your build — a real operational win over a Dockerfile frozen at whatever base image tag you pinned a year ago.
- Use project.toml to override detected build/run commands when the app's start script isn't the buildpack's default guess.
- Fall back to a hand-written multi-stage Dockerfile for apps with native compilation steps or unusual runtime dependencies the buildpack doesn't detect.
Remember this
Buildpacks trade fine-grained control for zero Dockerfile maintenance and automatic base-layer patching — the right default for standard apps, and the wrong tool the moment a build has bespoke steps a buildpack can't infer.
The healthcheck that hides a broken start command
A container can build successfully, start successfully (the process launches, docker ps shows it running), and still be completely broken — a missing environment variable causes the Express app to throw during route registration and exit the event loop in a way that leaves the Node process technically alive but never listening on its port. Without a HEALTHCHECK that actually calls the app's health endpoint, an orchestrator has no way to know the difference between starting up and silently broken, and will keep routing traffic to a container that can never answer it.
This is the single most common legacy-containerization incident: a config value that worked implicitly on the old VM (an environment variable set globally at the OS level) has no equivalent in the container, the app fails to read it, and the failure is invisible until a request actually times out against a port that never opened.
Quick reference
- A HEALTHCHECK must call a real endpoint that exercises the actual listener, not just check that the process PID exists.
- Audit every environment variable the legacy app reads implicitly from the host — each one needs an explicit ENV or secret injection in the container.
- Fail fast and loud: log a clear missing-required-environment-variable error and exit non-zero, rather than continuing into a half-initialized state.
- Test the failure path directly — start the container with a required variable unset and confirm the healthcheck actually reports unhealthy within its configured retries.
Remember this
A container can build and start successfully while never actually serving a request — a healthcheck that calls the real endpoint, not just checks the process is alive, is what turns that silent failure into something an orchestrator can detect and restart.
Key takeaway
Take one small Express app through all three approaches: write the naive single-stage Dockerfile first and record its image size and running user. Then rewrite it as the multi-stage build above and compare — expect a substantially smaller image and the running user reporting node instead of root.
Break it on purpose: unset a required environment variable the app depends on and start the container — confirm the HEALTHCHECK reports unhealthy within its configured retries instead of the container appearing to run indefinitely. Pass criterion: the multi-stage image is smaller and non-root, and the healthcheck visibly fails on the broken-config case rather than leaving a silently-unresponsive container marked healthy.
Related Articles
Explore this topic