Understanding Docker: Containers, Images, Layers and the System Behind Them
The first time I used Docker, I thought it was just a packaging tool.
Write a Dockerfile, run docker build, then docker run.
Simple.
Until one day my database container restarted and all the data was gone.
That moment made me realize something important:
Docker isn’t just a CLI tool.
It’s a system of concepts working together.
Most confusion around Docker comes from not seeing how these pieces connect:
- images
- containers
- layers
- volumes
- networks
- orchestration
Once the mental model clicks, Docker suddenly becomes much easier to reason about.
The Docker Ecosystem
Before diving into the details, it helps to see the big picture.
Docker Engine manages several core building blocks.
- Images: blueprints used to create containers.
- Containers: running instances of images.
- Volumes: persistent storage outside the container lifecycle.
- Networks: service discovery and communication.
- Docker Compose: defines multi-container applications.
Understanding how these pieces relate to each other is the key to understanding Docker.
flowchart LR
Engine["Docker Engine"]
Img["Images"]
Ctr["Containers"]
Vol["Volumes"]
Net["Networks"]
Comp["Docker Compose"]
Engine --> Img
Engine --> Ctr
Engine --> Vol
Engine --> Net
Comp --> Img
Comp --> Ctr
Comp --> Vol
Comp --> Net
Docker Is Not a Virtual Machine
One of the most common misconceptions is thinking Docker containers are similar to virtual machines.
They are not.
Virtual machines virtualize hardware. Containers virtualize the operating system environment.
Containers share the host kernel and isolate processes using Linux primitives such as:
- namespaces
- cgroups
- union filesystems
This is why containers start quickly and consume fewer resources than VMs.
flowchart TB
subgraph VM["Virtual Machine"]
HW["Hardware"]
Hyper["Hypervisor"]
G1["Guest OS"]
App1["App"]
HW --> Hyper --> G1 --> App1
end
subgraph Container["Container Model"]
Host["Host OS / Kernel"]
Runtime["Container Runtime"]
C1["Container A"]
C2["Container B"]
Host --> Runtime
Runtime --> C1
Runtime --> C2
end
Images vs Containers
One of the most important Docker concepts is the difference between images and containers.
Think of it like this:
- Image = blueprint
- Container = running instance
Images are:
- immutable
- read-only
- reusable
Containers are:
- runtime instances
- writable
- ephemeral
Running a container is essentially instantiating an image.
docker run nginx
Docker will:
- pull the image
- create a container
- start the process inside it
flowchart LR
Image["Image (immutable blueprint)"]
Create["docker run"]
Container["Container (runtime instance)"]
Process["Running process"]
Image --> Create --> Container --> Process
Layers: Why Docker Builds Are Fast
Docker images are built using a layered filesystem.
Each Dockerfile instruction creates a new layer.
Here is a simple Go example:
FROM golang:1.26
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app
CMD ["./app"]
Layer breakdown:
- Layer 1 -> Base image (
golang) - Layer 2 -> Dependencies
- Layer 3 -> Source code
- Layer 4 -> Compiled binary
If only the application code changes, Docker can reuse:
- the base image
- the dependency layer
and rebuild only the final layers.
flowchart TB
L1["Layer 1: golang:1.26"]
L2["Layer 2: go mod download"]
L3["Layer 3: application source"]
L4["Layer 4: compiled binary"]
L1 --> L2 --> L3 --> L4
Docker Layer Cache: Why Instruction Order Matters
Docker caches each layer during the build process.
Because dependencies are copied first, Docker only re-downloads them when go.mod changes.
This makes builds significantly faster.
When cache behavior is wrong, builds slow down immediately.
COPY . .
RUN go mod download
RUN go build
In this version, any source code change invalidates the dependency layer.
Now every build must:
- download dependencies again
- rebuild everything
This is why Dockerfile optimization usually follows a simple rule:
Dependencies first, source code later.
flowchart LR
Good1["COPY go.mod go.sum"] --> Good2["RUN go mod download"] --> Good3["COPY . ."] --> Good4["RUN go build"]
Multi-Stage Builds
In production environments you usually want the smallest possible image.
Go makes this easy using multi-stage builds.
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/app .
CMD ["./app"]
This approach:
- builds the binary inside the Go image
- copies only the compiled binary into the final image
- removes build tools from production
The result is a dramatically smaller image.
Volumes: Solving the Persistence Problem
Containers are ephemeral.
If a container disappears, its filesystem disappears too.
Docker solves this with volumes.
docker run -v postgres-data:/var/lib/postgresql/data postgres
Now database data survives container restarts.
This is the line many people only understand after they lose data once.
Containers are disposable. State should not live inside them unless you explicitly accept that risk.
flowchart LR
C["Postgres Container"] --> M["Mount point: /var/lib/postgresql/data"]
M --> V["Docker Volume: postgres-data"]
Docker Networking
Docker containers communicate through virtual networks.
The most common network driver is bridge.
Docker provides two key features here.
Internal DNS
Containers can communicate using service names.
For example:
postgres:5432redis:6379
Docker resolves these names automatically when the services are on the same network.
Network isolation
Each Docker network runs in its own namespace.
This provides:
- service isolation
- simple networking
- automatic service discovery
flowchart LR
A["Host Machine"]
subgraph N["Docker Network"]
B["API Container"]
C["Worker Container"]
D["Postgres Container"]
E["Redis Container"]
end
A --> B
A --> C
A --> D
A --> E
B -->|"postgres:5432"| D
B -->|"redis:6379"| E
C -->|"postgres:5432"| D
Docker CLI vs Docker Compose
You can run containers manually using the Docker CLI.
docker network create app-network
docker run -d \
--name postgres \
--network app-network \
-v db-data:/var/lib/postgresql/data \
postgres
docker run -d \
--name api \
--network app-network \
-p 8080:8080 \
my-api
This works, but managing infrastructure this way becomes messy quickly.
You must manually handle:
- networks
- volumes
- container lifecycle
- service dependencies
The system exists, but the definition of the system is scattered across shell commands.
The Same Setup with Docker Compose
Docker Compose allows defining the entire system in a single file.
version: "3"
services:
api:
build: .
ports:
- "8080:8080"
depends_on:
- db
db:
image: postgres
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Now the whole system runs with:
docker compose up
Compose automatically manages:
- networking
- service discovery
- volumes
- container lifecycle
- startup order
That is where Docker starts to feel less like a command-line utility and more like an application runtime model.
flowchart TB
Compose["docker-compose.yml"]
Up["docker compose up"]
S1["api"]
S2["db"]
V["db-data volume"]
N["default network"]
Compose --> Up
Up --> S1
Up --> S2
Up --> V
Up --> N
The Docker Mental Model
Once these pieces connect, Docker becomes much easier to understand.
Images -> blueprints
Containers -> running instances
Layers -> build caching
Volumes -> persistent data
Networks -> service discovery
Compose -> orchestration
Docker is not just:
docker builddocker run
It’s an ecosystem of concepts working together.
And once that mental model clicks, Docker stops feeling like magic and starts feeling predictable.
Further Reading
- If you want another event-driven systems mental model, read Kafka: Start Simple, Then Scale the Idea.
- If you want a broader methodology for evaluating system trade-offs, read Foundry: A Practical Methodology for Turning System Design into Engineering Decisions.