Docker packages an application together with its dependencies into a portable image that runs identically on a laptop, a CI runner, or a production server. It solves the classic "works on my machine" problem by making the runtime environment part of the artifact you ship. This guide covers the mental model, writing efficient Dockerfiles, the day-to-day CLI, persistence and networking, Compose, and slimming your images.
Images vs Containers
An image is a read-only template built from layers. A container is a running (or stopped) instance of an image with a thin writable layer on top. One image can spawn many containers, just as one class can produce many objects.
| Concept | Nature | Analogy |
|---|---|---|
| Image | Immutable, layered template | A class / blueprint |
| Container | Running instance with writable layer | An object / instance |
| Registry | Store for named, versioned images | A package repository |
Containers are ephemeral
Anything written inside a container's writable layer is lost when the container is removed. Persist real data in volumes, and treat containers as disposable, replaceable units.
Dockerfile Instructions
A Dockerfile is a recipe. Each instruction that changes the filesystem produces a new layer. The most common instructions:
| Instruction | Purpose |
|---|---|
FROM | Base image to build on |
WORKDIR | Set working directory for later steps |
COPY / ADD | Copy files into the image |
RUN | Execute a command at build time |
ENV | Set environment variables |
EXPOSE | Document the listening port |
CMD / ENTRYPOINT | Default command when the container starts |
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
# Copy manifests first so npm install is cached across code changes
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Now copy source (changes here won't bust the install layer)
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]
Layer Caching
Docker caches each layer and reuses it if the instruction and its inputs are unchanged. The moment one layer's input changes, every layer after it is rebuilt. The golden rule: order instructions from least to most frequently changing. Dependency manifests change rarely, so copy and install them before copying application source.
Why COPY order matters
If you COPY . . before installing dependencies, editing a single source file invalidates the cache and forces a full reinstall on every build. Splitting the copy keeps the expensive install step cached.
Multi-Stage Builds
Multi-stage builds use one stage to compile and a second, minimal stage to run. Build tools, compilers, and source never reach the final image, which stays small and has a smaller attack surface.
# syntax=docker/dockerfile:1
# ---- build stage ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/app ./cmd/server
# ---- runtime stage ----
FROM gcr.io/distroless/static-debian12
COPY --from=build /bin/app /app
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]
The Docker CLI
These commands cover the everyday build, run, inspect, and debug loop.
docker build -t myapp:1.0 . # build and tag from ./Dockerfile
docker run -d --name web -p 8080:3000 myapp:1.0 # run detached, map port
docker ps # running containers
docker ps -a # include stopped ones
docker logs -f web # follow container logs
docker exec -it web sh # open a shell inside a container
docker stop web && docker rm web # stop then remove
docker images # list local images
docker rmi myapp:1.0 # remove an image
docker system prune -af # reclaim space (dangling + unused)
Volumes: Persisting Data
Use named volumes for data Docker manages (databases), and bind mounts to map a host directory into a container (local development).
docker volume create pgdata
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:17
# bind mount host code for live reload during development
docker run -it -v "$(pwd)":/app -w /app node:22-alpine npm run dev
docker volume ls # list volumes
docker volume inspect pgdata
docker volume rm pgdata # delete (must not be in use)
Networks
Containers on the same user-defined bridge network can reach each other by container name via built-in DNS. This is how a web app finds its database without hardcoding IPs.
docker network create appnet
docker run -d --name db --network appnet postgres:17
docker run -d --name api --network appnet -e DB_HOST=db myapi:1.0
# 'api' resolves 'db' by name over appnet's DNS
docker network ls
docker network inspect appnet
Docker Compose
Compose defines multi-container applications in a single YAML file. One command brings the whole stack up, with a network created automatically so services find each other by name.
# compose.yaml
services:
api:
build: .
ports:
- "8080:3000"
environment:
DB_HOST: db
DB_PASSWORD: ${DB_PASSWORD}
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
volumes:
pgdata:
docker compose up -d # build + start in background
docker compose ps # status of services
docker compose logs -f api # follow one service's logs
docker compose exec db psql -U postgres
docker compose down -v # stop and remove, including volumes
.dockerignore
Like .gitignore, this file keeps unneeded files out of the build context. That speeds up builds and prevents secrets or bulky directories from leaking into images.
# .dockerignore
node_modules
.git
.env
*.log
dist
Dockerfile
docker-compose*.yml
Image Size Optimization
Smaller images pull faster, cost less to store, and expose fewer vulnerabilities. Key techniques:
| Technique | Effect |
|---|---|
| Slim/alpine or distroless base | Starts hundreds of MB smaller |
| Multi-stage builds | Drops compilers and source |
| Combine RUN steps, clean caches | Fewer, leaner layers |
.dockerignore | Smaller build context |
# chain apt in one layer and clean up to avoid caching package lists
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
docker history myapp:1.0 # see the size of each layer
docker scout cves myapp:1.0 # scan the image for known vulnerabilities
Registries
A registry stores and distributes images. Docker Hub is the default; teams also use GitHub Container Registry (GHCR), Amazon ECR, or a private registry. Tag with the full registry path, then push.
docker login ghcr.io # authenticate
docker tag myapp:1.0 ghcr.io/acme/myapp:1.0
docker push ghcr.io/acme/myapp:1.0
docker pull ghcr.io/acme/myapp:1.0
# build for multiple architectures and push in one step
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/acme/myapp:1.0 --push .
Practice Exercises
- Write a Dockerfile for a small Node or Python app that copies dependency manifests before source code, and explain which layer is reused when you change a source file.
- Convert a single-stage Go or Java build into a multi-stage build with a distroless runtime stage, then compare image sizes with
docker images. - Run a Postgres container backed by a named volume, insert some data, remove and recreate the container, and confirm the data survived.
- Create a user-defined network with an API and a database container, and verify the API can reach the database by container name.
- Write a
compose.yamlwith a web service and a database that usesdepends_onwith a healthcheck, then bring it up with a single command. - Add a
.dockerignore, rebuild, and usedocker historyanddocker scout cvesto measure the size and vulnerability improvements.