DevPath · Learn to code ESPTEN

Docker and CI/CD

Containers and Docker

The problem: "it works on my machine"

An application depends on an environment: a specific version of Node, certain variables, system libraries... If that environment differs between your laptop and the server, errors appear that are hard to reproduce. Containers solve this by packaging the application together with its environment so it runs the same everywhere.

Image vs container

This is the most important distinction in the topic:

From the same image you can launch many containers. The image is built once; containers are created, stopped and destroyed continuously.

The Dockerfile

An image is defined with a Dockerfile: a recipe of instructions that are executed top to bottom.

# Base image we start from
FROM node:20-alpine

# Working directory inside the container
WORKDIR /app

# Copy the manifests first (to take advantage of the cache, see below)
COPY package.json package-lock.json ./
RUN npm ci

# Copy the rest of the source code
COPY . .

# Command that runs when the container starts
CMD ["node", "server.js"]

The key instructions:

Layers and build cache

Each instruction generates a layer. Docker caches each layer: if an instruction and everything before it have not changed, it reuses the layer instead of re-running it. That's why we copy package.json and run npm ci before copying the rest of the code: as long as the dependencies don't change, that layer (the slow one) is reused even if you edit your source code.

Practical rule: put what changes little at the top and what changes a lot at the bottom.

Multi-stage builds

To build you need tools (compilers, dev-dependencies) that you don't want in the final image. A multi-stage build uses one stage to build and another, clean and small, to run:

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

# Stage 2: final image, only with what's needed to run
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

The final image contains neither the source code nor the dev-dependencies: only the build result (dist) and the production dependencies. Result: smaller images, faster to deploy and with a smaller attack surface.

Multiple services: docker-compose

A real app combines several containers (API, database, cache...). docker-compose describes and orchestrates them in a single file:

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/app
    depends_on:
      - db
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: pass
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

With docker compose up you bring up the whole stack at once. Services see each other by their name (db), and volumes persist data even though the container is destroyed.

Put this into practice

DevPath is a hands-on course: you read the theory here; in the app you put it into practice with exercises that really run, offline.

Start free in the app →
Continuous integration (CI) →