Why Containers Changed Everything
Before containers, deploying software was painful. "It works on my machine" was a meme because it was a constant reality. The developer's laptop had Python 3.8, a specific PostgreSQL client, certain environment variables, plus a dozen other dependencies invisibly assumed. Production had Python 3.6 and a different PostgreSQL. The code that worked locally crashed in production with cryptic errors.
Solutions before Docker were heavyweight: virtual machines for everything (slow, big, hard to manage), Puppet/Chef/Ansible for configuration management (better but still managing systems, not packaging applications), or just heroic effort by ops teams (does not scale).
Docker changed the model. The idea: package your application along with its operating system, libraries, dependencies, configuration, the works, into a single immutable artifact. That artifact runs the same on the developer's laptop, in CI, and in production. The "works on my machine" problem dissolves.
Containers are now the default unit of software deployment. Kubernetes, Docker Swarm, AWS ECS, Google Cloud Run all run containers. Understanding Docker is foundational. This article walks through what's actually happening under the hood.
Step 1: What a Container Actually Is
A container is a process running on Linux with isolated views of the filesystem, network, and process tree. From inside the container, it looks like a fresh Linux system: own root filesystem, own processes (PID 1 is your app), own network interface. From outside (the host), it is just one of many processes running on the same kernel.
The Underlying Linux Mechanisms
The isolation comes from Linux kernel features that have existed for years:
Namespaces. Separate views of system resources. Each container has its own:
PID namespace (its own process tree).
Network namespace (its own network interfaces, IP addresses).
Mount namespace (its own filesystem).
UTS namespace (its own hostname).
User namespace (its own user IDs).
IPC namespace (its own semaphores, shared memory).
cgroups (control groups). Resource limits and accounting. Each container can be constrained: max 2 CPU cores, max 4GB memory, max 100 MB/s disk IO.
These primitives existed in Linux long before Docker. Docker did not invent containers; it made them usable. Tools like LXC and OpenVZ predated Docker but were too complex for most developers.
What Docker Added
Image format and registry: a portable artifact that contains everything needed to run an application.
Build system: declarative Dockerfile that produces images reproducibly.
Runtime: the docker CLI to run, stop, inspect containers.
Networking: virtual networks connecting containers.
Volume management: persistent storage outside the container's lifecycle.
This was the productivity unlock. Docker did not change Linux; it changed the workflow.
Step 2: Containers vs Virtual Machines
People often ask "is a container just a VM?" No, but they solve overlapping problems.
How VMs Work
A VM virtualizes the whole machine, including the OS kernel. The hypervisor (VMware, KVM, VirtualBox, Hyper-V) runs each VM as if it were on its own hardware. Each VM has its own kernel, its own everything.
Heavy. Boots in tens of seconds. Uses gigabytes of memory just for the OS overhead. Strong isolation; a bug in one VM cannot affect others.
How Containers Work
Containers share the host kernel. They are just isolated processes. Much lighter. Boot in milliseconds. Use much less memory and disk.
Comparison
| Container | VM | |
|---|---|---|
| Isolation | Process-level (kernel-shared) | Hardware-level (full isolation) |
| Boot time | Milliseconds | Tens of seconds |
| Memory overhead | Tens of MB | Hundreds of MB or GB |
| Disk overhead | Tens of MB (deduplicated) | Multi-GB |
| Density | Hundreds per host | Tens per host |
| Security | Good (kernel is shared attack surface) | Excellent (separate kernels) |
| OS choice | Same kernel as host | Any OS |
When to Use Which
Containers for most app deployments. Lightweight, fast, packed densely.
VMs for stronger isolation needs. Multi-tenant cloud, hostile workloads, running Windows on Linux hosts.
Both: many cloud setups run containers inside VMs (each VM is a Kubernetes node, each node runs many container pods).
Both are now infrastructure layers; the choice is about what each layer gives you, not "which is better."
Step 3: Images vs Containers
Two terms that beginners conflate. They are different.
Image: a snapshot of a filesystem plus metadata (entrypoint, environment variables, default command). Read-only. Like a class in OOP, or a template.
Container: a running instance of an image. Read-write (a thin layer on top of the image). Like an object instance.
One Image, Many Containers
You build an image once: docker build -t myapp:v1 .
You run many containers from it: docker run myapp:v1 creates one container. Run again, get another container. Each is independent; changes to one don't affect others. They share the image's filesystem (which is read-only) but have their own writable layer on top.
Image Lifecycle
Build: create from a Dockerfile.
Tag: name it (myapp:v1, myapp:latest).
Push: upload to a registry (Docker Hub, ECR, GCR, your own).
Pull: download from a registry.
Run: instantiate a container from it.
Inspect: see metadata, layers, size.
Container Lifecycle
Run: start a new container.
Stop: gracefully terminate (SIGTERM, then SIGKILL after timeout).
Restart: stop and start again.
Pause: freeze the container's processes (cgroups freezer).
Remove: delete the container's filesystem.
Step 4: The Layered Filesystem
Images are made of layers. Each layer is a set of file additions or deletions. Layers stack: the final filesystem is the union of all layers.
Why Layers Matter
Caching. If you change one line of code, only the layer containing your code rebuilds. The base OS layer, the dependency installation layer, all reused. Builds go from 5 minutes to 5 seconds.
Sharing. Two images that share base layers don't duplicate them on disk. If 50 of your services use the same Python base image, the base image stores once.
Speed. Pulling an image only downloads layers you don't already have. If you pull v2 of an image after having v1, only the changed layers transfer.
Smaller registries. Layer deduplication across many images saves enormous storage at scale.
How Layers Form
Each instruction in a Dockerfile typically creates a layer:
FROM python:3.11-slim # layer 1: base OS + Python
WORKDIR /app # layer 2: working dir
COPY requirements.txt . # layer 3: just this file
RUN pip install -r req... # layer 4: installed dependencies
COPY . . # layer 5: all source code
EXPOSE 8000 # metadata, no layer
CMD ["python", "app.py"] # metadata, no layer
If you change a source file, layer 5 rebuilds. Layers 1-4 are reused from cache. Build time: seconds.
If you change requirements.txt, layers 3, 4, and 5 rebuild. Layers 1-2 reused. A bit slower but still fast.
If you change the FROM line (e.g., upgrade Python), everything rebuilds.
Step 5: The Dockerfile
A Dockerfile is a declarative recipe for building an image.
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
USER appuser
CMD ["python", "app.py"]
Common Instructions
FROM: base image. Required first instruction.
WORKDIR: set the working directory.
COPY: copy files from build context into the image.
RUN: execute a command during build.
ENV: set environment variables.
EXPOSE: document which ports the image listens on (informational; doesn't actually open ports).
USER: switch to a non-root user.
CMD: default command run when the container starts.
ENTRYPOINT: like CMD but harder to override; for command wrappers.
Dockerfile Best Practices
Pin base images by tag (not "latest"). Reproducibility matters. python:3.11.7-slim, not python:latest.
Pin by SHA256 hash for full reproducibility. FROM python@sha256:abcdef.... Even tags can move; hashes are immutable.
Order matters for caching. Put rarely-changing things at the top (base image, dependencies). Put frequently-changing things at the bottom (your source code). Maximizes cache hits.
Combine RUN commands when sensible. Each RUN creates a layer. RUN apt-get update && apt-get install -y curl, not two separate RUNs. But don't combine unrelated steps; they should be separately cacheable.
Use .dockerignore. Exclude .git, node_modules, secrets, build artifacts from the build context. Smaller context = faster builds.
Don't run as root. Create a non-root user, switch to them with USER. Security and best practice.
Multi-stage builds. Compile in one stage, copy artifacts to a slim runtime stage. Final image is tiny. (Detailed below.)
Smaller is better. Use alpine, distroless, or slim base variants. Smaller attack surface, faster pulls, less storage.
Step 6: Multi-Stage Builds
One of the most important patterns. Two-step build: a "builder" stage with all the build tools, and a "runtime" stage with just the binary.
# Stage 1: build
FROM golang:1.21 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/myapp .
# Stage 2: runtime
FROM gcr.io/distroless/base-debian11
COPY --from=builder /app/myapp /myapp
USER nonroot:nonroot
ENTRYPOINT ["/myapp"]
Why It Wins
The builder stage has Go compiler, build tools, source code, intermediate files: hundreds of MB.
The runtime stage has just the compiled binary: a few MB. No compiler, no source, nothing extra.
The final image is tiny. Smaller storage, faster pulls, smaller attack surface (no shell, no compiler, almost nothing for an attacker to exploit if they break in).
Patterns
Same pattern works for Node (build with full image, copy to alpine), Python (build wheels, copy to slim), Java (compile to jar, copy to JRE-only image), and so on.
Distroless images (Google's) take this further: only your binary plus the bare minimum to run it. No package manager, no shell. Minimal attack surface.
Step 7: Volumes and Persistent Data
Containers are ephemeral. Anything written inside disappears when the container stops. To persist data, use volumes.
Volume Types
Named volumes. Docker manages the storage. Portable, recommended for databases. docker run -v mydata:/var/lib/mysql mysql. The volume "mydata" persists across container restarts.
Bind mounts. Map a host directory into the container. docker run -v /host/path:/container/path. Useful for development (live code reloading) and configs. Couples the container to the host's filesystem layout.
tmpfs mounts. In-memory storage. Fast, ephemeral. For sensitive data that should never hit disk.
When to Use Each
Database storage: named volume. Portable, managed.
Development source code: bind mount. Edit on host, reflect in container.
Configuration: bind mount or environment variables.
Temporary scratch: tmpfs.
Cache: named volume; survives restarts but doesn't pollute host.
The State Question
Containers are stateless by design. Every persistent thing should live in a volume or external service. If you're storing important data inside the container's filesystem, you have a bug waiting to happen.
The mental model: containers are cattle, not pets. You can kill any one without losing data because the data lives elsewhere.
Step 8: Networking
Each container has its own network namespace. By default, Docker creates a bridge network where containers can talk to each other by name.
Network Modes
bridge: default. Isolated network with NAT to the host. Containers on the same bridge can talk; outside world can only reach through port mappings.
host: share the host's network. The container uses the host's IP and ports directly. Faster (no NAT) but loses isolation.
none: no networking. Container has only loopback. For special cases.
custom (user-defined networks): create your own bridge. Better than the default; provides built-in DNS for container names.
Container DNS
Within a user-defined network, containers can reach each other by name. Container "db" on the network is reachable as db:5432 from another container.
Docker provides an embedded DNS server that resolves container names. No need for IP addresses.
Port Mappings
docker run -p 8080:80 nginx means: forward host port 8080 to container port 80. The host listens on 8080; traffic goes to the nginx container's port 80.
Without port mapping, the container is reachable only from inside the Docker network. Port mapping is the bridge to the outside world.
Service Discovery in Docker Compose / Swarm
In multi-container apps, services find each other by name. Compose creates a network where all services are reachable by their service name. db:5432, cache:6379, etc.
Same applies in Swarm or Kubernetes (which has its own DNS).
Step 9: Docker Compose
For multi-container apps (web + database + cache), define everything in a docker-compose.yml file.
services:
web:
build: .
ports:
- "8000:8000"
depends_on: [db, redis]
environment:
DATABASE_URL: postgres://app:secret@db:5432/myapp
db:
image: postgres:15
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
redis:
image: redis:alpine
volumes:
pgdata:
docker compose up starts everything. Networks and dependencies handled automatically. Containers can reach each other by name (web reaches db at db:5432).
What Compose Is Good For
Local development environments.
CI test setups (spin up DB + cache + app, run tests, tear down).
Small production deployments on a single host.
Demos and proofs of concept.
What Compose Is Not Good For
Production at scale. Compose runs on one host; one server failure means total downtime. No automatic failover, no rolling updates, no automatic scaling.
For production at scale, you graduate to Kubernetes or another orchestrator (covered in the Kubernetes article).
Step 10: Image Registries
Where images live. Pushed when built; pulled when deployed.
Public Registries
Docker Hub. The default. Free for public repos. Rate limits on pulls without an account.
GitHub Container Registry. Integrates with GitHub Actions. Free for public images.
Quay.io by Red Hat. Public registry with OS-image vulnerability scanning.
Private Registries
Companies usually run private registries to store internal images:
AWS ECR (Elastic Container Registry). Managed AWS-native registry.
Google Artifact Registry / GCR. GCP equivalent.
Azure Container Registry. Azure equivalent.
Harbor. Open-source on-prem registry. Used by enterprises that need full control.
JFrog Artifactory. Multi-format artifact registry; also supports Maven, npm, etc.
Tagging Conventions
Always tag images meaningfully:
myapp:v1.2.3 for semantic versioning.
myapp:gitsha-abc123 for git-driven tagging.
myapp:2026-05-03 for date-based tags.
The latest tag is a footgun. It moves whenever you push something with that tag. Two pulls of myapp:latest can return different images. Avoid in production; pin to specific tags.
Image Scanning
Modern registries scan images for vulnerabilities. They check the OS packages and language dependencies in the image against CVE databases. Block deploys with high-severity issues.
Tools: Trivy (open source), Snyk, AWS Inspector, Anchore.
Step 11: Security
Containers are not magic security. Done wrong, they expose you.
Don't Run as Root
Many images default to root. If your container is compromised, the attacker has root access inside; with kernel exploits, this can become host root. Always create and switch to a non-root user.
Don't Bake Secrets Into Images
Hardcoded API keys, passwords, certificates: all visible to anyone who can pull the image. Use environment variables (passed at run time), mounted secrets, or a secrets manager (Vault, AWS Secrets Manager).
Minimize the Attack Surface
Use minimal base images (alpine, distroless). Fewer packages = fewer CVEs. Smaller blast radius if compromised.
Pin Image Versions
If you use python:latest and the registry image changes, your build can suddenly include malicious code. Pin specific versions; ideally pin by SHA hash.
Sign and Verify Images
Cosign, Notary, in-toto. Cryptographically sign images on build, verify signatures on deploy. Prevents tampering.
Limit Container Privileges
Don't grant --privileged unless absolutely needed (gives the container root-level access to the host).
Drop capabilities you don't need (CAP_NET_RAW, CAP_SYS_ADMIN). Default is some, but trim aggressively.
Use seccomp profiles to limit syscalls. Default is decent; custom profiles tighten further.
Network Restrictions
Don't expose container ports to the world if they don't need to be. Use Docker networks to isolate services.
Resource Limits
Always set CPU and memory limits. A runaway container can starve the host. --cpus=2 --memory=4g.
Step 12: Common Pitfalls
Treating Containers Like VMs
Don't run multiple processes per container. The container model is "one process per container." Need a database and an app? Two containers. Need a worker that ties to your app? Sidecar pattern (separate container, shared volume).
Multi-process containers (with supervisord or s6) work but fight against the container model. They make logs, signals, and updates harder.
Storing Data in the Container
Anything inside the container's filesystem is gone when the container stops. Use volumes for anything that should survive.
Forgetting to Limit Resources
A runaway container can consume all CPU or memory. Always set limits. The host is shared.
Building Huge Images
5 GB images mean slow deploys, slow autoscaling, expensive registry storage. Multi-stage builds and small base images are the fix. Aim for under 200 MB for most application images.
Logging
Containers should write logs to stdout/stderr, not to files. The container runtime captures stdout and ships it to your logging system. If you log to files, those files vanish when the container dies.
Time Zone and Locale
Default base images often have UTC and POSIX locale. If your app expects a different setup, configure explicitly. Database timestamps in different time zones cause subtle bugs.
Signal Handling
Containers shut down via SIGTERM. Your app should handle SIGTERM gracefully (finish in-flight requests, close connections). Many languages and frameworks need explicit signal handlers; defaults may just kill abruptly.
The Docker Daemon as Single Point of Failure
If the Docker daemon crashes, all containers on that host die. In production, Kubernetes or similar orchestrators handle this by replacing failed nodes. On standalone Docker, you have less protection.
Step 13: Beyond Docker
Docker is not the only container tool anymore. The ecosystem has fragmented.
Container Runtime Alternatives
containerd. Originally extracted from Docker. The runtime under most Kubernetes installations now (Docker is no longer the runtime in modern K8s).
CRI-O. Lightweight runtime designed for Kubernetes.
Podman. Daemonless runtime; rootless by default. Compatible with Docker CLI and images.
BuildKit. Modern build engine. Powers docker buildx and standalone builds.
What Changed in Kubernetes
Kubernetes used to use Docker. Around 2020, it deprecated Docker as a runtime. Reason: Docker is a full platform; Kubernetes only needs the runtime piece. Switching to containerd or CRI-O is more efficient.
This rarely affects users. You still build images with Docker; Kubernetes pulls and runs them with containerd.
Step 14: Recap of Key Decisions
Containers are isolated processes, not VMs. Lightweight, fast, packed densely.
Image is the artifact; container is the runtime instance. Build once, run many.
Layered filesystem = caching, sharing, speed. Order Dockerfile instructions to maximize cache hits.
Multi-stage builds for tiny final images. Build with everything, run with minimum.
Volumes for persistent data. Containers are ephemeral; data lives outside.
Containers communicate by service name on user-defined networks. No need for IP addresses.
Compose for local dev; Kubernetes for production at scale.
Pin image versions, never use "latest" in production.
Don't run as root; minimize attack surface. Containers are not security magic.
Always set resource limits. Runaway containers starve the host.
The One Thing to Remember
A container is just a Linux process with isolated namespaces and resource limits. Docker turned that into a developer-friendly tool by adding image format, registry, build system, and runtime CLI. Images are reproducible filesystem snapshots; containers are running instances. Get the Dockerfile patterns right (pin versions, multi-stage, non-root, small base) and most of the operational benefits follow naturally. The hard parts (orchestration, networking at scale, security hardening, observability) live above Docker, in tools like Kubernetes. Understanding containers is foundational because nearly all modern infrastructure starts there. Whether you use Docker, containerd, Podman, or whatever comes next, the model is the same.