I have walked into too many server rooms where the answer to every problem was "reinstall it." I have also watched teams burn entire weekends because an application ran on the developer's laptop and nowhere else. Docker ended most of those conversations for me, and it can do the same for you.
This guide is the one I wish I had when I started. It covers the mental model first, then the commands, then the production habits that separate hobby containers from infrastructure you can sleep through the night with. It is long on purpose. Bookmark it and come back as you grow.
If you prefer a shorter, gentler first read, start with my beginner Docker tutorial and return here when you are ready for the full picture.
What Docker actually solves
Docker packages an application with everything it needs to run, libraries, config files, environment variables, and the runtime, into a single immutable unit called a container image. That image runs identically on a laptop, a test server, or a cloud VM. The "works on my machine" excuse dies the moment the same image produces the same behavior everywhere.
Under the hood, Docker is not magic. It relies on two Linux kernel features:
- Namespaces isolate processes, networking, filesystems, users, and hostnames so each container believes it owns the machine.
- Cgroups limit and account for CPU, memory, and I/O so one noisy container cannot starve its neighbors.
A container shares the host kernel. That is why it boots in seconds and weighs megabytes, while a virtual machine boots an entire guest operating system and weighs gigabytes. The trade-off is isolation strength. A VM boundary is harder to cross than a container boundary, which matters for security decisions we will revisit later.
How the engine is actually put together
When you type docker run, more than one piece of software is involved. Docker Engine is the client and daemon you interact with, but since Docker 1.11 the heavy lifting has been delegated to specialized components:
- containerd manages the container lifecycle, image storage, and the container runtime interface.
- runc is the OCI runtime that actually creates and starts the container processes using namespaces and cgroups.
This split matters because it is why Docker, containerd, and Kubernetes can coexist. Kubernetes removed the Docker daemon from its control plane years ago and talks to containerd directly. The images you build today still run there, because they follow the OCI image spec. Understanding that layers, images, and runtimes are standardized is what makes the whole ecosystem portable instead of proprietary.
Image versus container
This is the first mental model to lock in.
- Image is the immutable blueprint. Read-only layers stacked on top of each other. Think of an installer ISO.
- Container is a running instance of an image with a thin writable layer on top. Think of the machine you installed.
docker pull ubuntu # download an image
docker images # list local images
docker run ubuntu echo hi # create + start a container from an image
docker ps # list running containers
docker ps -a # list all containers, including stopped
docker stop <id> # stop a running container
docker rm <id> # delete a stopped container
Deleting a container removes its writable layer. Anything written inside it disappears unless it lives in a volume. We will get to volumes shortly, but remember this: containers are ephemeral by design.
The Dockerfile: how images are built
A Dockerfile is a recipe. Every instruction produces a layer, and layers are cached. That single fact drives most performance advice you will ever read about Dockerfiles.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The order above is deliberate. Dependencies are installed before the application code is copied, so a code change reuses the cached dependency layers. If you copy everything first and install second, every code change invalidates the entire dependency cache and turns every build into a full rebuild.
Base image choices
- Alpine is tiny and popular for final images, but its musl libc can break native binaries compiled against glibc. Test before you trust it.
-
Distroless images contain only the runtime and your binary, no shell, no package manager. Excellent for security, painful for debugging, since you cannot
execinto a shell easily. - Slim variants (node:20-slim, python:3.12-slim) are a pragmatic middle ground.
Build with a context, and keep it small. A .dockerignore file prevents your node_modules, .git, and build artifacts from being shipped into the build context.
# .dockerignore
node_modules
.git
dist
*.log
Multi-stage builds
Multi-stage builds let you compile in a fat image and copy only the artifacts into a slim final image.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
The final image contains only Nginx and your static files. The entire Node toolchain never ships.
Slimming images down
Image size is not vanity. Smaller images pull faster, start faster, and expose a smaller attack surface. Beyond multi-stage builds, a few habits keep images lean:
-
Prefer slim bases.
node:20-slimovernode:20saves hundreds of megabytes for a one-line change. - Compile in the builder stage, copy only the artifact. Build tools never belong in the final image.
-
Clean package caches in the same layer that creates them.
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*stops the layer from carrying dead weight. -
Build with BuildKit. It is the default in modern Docker and enables
--mount=type=cache, so package managers reuse caches across builds instead of downloading the world every time. -
Build multi-architecture images with
docker buildx build --platform linux/amd64,linux/arm64so one tag works on x86 servers and ARM boxes alike.
A healthy habit: run docker system df once a week and prune what you no longer use. Old images and dangling layers accumulate silently, and on a busy CI server disk fills faster than anyone expects.
Running containers properly
docker run has flags you will use every day.
docker run -d --name web -p 8080:80 -v webdata:/data --restart unless-stopped nginx
-
-druns in the background. -
--namegives the container a stable name. -
-p host:containermaps ports. -
-v name:/pathattaches a named volume. -
--restart unless-stoppedbrings it back after reboots and crashes.
Resource limits are not optional
An unlimited container on a shared host is an accident waiting to happen. A runaway process inside the container can exhaust host memory and trigger the OOM killer on other workloads. I wrote a full breakdown of exactly this failure mode in a Docker container crash case study, where a missing memory limit took down a production service at night.
docker run -d --name api \
--memory 512m --memory-swap 512m \
--cpus 0.5 \
-p 3000:3000 \
my-api:latest
Set limits in Compose too, and set reservations so the scheduler knows the floor.
Volumes: surviving container death
Containers are ephemeral. Volumes are the escape hatch. A volume is storage managed by Docker that outlives any container.
docker volume create dbdata
docker run -d -v dbdata:/var/lib/postgresql/data postgres:16
-
Named volumes are managed by Docker, stored in
/var/lib/docker/volumes, and are the production default for databases. - Bind mounts map a host directory into the container. Great for development because your editor writes directly into the container's view. Dangerous in production because host filesystem quirks leak into the runtime.
Databases in containers are a defensible choice for small and medium workloads, provided the data lives in a named volume and backups run against that volume, not against the container. If you manage storage at the OS level, the LVM recovery case study shows what happens when the underlying disk layer misbehaves.
Backing up and restoring volumes
Backups run against the volume, not the container. The container is disposable, the data is not. The classic approach is a tar pipeline while the service is stopped or quiescent:
docker run --rm -v dbdata:/data -v /backup:/backup alpine \
tar czf /backup/dbdata-$(date +%F).tar.gz -C /data .
To restore, reverse the pipeline:
docker run --rm -v dbdata:/data -v /backup:/backup alpine \
tar xzf /backup/dbdata-2026-08-17.tar.gz -C /data
Two cautions. First, tar on a live database is at best a crash-consistent copy, so for Postgres and MySQL prefer their native dump tools, pg_dump or mysqldump, piped into the same pattern. Second, test the restore. A backup you have never restored is a rumor, not a backup.
Networking
By default, containers attach to a bridge network and reach each other by name. Compose creates one network per project automatically.
docker network ls
docker network inspect bridge
docker port web
Three network modes matter:
- bridge: default. Isolated virtual network with port mapping to the host.
- host: container shares the host network stack. Lower latency, but the container can bind any host port, which weakens isolation.
- none: no networking. Useful for security-focused sidecar processes.
For inter-container communication, prefer service names over IP addresses. IPs change when containers restart.
Docker Compose: your everyday workflow
Once an application has more than one moving part, web, database, cache, you stop typing docker run repeatedly and start defining everything in docker-compose.yml.
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
api:
build: .
environment:
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/app
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
dbdata:
Three details here prevent real incidents.
-
Environment via variables, not secrets in the file. Use an
.envfile for local development and a secret manager in production. -
Healthchecks make
depends_onmeaningful. Withoutcondition: service_healthy, the API starts before Postgres accepts connections and crashes during startup. -
Restart policies (
unless-stopped) keep services alive across host reboots.
The compose file in my MERN migration case study shows a full production stack, backend, frontend, managed database, and object storage, orchestrated with exactly these patterns.
CI/CD: build, scan, and ship on every push
Manual docker build on a server is a smell. The image you tested locally should be the image that runs in production, which means building once in a pipeline and promoting the same artifact. A minimal GitHub Actions workflow does the job:
name: build-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 }}
# Build locally first, no push yet
- uses: docker/build-push-action@v6
with:
load: true
tags: my-api:scan
# Scan the local image; exit-code 1 blocks the pipeline on HIGH/CRITICAL
- uses: aquasecurity/trivy-action@master
with:
image-ref: my-api:scan
severity: HIGH,CRITICAL
exit-code: "1"
# Only now push to the registry
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
The scan step runs before the push, so a broken image never reaches the registry. Whether you use GitHub Actions, GitLab CI, or a self-hosted runner, the pattern is the same: build once, scan, tag, push, and let the deployment pull that exact digest.
Production hardening checklist
Move a containerized application to production and these become non-negotiable:
Healthchecks
Every service that serves traffic should expose a health endpoint, and Docker should probe it.
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
start_period matters. It gives slow-starting applications time to warm up before failures count.
Log rotation
By default, Docker writes container logs to JSON files without limits. A chatty application can fill the disk. The docker-crash case study above is exactly what happens when logs grow unchecked.
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Non-root user
Running as root inside a container is a common hardening gap. Add a dedicated user in your image.
RUN addgroup -S app && adduser -S app -G app
USER app
Read-only filesystem
Mark the filesystem read-only when the application does not need to write.
read_only: true
tmpfs:
- /tmp
Scan images
Run vulnerability scanners on your images before deployment. Trivy and Grype are both free and can run in CI.
trivy image my-api:latest --severity HIGH,CRITICAL
For the broader security picture, the Linux server hardening best practices guide covers the host side, SSH, firewalls, and user management, which protects the Docker host itself.
Monitoring containers in production
Healthchecks tell you a service is alive, not that it is well. For the second part you need metrics. Start with the built-in tools, then graduate to a stack when the fleet grows.
-
docker statsgives instant CPU and memory per container. It is the first thing to run when something feels slow. -
docker eventsstreams lifecycle events, container started, stopped, killed. Invaluable when an automation script restarts things behind your back. - cAdvisor exposes per-container metrics in Prometheus format and is a ten-minute addition to any Compose stack.
- Prometheus plus Grafana is the standard pairing once you want history, alerts, and dashboards. Set alerts on restart counts and memory pressure, not just uptime.
The principle is simple: you cannot fix what you cannot see, and you cannot see what you never measured. Install the dashboard before the incident, not after.
Debugging containers under pressure
When something breaks at 2 AM, these are the commands you reach for, in order.
docker ps -a # what is actually running?
docker logs --tail 200 api # what did the app say before dying?
docker inspect api # what does Docker think the state is?
docker stats # who is eating resources?
df -h # is the disk full?
The exit code is your first clue. Exit 137 usually means the kernel OOM killer terminated the container. Exit 1 means the application failed on its own. The full diagnostic workflow, including reading kernel OOM logs, is documented step by step in the Docker container crash case study.
docker inspect api --format '{{.State.Status}} OOMKilled={{.State.OOMKilled}} Exit={{.State.ExitCode}}'
Updating containers without downtime
Updating a container is not a mystery. Pull the new image, recreate the container, and verify. With Compose:
docker compose pull
docker compose up -d
docker compose ps
For a single container, docker pull followed by docker rm -f and docker run with the same flags does the same job, or docker compose up -d handles the recreation for you.
Three habits keep updates boring:
-
Always pull explicitly.
docker runwith:latestuses the cached image unless you pull first. A server that has not been updated in weeks runs a very old image while everyone assumes it is current. -
Pin versions in production.
:latestis a moving target. Use a specific tag or digest in your deployment so a rebuild does not silently change behavior. - Have a rollback. Keep the previous tag and know the one command to return to it. The five minutes spent writing down the rollback procedure will save you an hour at 3 AM.
Complete walkthrough: from a folder to production
Theory is cheap, so here is the shortest path from a bare repository to a running service, using everything above.
- Write the Dockerfile with the dependency-first order and a non-root user.
-
Add a
.dockerignoreso the build context stays small. - Define the stack in Compose with a healthcheck, log limits, memory limits, and a named volume for anything persistent.
-
Test locally:
docker compose up -d, hit the service, break it, watch the logs. - Build, scan, and push from CI so the artifact is reproducible and vetted.
- Pull and run on the server with the same Compose file, or wire it into your orchestration of choice.
- Verify with a health check from outside the host, then walk away.
Every step in this list appears in this guide, and every failure mode, missing healthcheck, unbounded logs, no memory limit, root user, shows up in the case studies linked above. Build the habit on a staging service first. When the process bores you, you are ready for production.
When Docker is not the answer
Docker is a tool, not a religion. Some workloads do not belong in containers.
- GUI-heavy desktop applications gain little from containerization.
- Stateful high-IO databases at large scale often perform better with dedicated instances and specialized storage.
- Real-time kernels or specialized hardware drivers can fight container isolation.
- Windows-only legacy applications need a Windows container host, which changes the whole cost equation.
For everything else, containers are the default answer. If you are still deciding whether containers or plain VMs fit a workload, think in terms of team velocity and isolation requirements, not fashion.
Where to go next in the Docker universe
Docker is the foundation. The ecosystem above it is where most of the career value lives.
- Orchestration: when you have many containers across multiple hosts, you need a scheduler. Kubernetes is the industry standard, but start with Docker Swarm for learning the concepts on a smaller scale. Do not jump to Kubernetes until single-host Compose feels boring. If you are weighing the jump, my Docker vs Kubernetes comparison gives the honest criteria I use with clients.
- CI/CD: build and scan images automatically on every push. GitHub Actions and GitLab CI both have first-class Docker support.
- Registry: push images to Docker Hub or a private registry so deployments pull exactly the tested artifact.
- Secrets: never bake secrets into images. Use Docker secrets, a vault, or your cloud provider's secret store.
If you are planning the learning path, my guide on how long it takes to learn Docker gives a realistic timeline, and my building a Docker container from scratch guide shows how to assemble an image layer by layer without a base OS.
For automation beyond containers, Ansible handles configuration management on the hosts themselves, and Terraform provisions the infrastructure those hosts run on. Containerize the app, automate the host, provision the cloud, and you have the complete modern stack.
Frequently asked questions
Is Docker a virtual machine? No. Containers share the host kernel and isolate processes, while VMs run a full guest operating system. That is why containers are lighter and faster to start, and why they are weaker at the kernel boundary.
Do I need Docker Desktop to run containers? On Windows and macOS, Docker Desktop is the easiest path because it runs a Linux VM behind the scenes. On Linux, install the Docker Engine directly, no desktop layer required.
Are containers safe to run untrusted code? Not by default. Treat a container as a lightweight process with a few extra walls, not as a sandbox. For untrusted code, use a dedicated VM or a gVisor-style runtime.
What is the difference between Docker and Kubernetes? Docker runs containers on a single host. Kubernetes schedules containers across many hosts and handles networking, storage, and self-healing. Learn Compose on one server before you touch Kubernetes.
Do I need Docker if I use serverless? Different problems. Serverless hides the server from you, Docker gives you full control over the server. Most real deployments use both at different layers.
Final words
Docker rewards boring discipline. Healthchecks, log limits, resource limits, non-root users, read-only filesystems, image scanning. None of these are glamorous, and all of them are what separates a demo from a service.
Start small. Containerize one unimportant service first, run it with Compose, break it, and fix it. When that becomes routine, the rest of the ecosystem stops looking intimidating.
I have been running containers in production for years. The containers themselves rarely cause incidents. The missing healthcheck, the unbounded log, the root user, the absent memory limit, those cause incidents. This guide exists so yours do not.
I hope this complete Docker guide helps you make better decisions in real-world situations.


Top comments (0)