Docker and Containers

Containers, images, Dockerfile best practices, and multi-stage builds for packaging and distributing applications.

What is Docker?

Docker is a platform that lets you package applications together with all their dependencies into isolated units called containers. A container includes the code, the runtime, the libraries, and the configuration needed for the application to run identically on any machine.

Unlike virtual machines, containers share the host operating system’s kernel, which makes them much more lightweight and faster to start.

Core concepts

Image

An image is an immutable package that contains everything needed to run an application. It is built from a Dockerfile and stored in a registry (such as Docker Hub, ECR, or GCR).

Images are made up of layers. Each Dockerfile instruction creates a new layer. Docker caches the layers that don’t change, which speeds up subsequent builds.

Container

A container is a running instance of an image. It is ephemeral by nature — when it stops, its internal state is lost (unless volumes are used).

Registry

A registry is an image repository. The most common ones are:

  • Docker Hub: the default public registry
  • Amazon ECR: private registry on AWS
  • Google GCR / Artifact Registry: registry on GCP
  • GitHub Container Registry: integrated with GitHub

Dockerfile: best practices

The Dockerfile defines how an image is built. A well-written Dockerfile produces images that are small, secure, and fast to build.

Basic example

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "dist/server.js"]

Best practices

  1. Use small base images: prefer alpine or slim variants over full images
  2. Order instructions by change frequency: the layers that change least go first to take advantage of the cache
  3. Copy dependencies before the code: COPY package*.json before COPY . to cache npm install
  4. Don’t run as root: create an unprivileged user with USER
  5. Use .dockerignore: exclude node_modules, .git, test files, and documentation
  6. Minimize the number of layers: combine related RUN commands with &&
  7. Don’t install unnecessary tools: every extra package increases the attack surface

Example with a non-root user

FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]

Multi-stage builds

Multi-stage builds let you use multiple stages in a single Dockerfile. This is especially useful for separating the build stage from the runtime stage, producing much smaller final images.

Why use multi-stage?

  • The final image doesn’t include build tools (TypeScript compiler, build tools, etc.)
  • The image size is drastically reduced
  • The attack surface in production is minimized

Example: Node.js application with TypeScript

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

# Stage 2: Production
FROM node:20-alpine AS production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]

In this example, the final image contains only the compiled code and the production dependencies — no TypeScript, no devDependencies, no source code.

Docker Compose for development

Docker Compose lets you define and run multi-container applications. It’s ideal for the local development environment:

version: '3.8'
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://postgres:password@db:5432/myapp
    depends_on:
      - db
      - redis

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_PASSWORD: password
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  pgdata:

Container security

  • Scan images: use tools like Trivy, Snyk, or Docker Scout to detect vulnerabilities
  • Update base images: keep base images up to date with security patches
  • Don’t store secrets in images: use environment variables or secret managers at runtime
  • Limit resources: configure CPU and memory limits to prevent a container from consuming all the host’s resources
  • Use signed images: verify the provenance of images with Docker Content Trust

Summary

Docker simplifies packaging and distributing applications by ensuring that the same artifact works in any environment. The keys are: small images with multi-stage builds, Dockerfile best practices, security by design, and Docker Compose for local development.