Skip to content
intermediate Phase 16 · DevOps & Deployment

Docker for Full Stack

Containerize full stack apps with Docker, Docker Compose, multi-stage builds, and dev environments.

1h 15m
0 problems
Topic Progress 0%

Dockerfile Best Practices

Dockerfile Best Practices

Multi-Stage Builds for Production

Multi-stage builds drastically reduce final image size by separating build-time and run-time dependencies. The build stage contains compilers, dev tools, and source code, while the production stage only includes compiled artifacts.

# Stage 1: Install dependencies only
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Create non-root user for security
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup

# Copy only what is needed
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

USER appuser
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "dist/server.js"]

This approach produces a final image that is typically 60-80% smaller than a single-stage build. The deps stage caches npm ci so rebuilds only happen when package.json or package-lock.json change.

Layer Caching Optimization

Docker builds each instruction as a layer. Layers that change infrequently should come earlier in the Dockerfile so they are cached across builds.

# BAD: source code change invalidates npm ci cache
COPY . .
RUN npm ci

# GOOD: copy dependency manifests first
COPY package.json package-lock.json ./
RUN npm ci
# Now source changes only invalidate the COPY . layer
COPY . .

.dockerignore

A .dockerignore file prevents unnecessary files from being sent to the Docker daemon, reducing build context size and avoiding secrets leaking into images.

node_modules
.git
.env
.env.*
*.md
test
e2e
.github
docker-compose*.yml
Dockerfile*
.dockerignore
npm-debug.log
coverage
.nyc_output

Layer Consolidation

Each RUN instruction creates a new layer. Consolidate related commands to reduce layers and clean up in the same layer that installs packages.

# BAD: three layers, apt cache persists
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean

# GOOD: single layer, apt cache cleaned
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

Docker Compose for Multi-Service Apps

Docker Compose for Multi-Service Apps

Development Environment Setup

Docker Compose defines and runs multi-container applications with a single YAML file. Each service runs in its own container with its own image, volumes, and network.

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: builder
    ports:
      - '3000:3000'
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://postgres:password@db:5432/myapp
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    command: npm run dev
    networks:
      - backend

  db:
    image: postgres:15-alpine
    ports:
      - '5432:5432'
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'
    volumes:
      - redis_data:/data
    networks:
      - backend

volumes:
  postgres_data:
  redis_data:

networks:
  backend:
    driver: bridge

Development vs Production Overrides

Use separate compose files to override settings per environment without modifying the base file.

# docker-compose.dev.yml
services:
  app:
    environment:
      - NODE_ENV=development
      - DEBUG=app:*
    volumes:
      - ./src:/app/src
# docker-compose.prod.yml
services:
  app:
    build:
      target: runner
    environment:
      - NODE_ENV=production
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '0.5'
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
    logging:
      driver: json-file
      options:
        max-size: '10m'
        max-file: '3'
# Development
 docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Production
 docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Common Compose Commands

docker compose up -d              # start in detached mode
docker compose logs -f app        # follow logs for a service
docker compose exec app sh        # shell into a running container
docker compose down -v            # stop and remove volumes
docker compose ps                # list running services
docker compose build --no-cache   # rebuild without cache
docker compose config            # validate and display resolved config

Health Checks and Container Reliability

Health Checks and Container Reliability

Application Health Endpoints

Expose health and readiness endpoints in your application so Docker and orchestrators can determine container status.

// health.ts
import express from 'express';
import { db } from './database';
import { redis } from './cache';

const router = express.Router();

router.get('/health', async (_req, res) => {
  try {
    await db.query('SELECT 1');
    await redis.ping();
    res.json({
      status: 'healthy',
      uptime: process.uptime(),
      timestamp: new Date().toISOString()
    });
  } catch (error) {
    res.status(503).json({
      status: 'unhealthy',
      error: error instanceof Error ? error.message : 'Unknown error'
    });
  }
});

router.get('/ready', async (_req, res) => {
  const checks = await Promise.allSettled([
    db.query('SELECT 1'),
    redis.ping(),
  ]);
  const healthy = checks.every(c => c.status === 'fulfilled');
  res.status(healthy ? 200 : 503).json({ ready: healthy });
});

export default router;

Dockerfile Health Checks

The HEALTHCHECK instruction tells Docker how to test whether a container is still working. Docker marks the container as unhealthy if the check fails consecutively.

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
  • interval: Time between checks (default 30s)
  • timeout: Max time for a single check (default 30s)
  • start-period: Grace period before checks count (default 0s)
  • retries: Consecutive failures before unhealthy (default 3)

Graceful Shutdown

Handle SIGTERM to drain connections and finish in-flight requests before the container stops.

import { server } from './app';

function gracefulShutdown(signal: string) {
  console.log(`Received ${signal}. Starting graceful shutdown...`);
  server.close(() => {
    console.log('HTTP server closed');
    process.exit(0);
  });
  // Force close after 10 seconds
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 10000);
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

Resource Limits

Prevent a single container from consuming all host resources.

services:
  app:
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '0.5'
        reservations:
          memory: 256M
          cpus: '0.25'

Centralized Logging

Use the JSON log driver and collect logs with a centralized solution.

services:
  app:
    logging:
      driver: json-file
      options:
        max-size: '10m'
        max-file: '5'

For production, consider using the fluentd or gelf driver to ship logs to ELK, Datadog, or CloudWatch.

Docker Security Hardening

Docker Security Hardening

Non-Root Users

Running containers as root gives the process full access to the container filesystem and potential kernel exploits. Always create and switch to a non-root user.

FROM node:20-alpine

RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup

WORKDIR /app
COPY --chown=appuser:appgroup . .

USER appuser
EXPOSE 3000
CMD ["node", "server.js"]

Read-Only Filesystem

Mount the container filesystem as read-only to prevent tampering. Use tmpfs for writable directories like /tmp.

docker run --read-only --tmpfs /tmp:rw,noexec,nosuid myapp:latest

Secret Management

Never bake secrets into images or store them in environment variables committed to version control. Use Docker secrets or a runtime vault.

# docker-compose.yml with secrets
services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - jwt_key

secrets:
  db_password:
    file: ./secrets/db_password.txt
  jwt_key:
    file: ./secrets/jwt_key.txt
# Access secrets at /run/secrets/<name>
RUN --mount=type=secret,id=db_password \
    DB_PASS=$(cat /run/secrets/db_password) && \
    echo "DB configured"

Image Scanning

Scan images for known vulnerabilities before deploying to production.

# Docker Scout (built into Docker Desktop)
docker scout cves myapp:latest

# Trivy (open-source)
trivy image myapp:latest

# Grype
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  anchore/grype myapp:latest

Security Checklist

# Use specific base image versions, never :latest
FROM node:20.9.0-alpine

# Remove unnecessary packages
RUN apk add --no-cache curl && \
    apk del --purge build-deps

# Set restrictive permissions
RUN chmod 400 /app/config/secrets.json

# Use COPY instead of ADD (ADD has auto-extraction features)
COPY package.json ./

# Set filesystem ownership
COPY --chown=appuser:appgroup . .

Network Isolation

Use Docker networks to isolate service communication. Services that do not need to communicate should be on separate networks.

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge

services:
  nginx:
    networks:
      - frontend
  app:
    networks:
      - frontend
      - backend
  db:
    networks:
      - backend

Volumes and Data Persistence

Volumes and Data Persistence

Types of Storage

Docker offers three volume mounting options. Named volumes are managed by Docker and are the recommended approach for persistent data.

# Named volumes (Docker-managed)
docker volume create postgres_data
docker run -v postgres_data:/var/lib/postgresql/data postgres:15

# Bind mounts (host directory)
docker run -v $(pwd)/src:/app/src myapp:latest

# tmpfs mounts (in-memory, for sensitive data)
docker run --tmpfs /tmp:rw,noexec,nosuid myapp:latest

Docker Compose Volume Configuration

services:
  db:
    image: postgres:15-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d:ro
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

  app:
    build: .
    volumes:
      - ./src:/app/src
      - app_uploads:/app/uploads
    depends_on:
      - db

volumes:
  postgres_data:
    driver: local
  app_uploads:
    driver: local

Database Persistence Example

For PostgreSQL, ensure the data directory is always mounted to a named volume to survive container restarts and removals.

services:
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./migrations:/docker-entrypoint-initdb.d
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

Redis Persistence

services:
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data

volumes:
  redis_data:

Backup and Restore

# Backup a named volume
docker run --rm \
  -v postgres_data:/source:ro \
  -v $(pwd):/backup \
  alpine tar czf /backup/postgres_backup.tar.gz -C /source .

# Restore from backup
docker run --rm \
  -v postgres_data:/target \
  -v $(pwd):/backup:ro \
  alpine tar xzf /backup/postgres_backup.tar.gz -C /target

Volume Permissions

When using bind mounts, ensure the container user has write access to the mounted directory.

# Create the directory and set ownership
RUN mkdir -p /app/uploads && \
    chown -R appuser:appgroup /app/uploads

USER appuser
services:
  app:
    volumes:
      - ./uploads:/app/uploads
    user: "1001:1001"

Quiz

1. What is the primary benefit of multi-stage Docker builds?

Question 1 options

2. Why should you copy package.json before running npm ci in a Dockerfile?

Question 2 options

3. How do you prevent a Docker container from running as root?

Question 3 options

Flashcards

Question

What is Docker Deployment?

Answer

Docker Deployment covers important concepts and best practices.

Question

What is Docker Deployment?

Answer

Docker Deployment covers important concepts and best practices.

Question

What is Docker Deployment?

Answer

Docker Deployment covers important concepts and best practices.

Revision Notes

Key Takeaways

  • 1. Multi-stage builds separate build-time and run-time dependencies, producing smaller and more secure production images
  • 2. Copy dependency manifests before source code to leverage Docker layer caching and speed up rebuilds
  • 3. Always create non-root users in Dockerfiles and use the USER instruction to run processes with limited privileges
  • 4. Use Docker Compose with override files to manage environment-specific configurations without modifying the base file
  • 5. Implement health checks at both the Docker and application levels to enable automatic container health monitoring
  • 6. Store persistent data in named volumes, not bind mounts, to survive container removal and enable backup strategies
  • 7. Never bake secrets into images; use Docker secrets, environment injection at runtime, or vault solutions

Interview Tips

  • Explain how Docker layer caching works and how to structure Dockerfiles to maximize cache hits
  • Describe the difference between ADD and COPY instructions and when to use each
  • Discuss strategies for reducing Docker image size including multi-stage builds, Alpine base images, and .dockerignore
  • Explain how to handle database migrations and persistent storage in Docker environments
  • Describe container orchestration concepts and how Docker Compose fits into a production deployment pipeline
  • Discuss Docker security best practices including image scanning, network isolation, and secret management

Cheat Sheet

Dockerfile Instructions

FROM | WORKDIR /path | COPY | RUN | EXPOSE | USER | HEALTHCHECK CMD | ENTRYPOINT ["cmd"] | CMD ["args"]

Docker Compose Commands

docker compose up -d | docker compose down -v | docker compose logs -f | docker compose exec sh | docker compose build --no-cache | docker compose ps | docker compose config

Image Management

docker build -t name:tag . | docker images | docker rmi | docker image prune | docker tag name:tag | docker push name:tag

Volume Commands

docker volume create | docker volume ls | docker volume rm | docker volume inspect

Container Debugging

docker logs | docker exec -it sh | docker inspect | docker stats | docker top

Security

docker scout cves | trivy image | --read-only flag | --tmpfs /tmp | --cap-drop ALL