Skip to content

Expert · Deployment and scaling · topic 48 of 54 · 30 min

Docker: packaging your app

Explanation

Docker packs your app together with everything it needs (the right Node.js version, libraries, settings) into an image. You run that image as a container, and it behaves the same on your laptop, a test server and production. A Dockerfile is the recipe for the image, and Docker Compose starts your app with Postgres and Redis in one command.

The "works on my machine" problem

Your API works on your laptop. On the server it crashes, because the server has an older Node.js, a missing library or a different setting. Docker fixes this by shipping the app with its environment. If the container runs on your laptop, the same container runs on the server.

Image and container

An image is a read-only package: a small Linux system, the runtime (for example Node.js), your code and its dependencies. A container is a running copy of an image, with its own processes and network. You can start many containers from one image, and stop and throw them away at any time.

Think of it like this: An image is a recipe card for dal makhani, written once and printed many times. A container is a plate of dal made from that recipe. Ten customers can each get a plate from the same card, every plate tastes the same, and finishing one plate does not change the recipe.

A Dockerfile for a Node.js API

output
# Stage 1: build
FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: run
FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
A multi-stage Dockerfile: build tools stay in stage 1; the final image has only what is needed to run.
  1. FROM picks the base image. node:24-slim is a small Debian image with a Node.js LTS version. Always pin a version; :latest can change under you.
  2. COPY package*.json and RUN npm ci come BEFORE COPY . . so Docker can reuse the installed packages (its layer cache) when only your code changes. Builds go from minutes to seconds.
  3. Multi-stage: the first stage compiles; the second copies only the result. The final image is smaller and has fewer tools an attacker could use.
  4. USER node runs the app as a normal user, not root, so a bug in your app cannot take over the whole container as easily.
  5. CMD is the command that runs when the container starts.
output
# .dockerignore: files Docker must NOT copy into the image
node_modules
.git
.env
dist
*.log
Keeps the image small and, most importantly, keeps your .env secrets out of it.

Build, run and inspect

bash
docker build -t notes-api:1.4.0 .                 # build an image from the Dockerfile here
docker run -d -p 8080:3000 --env-file .env --name notes notes-api:1.4.0
docker ps                                          # list running containers
docker logs -f notes                               # follow the container's logs
docker stop notes && docker rm notes               # stop and remove it
-p 8080:3000 maps port 8080 on your machine to port 3000 inside the container.

Ports: a container has its own network, so your app listening on port 3000 inside it is not reachable until you publish the port with -p host:container. Volumes: a container's files disappear when it is removed. A volume is storage that lives outside the container, so database files survive restarts and upgrades.

Docker Compose: app + Postgres + Redis

output
# compose.yaml (local development only)
services:
  api:
    build: .
    ports:
      - "8080:3000"
    environment:
      DATABASE_URL: postgres://app:devpass@db:5432/notes
      REDIS_URL: redis://cache:6379
    depends_on:
      - db
      - cache
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: notes
    volumes:
      - pgdata:/var/lib/postgresql/data
  cache:
    image: redis:7
volumes:
  pgdata:
docker compose up -d starts all three. Inside the network, the app reaches Postgres at the host name db.

A registry is a store for images, like an app store for containers. Docker Hub, GitHub Container Registry (ghcr.io), AWS ECR and Google Artifact Registry are common. Your CI builds the image once, pushes it with a version tag, and every server pulls exactly that image.

Careful: Never put secrets in the Dockerfile (ENV DB_PASSWORD=...) or copy .env into the image. Anyone who can pull the image can read them. Pass secrets at run time with environment variables or your platform's secret manager.
Tip: Tag images with a version or the git commit (notes-api:1.4.0 or notes-api:3f9c2ab). Then you always know what is running, and rolling back is just running the previous tag.

Watch the lesson

Docker: packaging your app · 1/7

Docker

It works on my machine is a famous problem. Docker fixes it by shipping your app together with its environment.

Prefer a video by a teacher? Find free videos on “docker tutorial for beginners node js dockerfile compose” on YouTube ↗

In real life: A new developer joins a team in Bengaluru

Before Docker, a new developer spent two days installing the right Postgres, Redis and Node.js versions, and still had a bug that "only happens on Priya's laptop". Now the project has a compose.yaml. The new developer clones the repo, runs docker compose up, and the API, a Postgres 17 database and Redis are running in five minutes, with the same versions as production. The CI builds the same Dockerfile, so what was tested is exactly what is deployed.

Practice (0/3 done)

Task 1 · Code · Javascript

docker run -p takes a mapping like "8080:3000" (host port : container port). Write parsePortMapping(text) that returns { host: 8080, container: 3000 } as numbers. If the text does not have exactly two parts, or either part is not a whole number from 1 to 65535, throw an Error.

Task 2 · Code · Javascript

Write lintDockerfile(text) that returns a list of warnings, in this order. 1) For every FROM line whose image has no tag or the tag latest, add "line N: pin a version instead of latest (IMAGE)". 2) If some RUN line contains "npm ci" or "npm install", and a "COPY . <dest>" line (source is exactly ".") comes before the first such RUN line, add "line N: COPY . comes before npm install" for that COPY line. 3) If there is no USER line at all, add "no USER line: the container will run as root" at the end. N is the 1-based line number. Warnings 1 and 2 go in line order.

Task 3 · Practice

Write a compose.yaml for local development of a Notes API with Postgres and Redis, plus a .dockerignore. Start it with docker compose up -d, check docker ps shows three containers, then stop it with docker compose down.

Quick quiz

Question 1

1.What is the difference between an image and a container?

Question 2

2.Why copy package*.json and run npm ci BEFORE COPY . . in a Dockerfile?

Question 3

3.What does -p 8080:3000 mean in docker run?

Question 4

4.Where should Postgres keep its data when it runs in a container?

Question 5

5.Which line makes a container safer by not running as root?

Read more (free)

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

← Previous: Linux basics for backend developers