Docker Tutorial: Learn Containerization from Scratch (2026)
I remember the first time I tried to deploy a Rails app on a fresh Ubuntu server. Ruby version mismatch, missing gem dependencies, a PostgreSQL library that refused to link — I spent six hours troubleshooting before the app even started. That night I discovered Docker, and it fundamentally changed how I think about software delivery. Containers wrap your application and its entire environment into a single, portable unit that runs identically on your laptop, your teammate's machine, a staging server, or a production cluster. This tutorial will take you from zero to productive with Docker, covering everything you need to containerize applications confidently in 2026.
Understanding Containers vs Virtual Machines
A container is not a miniature virtual machine. When I explain this to teams, I draw a simple diagram: a VM runs a full guest operating system on top of a hypervisor, consuming gigabytes of disk and seconds to boot. A container shares the host kernel and runs as an isolated user-space process, starting in milliseconds and using megabytes. Docker uses Linux kernel features like namespaces for isolation and cgroups for resource limits. On Windows, Docker Desktop leverages Hyper-V and WSL 2 to provide the same experience. The result is density: you can run dozens of containers on a single host where you might only run three or four VMs. In production environments I have managed, container density routinely reaches 20 to 30 containers per host with minimal overhead.
docker run -d --name web --memory=256m --cpus=0.5 nginx:alpine
docker stats web
docker inspect web | Select-String -Pattern "Memory"
Working with Docker Images and Registries
Images are read-only templates that define what runs inside a container. When I first used Docker, I treated images like black boxes — I pulled them from Docker Hub and ran them without understanding their structure. The real power comes when you realize images are layered filesystems. Each instruction in a Dockerfile creates a new layer that Docker caches. If you rebuild the image and only the last few lines changed, Docker reuses every prior layer from cache. This makes rebuilds nearly instant during development. You publish images to registries — Docker Hub, Amazon ECR, Google Artifact Registry, or a self-hosted Harbor instance. Tagging matters: never use latest in production. Pin to semantic versions or commit hashes so your deployments are reproducible.
docker pull python:3.12-slim
docker tag myapp registry.example.com/myapp:v1.2.3
docker push registry.example.com/myapp:v1.2.3
Writing Production-Grade Dockerfiles
A good Dockerfile is a craft. Early on, I wrote Dockerfiles that produced 2 GB images and took ten minutes to build. The turning point was learning multi-stage builds. You use one stage with the full SDK to compile your application, then copy only the compiled artifacts into a slim runtime image. For a Go application, the final image can drop from 800 MB to under 15 MB. Other practices matter too: order your layers from least to most frequently changing so Docker caches package installs; run apt-get update and install in the same layer to avoid stale cache bloat; use a non-root user for security; set working directory explicitly; and leverage .dockerignore to exclude unnecessary files from the build context.
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM alpine:3.20
RUN adduser -D appuser
COPY --from=builder /app/server /server
USER appuser
CMD ["/server"]
Container Networking and Data Persistence
Containers are ephemeral by design — when a container stops, everything written to its writable layer disappears. That is great for stateless applications and terrible for databases. The solution is volumes, which store data on the host filesystem and survive container restarts. Docker manages named volumes, but you can also bind-mount specific directories for development hot-reloading. For networking, Docker creates a bridge network by default where containers communicate via IP. But you should create custom user-defined bridge networks where containers resolve each other by service name. This is how Compose-based applications work under the hood: each service becomes a hostname that other services can reach.
docker network create --driver bridge app-net
docker volume create postgres-data
docker run -d --name db --network app-net -v postgres-data:/var/lib/postgresql/data postgres:16
Orchestrating Multiple Containers with Docker Compose
Running one container is straightforward. Running a web app, a database, a cache, and a message queue with all their networks, volumes, and environment variables becomes unwieldy with raw docker commands. Compose lets you define everything in a YAML file and spin up the entire stack with a single command. I use Compose daily for local development environments. The version 3 format supports service definitions with health checks, dependency ordering, resource limits, and volume mounts. For production-like setups, you can extend the same Compose file with override files for different environments. The profiles feature in recent versions lets you define optional services like admin panels or monitoring that start only on demand.
services:
web:
build: .
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
Best Practices and Security Hardening
Over the years I have seen containers deployed with root access, hardcoded secrets in environment variables, and images pulled from untrusted sources. Each of these is a preventable vulnerability. Root in a container is the same root as on the host if the container escapes — always create a non-root user in your Dockerfile. Use Docker Content Trust to sign and verify image publishers. Run containers with read-only root filesystems when the application does not need to write to disk. Scan images with tools like Trivy or Snyk before deployment. In 2026, most CI pipelines include an automatic image scan step that fails the build if critical vulnerabilities are found. Finally, never embed secrets in images — use Docker secrets or a dedicated secrets manager like HashiCorp Vault injected at runtime.
docker run --read-only --tmpfs /tmp --user appuser --cap-drop ALL --cap-add NET_BIND_SERVICE myapp:latest
docker scout quickview myapp:latest
docker scout recommendations myapp:latest
Frequently Asked Questions
What is the difference between an image and a container?
An image is a read-only template with instructions for creating a container. A container is a running instance of that image — the executable environment with its own filesystem, network, and process tree. Think of an image as a class and a container as an object instantiated from that class.
Should I use Alpine-based images for everything?
Alpine images are small because they use musl libc instead of glibc. This works for most Go, Rust, and Python applications, but some C extensions and Java runtimes have compatibility issues with musl. Always test your application on the base image you choose. For Java workloads, Eclipse Temurin-based images are the standard despite being larger.
How do I handle secrets in Docker without exposing them?
Use Docker BuildKit's --secret flag during builds to pass credentials without including them in the image layers. At runtime, use docker secret create for Swarm deployments or mount secrets from a secure store like Vault. Never use ARG or ENV for sensitive values in Dockerfiles.
Can containers replace virtual machines in production?
Containers and VMs serve different purposes. Containers provide application-level isolation and are ideal for microservices. VMs provide kernel-level isolation and are better for running untrusted workloads or when you need a different operating system kernel. Most production architectures use both: VMs as the host infrastructure and containers inside them for application deployment.
Originally published on Ayodhyyya. Last updated June 1, 2026.