Docker is one of the most impactful tools in modern software development, yet it remains intimidating to developers who haven't used it before. This guide explains Docker from first principles, then walks through containerizing a real application — no prior DevOps experience required.
Why Docker Exists: The Problem It Solves
The classic developer complaint: "It works on my machine." Your app runs fine locally with Node.js 20 and PostgreSQL 15. Your teammate has Node.js 18 and PostgreSQL 14 and gets different behavior. The production server runs CentOS with a system-installed Node.js 16 and the app crashes on startup.
Docker solves this by packaging your application and everything it needs to run — the runtime, system libraries, configuration, and dependencies — into a single self-contained unit called a container. That container runs identically on any machine that has Docker installed, from a developer's MacBook to a Linux server in a data center.
Core Concepts: Images and Containers
Two terms trip up every Docker beginner:
- Image: A read-only template — a blueprint that describes the filesystem, environment variables, and startup command for a container. Images are built from a
Dockerfile. - Container: A running instance of an image. You can run many containers from the same image simultaneously. A container is disposable — you stop it, delete it, and start a fresh one with no state carried over (unless you use volumes).
The analogy: an image is a class definition; a container is an instance of that class.
Installing Docker
# macOS / Windows: Install Docker Desktop from docker.com
# Linux (Ubuntu/Debian):
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # allow running docker without sudo
newgrp docker
# Verify install
docker --version # Docker version 26.x.x
docker run hello-world
Your First Dockerfile
A Dockerfile is a text file that defines how to build an image. Here's one for a Node.js application:
# Start from the official Node.js 22 LTS image (Alpine = minimal Linux)
FROM node:22-alpine
# Set working directory inside the container
WORKDIR /app
# Copy dependency manifests first (for layer caching)
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci --only=production
# Copy the rest of the application code
COPY . .
# Expose port 3000 (documentation only — doesn't publish it)
EXPOSE 3000
# Default command when the container starts
CMD ["node", "src/index.js"]
Build and run it:
# Build the image, tag it "my-app"
docker build -t my-app .
# Run a container, publish port 3000 to host port 3000
docker run -p 3000:3000 my-app
# Run in detached (background) mode
docker run -d -p 3000:3000 --name my-app-container my-app
# View running containers
docker ps
# View logs
docker logs my-app-container
# Stop and remove
docker stop my-app-container
docker rm my-app-container
Docker Layer Caching
Docker builds images in layers — each instruction in a Dockerfile is a layer. Layers are cached: if a layer hasn't changed, Docker reuses it from cache instead of rebuilding. This is why you should copy package.json and run npm install before copying your source code:
# Good: dependency layer cached until package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . . # source changes here don't bust the npm cache
# Bad: any source change forces npm install to re-run
COPY . .
RUN npm ci
In a large project, this difference can mean rebuilds taking 30 seconds instead of 3 minutes.
Docker Compose: Running Multiple Services
Most applications need more than one container — a web server, a database, a cache. Docker Compose lets you define all of them in a single compose.yml (formerly docker-compose.yml) and start them with one command.
# compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=myapp
- DB_USER=postgres
- DB_PASS=secret
depends_on:
db:
condition: service_healthy
volumes:
- .:/app # mount source code for hot reload in dev
- /app/node_modules # keep container's node_modules
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
volumes:
- postgres_data:/var/lib/postgresql/data # persist DB data
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
docker compose up # start all services (attached)
docker compose up -d # start in background
docker compose down # stop and remove containers
docker compose down -v # also remove volumes (wipes DB data)
docker compose logs -f app # tail logs for the app service
docker compose exec app sh # open a shell inside the app container
Volumes: Persisting Data
Containers are stateless — when you remove a container, its filesystem is gone. Volumes are the mechanism for persisting data outside the container:
# Named volume (managed by Docker — survives container removal)
docker volume create mydata
docker run -v mydata:/app/data my-app
# Bind mount (maps a host directory into the container)
docker run -v $(pwd)/data:/app/data my-app
# Read-only bind mount
docker run -v $(pwd)/config:/app/config:ro my-app
In development: use bind mounts so code changes on your host are reflected inside the container instantly. In production: use named volumes for database files; never bind-mount source code.
Multi-Stage Builds for Production
Production images should be small and contain no build tools. Multi-stage builds let you use a fat image for building and a minimal image for the final artifact:
# Stage 1: Build TypeScript
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build # outputs to /app/dist
# Stage 2: Production runtime (minimal)
FROM node:22-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist # only copy compiled output
EXPOSE 3000
CMD ["node", "dist/index.js"]
The production image only contains the compiled JavaScript and production dependencies — no TypeScript compiler, no devDependencies. This typically reduces image size by 40–60%.
Containerizing a PHP Application
FROM php:8.3-fpm-alpine
# Install PHP extensions
RUN apk add --no-cache \
libpng-dev libjpeg-dev \
&& docker-php-ext-install pdo pdo_mysql gd
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader
EXPOSE 9000
CMD ["php-fpm"]
PHP-FPM runs PHP processing; Nginx handles HTTP. In Compose, you'd pair them:
services:
php:
build: .
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- .:/var/www/html
depends_on:
- php
Useful Docker Commands Cheat Sheet
# Images
docker images # list local images
docker pull postgres:17 # download image
docker rmi my-app # remove image
docker image prune # remove dangling images
# Containers
docker ps # running containers
docker ps -a # all containers (including stopped)
docker stop <id> # graceful stop
docker kill <id> # force stop
docker rm <id> # remove container
docker container prune # remove all stopped containers
# Debugging
docker exec -it <id> sh # shell into running container
docker logs -f <id> # tail logs
docker inspect <id> # full container metadata
docker stats # live resource usage
# Cleanup
docker system prune -a # remove everything unused (images, containers, volumes, networks)
Docker in CI/CD
Building and pushing Docker images in GitHub Actions:
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Summary
Docker's core value is deterministic environments — the same container runs identically across development, staging, and production. Start with a simple Dockerfile for your app, add a compose.yml to wire up the database and cache, and use multi-stage builds for production images. Once you've containerized one project, the pattern becomes straightforward to apply everywhere.
CREESOL