Docker has a reputation for being complicated, which keeps people avoiding it far longer than they should. The core idea is small: package your app with everything it needs to run, so it runs the same everywhere. Here's the practical starter.
Image vs container
- An image is a frozen snapshot — your app + its dependencies + runtime. Built once, immutable.
- A container is a running instance of an image. You can run many containers from one image.
- Analogy: the image is the class, the container is the object.
A real Dockerfile, line by line
# start from an official Node image (the base layer)
FROM node:22-alpine
# everything runs relative to /app inside the container
WORKDIR /app
# copy manifests first so 'npm ci' is cached unless deps change
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# now copy the rest of the source
COPY . .
# document the port the app listens on
EXPOSE 3000
# the command that runs when the container starts
CMD ["node", "server.js"]Copying package files and installing BEFORE copying source is the key optimisation — Docker caches that layer, so you don't reinstall dependencies on every code change. Big speed win.
The commands you need
docker build -t myapp . # build an image, tag it "myapp"
docker run -p 3000:3000 myapp # run it, map host:container port
docker ps # list running containers
docker logs <id> # see a container's output
docker exec -it <id> sh # shell into a running container
docker stop <id> # stop itAdd a .dockerignore
node_modules
.git
.env
dist
*.logThat's genuinely enough to be productive: understand image-vs-container, write a cached Dockerfile, and know six commands. Compose, multi-stage builds and orchestration come later — but you can containerise a real app today with just this.