Docker solves a simple problem: "it works on my machine". It packages your application and everything it needs to run into a unit that behaves the same on a laptop, a CI server and production.
Images and containers
An image is a read-only template: an operating-system layer, your runtime, your dependencies and your code. A container is a running instance of an image. You can start many containers from one image, and throw them away without losing anything that was not stored outside them.
A minimal Dockerfile
A Dockerfile describes how to build an image. For a Node.js service it can be this short:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]Copying package*.json and installing dependencies before copying the rest of the code is deliberate. Docker caches each step, so unchanged dependencies are not reinstalled on every code change.
Running it
Build the image and run a container, mapping a port to your machine:
docker build -t my-api .
docker run -p 3000:3000 my-apiVolumes keep data
A container's own filesystem disappears with it. For anything that must survive, such as a database, mount a volume. It lives outside the container and is reattached when you start a new one.
Compose for multi-service setups
Most apps need more than one service. Docker Compose describes them together in a single file, so docker compose up starts your API and its database with one command. This is the easiest way to give every teammate an identical development environment.
Habits worth adopting
- Use small base images such as
alpinevariants where they are compatible. - Add a
.dockerignoresonode_modulesand local files stay out of the image. - Never bake secrets into an image. Pass them as environment variables at runtime.
- Run as a non-root user in production.
Once these basics feel natural, CI/CD pipelines and cloud deployments become far less mysterious: they are mostly the same image, built and run somewhere else.