How to Use Docker: A Practical Introduction
Docker packages an application with everything it needs (runtime, libraries, config defaults) into an image, and runs that image as an isolated process called a container. The same image runs the same way on a laptop, a CI runner and a server. This post covers the workflow you use day to day and the defaults worth setting from the start.
Five terms
- Image: a read-only template, built from a
Dockerfile. Identified by name and tag, for examplenginx:stable. - Container: a running (or stopped) instance of an image, with its own writable layer.
- Registry: where images are stored and pulled from. Docker Hub is the default, others include GitHub Container Registry, Amazon ECR and Google Artifact Registry.
- Volume: storage managed by Docker that outlives containers. Databases and uploads go here.
- Network: containers on the same user-defined network reach each other by name.
Install and check
On macOS and Windows, install Docker Desktop. On Linux, install Docker Engine from Docker's own package repository as described in the official install guide for your distribution. Distribution packages are often older. Then:
docker version
docker run --rm hello-world
On Linux, containers run directly on the host kernel. Docker Desktop runs them in a lightweight VM, which has two practical effects: bind-mounted source directories are slower than on Linux, and the memory and CPU available to all containers are capped by the VM settings.
Everyday commands
docker pull nginx:stable # download an image
docker run -d --name web -p 8080:80 nginx:stable # start a container
docker ps # running containers (-a for all)
docker logs -f web # follow output
docker exec -it web sh # shell inside the container
docker stop web && docker rm web # stop and remove
docker images # local images
Containers are disposable. To change a container's settings, remove it and create a new one. Anything that must survive goes into a volume.
Build your own image
A Dockerfile for a Node.js app with a build step, using a multi-stage build so build tools and dev dependencies stay out of the final image:
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
And a .dockerignore next to it:
node_modules
.git
.env
dist
docker build -t my-app:1.0 .
docker run -d --name my-app -p 3000:3000 my-app:1.0
Points that keep images small and builds fast:
- Start from a small base.
-alpineor-slimvariants, or distroless images for compiled languages. - Order layers by how often they change. Dependency manifests first,
npm ci, then the source. A code change then reuses the cached dependency layer. - Multi-stage builds. Compilers, dev dependencies and caches stay in the build stage.
- Run as a non-root user with
USER. Many official images include one (nodehere). - Tag with versions, not only
latest, so you know what runs and can roll back.
Multi-container apps with Docker Compose
Most apps need more than one container. Docker Compose describes them in one file, compose.yaml, and manages them together. Compose v2 is part of the Docker CLI as docker compose (with a space). The old docker-compose binary and the top-level version: key are obsolete.
services:
app:
build: .
image: my-app:1.0
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
restart: unless-stopped
volumes:
db-data:
Put DB_PASSWORD=... in a .env file next to compose.yaml (and keep it out of git). Compose substitutes it into the file. Services reach each other by service name, so the app connects to db:5432. depends_on with service_healthy waits for the database health check before starting the app.
docker compose up -d --build # build if needed, create and start everything
docker compose ps # status of the project's containers
docker compose logs -f app # logs of one service
docker compose exec db psql -U app # shell or tool inside a service
docker compose down # stop and remove containers and network
docker compose down -v # same, and delete volumes (data is gone)
For environment-specific changes, keep the base file and add an override: docker compose -f compose.yaml -f compose.prod.yaml up -d. Later files override earlier ones. A file named compose.override.yaml is picked up automatically, which is handy for local development settings.
Run containers with sensible defaults
Whether you use plain docker run or Compose, a few settings prevent the usual production surprises.
Limit resources. Without limits, one container can take all of the host's memory and trigger the kernel OOM killer for everything else.
docker run -d --name my-app --memory 512m --cpus 1 my-app:1.0
In Compose, use deploy.resources.limits with memory and cpus under the service.
Rotate logs. The default json-file logging driver keeps everything a container prints, without rotation, until the disk is full. Set a default in /etc/docker/daemon.json:
{
"log-driver": "local",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Restart the daemon afterwards. The setting applies only to containers created after the change, so recreate existing ones.
Use a restart policy. --restart unless-stopped (or restart: unless-stopped in Compose) brings containers back after crashes and reboots.
Keep state out of containers. Databases and uploads in named volumes, configuration in environment variables or mounted files. Then replacing a container is always safe.
Publish only what is needed. -p 127.0.0.1:3000:3000 when only a local reverse proxy should reach the app. Ports published on all interfaces bypass host firewalls like ufw because Docker manages its own iptables rules.
Clean up regularly. Old images and stopped containers pile up:
docker system df # what uses space
docker image prune # dangling images
docker system prune # stopped containers, unused networks, dangling images, build cache
docker system prune does not touch volumes unless you add --volumes. Read the confirmation prompt before you agree.
Quick reference
docker run -d --name NAME -p HOST:CONTAINER IMAGE:TAG
docker ps -a / docker logs -f NAME / docker exec -it NAME sh
docker build -t my-app:1.0 .
docker compose up -d --build / docker compose logs -f / docker compose down
docker run --memory 512m --cpus 1 --restart unless-stopped ...
docker system df / docker system prune
