"It works on my machine" was a joke for so long that people forget it described a real and expensive problem. I have watched a release get delayed two days because a server had Node 16 and a developer's laptop had Node 18, and one dependency behaved differently between them. Nobody was careless. The environments had simply drifted, the way environments always do.
Containers fixed that, and the fix was less exotic than it seemed at the time. Here is what is actually happening underneath, and the practices that separate a Dockerfile that works from one you would put in front of customers.
Containers Are Not Small Virtual Machines
This is the misconception that makes everything else confusing.
A virtual machine emulates hardware. It runs its own kernel, boots an operating system, and the hypervisor divides the physical machine between guests. Strong isolation, and you pay for it: gigabytes of disk, hundreds of megabytes of memory before your application starts, and boot times measured in tens of seconds.
A container is just a process on the host, with the kernel lying to it about what it can see. Namespaces restrict its view of the filesystem, network, process list and users. Cgroups cap the CPU and memory it may consume. There is no guest kernel and no boot. Starting a container is starting a process — milliseconds, not seconds.
Two consequences follow directly, and both matter.
First, containers share the host kernel. That is why a Linux container cannot run on Windows without a Linux VM underneath, and it is why container isolation is weaker than VM isolation. A kernel vulnerability is a path out. For running your own code, that is an acceptable trade; for running untrusted code from strangers, people add another layer.
Second, a container is not a place to live. It is one process. When that process exits, the container is finished. This is why people are surprised that their container "will not stay running" — it ran the command, the command finished, and everything worked as designed.
Images, Layers, and Why Your Build Is Slow
An image is a stack of read-only layers. Each instruction in a Dockerfile that changes the filesystem creates one. Running a container adds a thin writable layer on top, which is discarded when the container is removed.
The thing worth internalising: layers are cached, and the cache invalidates from the first change downward. Change one instruction and every instruction after it re-runs. This single fact explains most slow builds.
# Slow: any source change re-runs npm ci
COPY . .
RUN npm ci
# Fast: dependencies only reinstall when package files change
COPY package*.json ./
RUN npm ci
COPY . .
Order your Dockerfile from least likely to change to most likely. Dependencies before source, always. On a project I worked on this took the build from four minutes to about thirty seconds, and that difference compounds across every developer and every CI run for the life of the project.
The other consequence of layering catches people out with secrets. Deleting a file in a later layer does not remove it from the image — the earlier layer still contains it, and anyone can extract it. If a credential was ever copied in, it is in the image, no matter what you did afterwards. Use build secrets or multi-stage builds instead.
A Production Dockerfile
Multi-stage builds are the single biggest improvement available. Build with the full toolchain, ship only the result.
# ---- build ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# ---- run ----
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
The compiler, the dev dependencies and the source never reach the final image. Typical result on a Node service: from over a gigabyte to somewhere around 150MB. Smaller images pull faster, start faster and — because they contain less software — have fewer vulnerabilities for a scanner to find.
Other things I treat as required:
Pin the base image tag.
node:20-alpine, notnode:latest. Reproducibility is the entire point of containers; a floating base tag throws it away.Do not run as root. One line. Removes a whole category of escalation.
Use a
.dockerignore. Without it you are copyingnode_modules,.gitand possibly.envinto the build context, which is slow at best and a leak at worst.Use exec form for CMD.
CMD ["node", "dist/main.js"], notCMD node dist/main.js. Shell form wraps your process in a shell that swallows SIGTERM, so your application never gets the chance to shut down gracefully and gets killed after the timeout instead.
Compose Is for Development
Docker Compose earns its keep by making a whole local environment one command. A new developer clones the repository, runs docker compose up, and has the API, the database and Redis running with the right versions.
services:
api:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://dev:dev@db:5432/app
depends_on:
db: { condition: service_healthy }
volumes:
- .:/app # live reload in dev only
- /app/node_modules
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev"]
interval: 5s
retries: 5
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:
Two details that save time. The service_healthy condition means the API waits for Postgres to actually accept connections, not merely for the container to exist — otherwise your app races the database on every startup. And the anonymous volume on node_modules stops the host directory from shadowing what was installed inside the image, which is the cause of the mysterious "cannot find module" on macOS and Windows.
For production, Compose is workable on a single small server, and I have shipped it that way for internal tools. Once you need rolling updates, health-gated deploys or more than one machine, move to a platform that does that properly — a managed container service or Kubernetes.
Where Data Lives
The writable layer disappears with the container. Anything that must survive goes in a volume, and a named volume managed by Docker is what you want for databases — it lives outside the container lifecycle and does not depend on host paths.
Bind mounts, which map a host directory in, are for development: your source on the host, live-reloaded inside the container. They are not for production data.
And the honest advice about databases in containers: for local development, absolutely. For production, use a managed database. Backups, failover, patching and point-in-time recovery are worth paying for, and a Postgres container on a single host has none of them.
Mistakes I Keep Seeing
Baking configuration into the image, so you need a different image per environment. Configuration comes in at runtime as environment variables; the image should be identical everywhere.
Secrets in the Dockerfile or in docker-compose.yml committed to the repository. They end up in image layers and in git history, both permanent.
Installing a package manager cache and not cleaning it, leaving hundreds of megabytes of nothing in the image.
One container running several processes with a supervisor inside. If you need three processes, you need three containers — otherwise the orchestrator cannot see or restart the one that died.
Never scanning images. Containers are software supply chain, and a base image from a year ago has known vulnerabilities. Run a scanner in CI and rebuild regularly, even when your code has not changed.
Where to Start
Containerise one service you already understand. Get the multi-stage build working, add a .dockerignore, run it as a non-root user, and make sure it shuts down cleanly when you press Ctrl+C. Then add Compose so the whole local environment comes up with one command.
That is most of the value, and it arrives on day one — new developers productive in minutes, and the environment on your laptop being genuinely the same as the one in production. Everything else in the container ecosystem is built on top of understanding that layer well.



