This is the first post in our Docker fundamentals series. Later posts cover production Dockerfiles, networking, Compose, and image security.
"A container is like a lightweight virtual machine" is the analogy almost everyone hears first, and it's the reason so many people get confused six months later when a container behaves in a way no VM ever would. It's worth building the correct mental model from the start instead.
What a container actually is
A container is an isolated process running on the same kernel as the host machine — not a separate operating system, not a hypervisor-managed guest. Linux provides two kernel features that make this isolation possible: namespaces (giving a process its own view of processes, network interfaces, and filesystem mounts) and cgroups (limiting how much CPU, memory, and I/O that process can consume). Docker is, underneath all its tooling, a friendly interface over those two kernel primitives.
This is why containers start in milliseconds while VMs take seconds to minutes — there's no separate kernel to boot. It's also why a container sharing the host's kernel means a kernel-level vulnerability can, in the worst case, be exploited across container boundaries in a way that's architecturally impossible with a real VM's hardware-enforced isolation. The trade-off is real in both directions, not just a speed win.
Images vs. containers
An image is a read-only template — filesystem contents plus metadata (what command to run, what ports to expose). A container is a running instance of an image, with a thin writable layer on top.
docker pull nginx:1.27
docker run -d --name my-nginx -p 8080:80 nginx:1.27docker run creates a container from the nginx:1.27 image and starts it. Critically, docker run again creates a second, independent container from the same image:
docker run -d --name my-nginx-2 -p 8081:80 nginx:1.27Two containers, one shared underlying image (Docker only stores the image's layers once, regardless of how many containers use it), each with their own independent writable layer and their own isolated process. This is the relationship most people get wrong at first: the image is the recipe, the container is one specific instance of a meal made from it — you can make many meals from the same recipe without changing the recipe itself.
Layers, and why they matter for more than just disk space
An image is built from a stack of read-only layers, each one representing a single instruction in the Dockerfile that created it.
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y curl
COPY app.py /app/app.py
CMD ["python3", "/app/app.py"]Each of FROM, RUN, and COPY produces its own layer, stacked on top of the previous one. Docker caches layers by content hash — rebuild this image without changing app.py, and the RUN apt-get install layer (usually the slowest step) is reused unchanged from cache, rather than re-executed:
Step 2/4 : RUN apt-get update && apt-get install -y curl
---> Using cache
---> a1b2c3d4e5f6
This caching behavior is exactly why instruction order in a Dockerfile is a real performance decision, not a stylistic one — a detail we go into in depth in the next post in this series, because getting it wrong is one of the most common reasons a team's Docker builds feel unnecessarily slow.
Inspecting an image's actual layers
It's worth actually looking at what a real image contains, rather than treating layers as an abstract concept:
docker history nginx:1.27IMAGE CREATED BY SIZE
a1b2c3d4e5f6 CMD ["nginx" "-g" "daemon off;"] 0B
b2c3d4e5f6a7 EXPOSE map[80/tcp:{}] 0B
c3d4e5f6a7b8 COPY docker-entrypoint.sh / 4.6kB
d4e5f6a7b8c9 RUN apt-get update && apt-get install nginx 62.3MB
e5f6a7b8c9d0 FROM debian:bookworm-slim 80.4MB
Reading this top to bottom (most recent instruction first) shows exactly where an image's size actually comes from — here, the base Debian layer and the nginx package install account for nearly all of it, while the metadata-only instructions (CMD, EXPOSE) add nothing. This is genuinely the first place to look when an image feels unexpectedly large: docker history tells you which specific instruction is responsible, rather than leaving you to guess.
The container lifecycle
docker ps # running containers
docker ps -a # all containers, including stopped ones
docker stop my-nginx # sends SIGTERM, then SIGKILL after a grace period
docker start my-nginx # starts an existing (stopped) container again
docker rm my-nginx # permanently removes a stopped container
docker logs my-nginx # stdout/stderr from the container's main process
docker exec -it my-nginx sh # a shell inside a running container, for debuggingTwo of these deserve emphasis because they're a common source of confusion:
stopandrmare different operations. A stopped container still exists — its filesystem changes, its logs, its configuration — until yourmit. This is genuinely useful for debugging: a container that crashed can be inspected withdocker logseven after it's stopped.docker execruns a new process inside an already-running container's namespaces — it doesn't attach to the container's original process. Killing yourexecshell doesn't affect the container's actual main process at all.
Why containers are disposable, on purpose
The writable layer created by docker run is deleted along with the container when you rm it. Any file written inside a running container that isn't in a mounted volume disappears the moment that container is removed. This is a deliberate design choice, not an oversight: containers are meant to be treated as disposable and stateless, with anything that needs to persist — a database's actual data, user-uploaded files — stored outside the container in a volume or bind mount.
docker run -d --name my-db -v pgdata:/var/lib/postgresql/data postgres:16pgdata here is a named volume, managed by Docker and stored outside any single container's writable layer. Remove and recreate the container, and the volume — and the real data inside it — survives, because it was never actually inside the container's disposable filesystem layer to begin with.
A bind mount is the other common option, mapping a specific host directory directly into the container rather than letting Docker manage the storage location:
docker run -d --name my-app -v $(pwd)/logs:/var/log/app my-app:latestThe distinction matters in practice: a named volume is portable and managed entirely by Docker (the right choice for a database's data directory); a bind mount ties the container to a specific path on this specific host (the right choice for local development, where you want to edit source files on your laptop and see the change reflected immediately inside a running container).
Resource limits at the container level
Beyond the Kubernetes-level resource requests and limits covered later in this blog, plain docker run already supports constraining a single container's CPU and memory directly:
docker run -d --name my-app --memory="512m" --cpus="0.5" my-app:latest--memory caps the container's memory usage — exceeding it triggers the same kind of out-of-memory kill covered for Kubernetes Pods elsewhere in this blog, just enforced by the Docker Engine directly rather than a cluster scheduler. Setting these deliberately on a shared host running several containers prevents one container's runaway memory usage from starving every other container sharing that same machine — the exact same reasoning behind Kubernetes resource requests and limits, applied here one level down, to plain Docker.
Restart policies: what happens when a container's process exits
By default, a container that exits — whether cleanly or due to a crash — simply stays stopped. --restart changes that behavior:
docker run -d --name my-app --restart unless-stopped my-app:latestunless-stopped restarts the container automatically if it exits unexpectedly, but respects a deliberate docker stop and won't restart it in that case — a reasonable default for a long-running service on a single host. always restarts unconditionally, even after a deliberate stop (only useful in narrower cases); on-failure restarts only on a non-zero exit code, leaving a clean exit alone. This is a genuinely simpler, single-host version of the same self-healing principle behind a Kubernetes Deployment recreating a failed Pod, covered in our next series — worth knowing as the plain-Docker equivalent before reaching for a full orchestrator.
What to actually remember from this post
- A container is an isolated process sharing the host kernel, not a separate virtual machine — namespaces and cgroups provide the isolation, not a hypervisor.
- An image is a read-only template; a container is a running instance of one — many containers, one shared image.
- Layers are cached by content, which is what makes Dockerfile instruction order a genuine performance lever, not just style —
docker historyshows you exactly which instruction is responsible for an image's size. - Containers are disposable by design — anything that needs to survive a container being removed belongs in a named volume or bind mount, not the container's own writable layer.
Next in the series: Writing Production-Ready Dockerfiles, where layer caching and multi-stage builds turn this basic understanding into images that are actually fast to build and safe to ship.
