Francisco Javier Palacios Pérez Fco. Javier Palacios Pérez
Software Developer
Docker images and containers: understanding the relationship

Docker images and containers: understanding the relationship

Docker images and containers: understanding the relationship

Docker images and containers: understanding the relationship

You pulled ubuntu, you pulled nginx, you’ve been running containers and destroying them. But here’s the thing — do you actually know what an image is? Not the one-liner (“a template”), but what’s actually happening on your disk? Why does pulling node:22 take a while the first time and almost nothing the second? Why can you run ten containers from the same image and none of them affect each other?

If you got through lesson 2 with a vague intuition of how this works, this lesson is about turning that intuition into a mental model you can rely on. Because everything in Docker — Dockerfiles, image caching, volumes, multi-stage builds — makes a lot more sense once you understand what’s happening underneath.

What is a Docker image, really?

Let’s start with what an image is not. It’s not a file. It’s not a zip archive. It’s not a snapshot of a running system.

A Docker image is a stack of layers. Each layer is a set of filesystem changes — files added, files modified, files deleted. When you stack them all together, you get a complete filesystem that a container can run. Think of it like a Git commit history: each commit (layer) describes what changed, and the full history gives you the current state of the project.

This is called a union filesystem (OverlayFS is the most common implementation on Linux). Docker merges these read-only layers into a single coherent view, and that’s what the container sees as its filesystem.

Let’s make it concrete. Imagine an image built like this:

Layer 1 (Base): Ubuntu 22.04 — adds ~80MB of OS files
Layer 2: apt-get install python3 — adds Python interpreter
Layer 3: pip install flask — adds Flask and its dependencies
Layer 4: COPY app.py /app/ — adds your application code

Each layer only stores the diff from the previous one. And here’s where it gets efficient: if you have ten different images that all start from ubuntu:22.04, they all share that first layer on disk. Docker doesn’t download or store it ten times — it references the same layer. Pull a new image that shares base layers with something you already have, and Docker will say “already exists” for those layers.

docker pull python:3.12
3.12: Pulling from library/python
fa9b7e77b6bf: Already exists       ← base layers shared with other images
48be9699aae1: Already exists
...
b0c4ce42c5d0: Pull complete        ← new layers specific to python:3.12
Digest: sha256:...
Status: Downloaded newer image for python:3.12

That’s the layer cache doing its job.

Exploring layers

You can inspect exactly what layers an image contains:

docker image inspect nginx

This dumps a large JSON object. The relevant section is RootFS.Layers, which lists the SHA256 hashes of each layer. You can also see a higher-level view with:

docker image history nginx
IMAGE          CREATED        CREATED BY                                      SIZE
a7be6198544f   2 weeks ago    CMD ["nginx" "-g" "daemon off;"]               0B
<missing>      2 weeks ago    STOPSIGNAL SIGQUIT                             0B
<missing>      2 weeks ago    EXPOSE 80                                      0B
<missing>      2 weeks ago    COPY /etc/nginx /etc/nginx /                   4.61kB
<missing>      2 weeks ago    RUN /bin/sh -c apt-get update && apt-get ...   91.4MB
<missing>      2 weeks ago    ENV NGINX_VERSION=1.27.4                       0B
<missing>      2 weeks ago    FROM debian:bookworm-slim                      0B

Read it bottom to top: start with the base debian:bookworm-slim, install packages, copy Nginx config, set the startup command. Each line is a layer (or a metadata instruction that adds 0 bytes).

Image tags: this is not optional knowledge

When you run docker pull nginx, you’re actually running docker pull nginx:latest. The :latest is a tag, and latest is the default. Tags are mutable pointers — today’s nginx:latest is not the same image as nginx:latest from six months ago. It just points to the newest stable build.

In production, you never use latest. You pin to a specific version:

docker pull nginx:1.27.4         # Specific patch version — deterministic
docker pull nginx:1.27           # Minor version — gets patches
docker pull nginx:1              # Major version — gets minor updates too
docker pull nginx:latest         # ❌ Fine for local experiments, not for prod

Why? Because latest means your deployment changes every time you pull. Upgrading Nginx should be a deliberate decision, not a side effect of running docker pull.

There’s also a pattern you’ll see often: variant tags. The same version with different base images:

nginx:1.27.4-alpine    # Alpine Linux base (~5MB), minimal, fast to pull
nginx:1.27.4           # Debian base (~180MB), more compatible
nginx:1.27.4-slim      # Reduced Debian, middle ground

Alpine is popular for production because of its tiny size. The tradeoff: it uses musl libc instead of glibc, which can cause subtle compatibility issues with some software. For most things it’s fine; for others it’s a frustrating debugging session (because C library mismatches).

The container lifecycle

An image is static. A container is alive — and it has a lifecycle.

When you run docker run, the container doesn’t just appear in a running state. It goes through a series of states:

Created → Running → (Paused) → Stopped → Removed

Created: the container exists but hasn’t started yet. You can do this explicitly with docker create, or it’s an implicit step inside docker run.

Running: the main process is executing. The container consumes CPU, RAM, and filesystem resources.

Paused: the process is suspended (SIGSTOP). The container still exists in memory but isn’t doing anything. docker pause and docker unpause. Rarely used, but useful for debugging — freeze a container in place and inspect its state.

Stopped: the main process has exited. The container still exists as a stopped entity — its filesystem is preserved, its logs are still accessible. You can restart it with docker start.

Removed: the container is gone. Filesystem, logs, everything. You can’t bring it back.

# Walk through the lifecycle manually
docker create --name demo nginx          # Created
docker start demo                        # Running
docker pause demo                        # Paused
docker unpause demo                      # Running again
docker stop demo                         # Stopped (SIGTERM → SIGKILL after 10s)
docker rm demo                           # Removed

Or skip the ceremony and do everything at once:

docker run --rm nginx    # Creates, starts, and removes when done

The writable layer: why containers don’t modify images

Here’s a key detail that confuses people. If images are read-only, how can a container write files?

When Docker creates a container from an image, it adds a thin writable layer on top of all the read-only image layers. This is where everything the container writes goes — log files, temporary data, any apt-get install you do inside the container. The image layers beneath are never touched.

┌─────────────────────────────┐
│   Container writable layer  │  ← changes here only
├─────────────────────────────┤
│   Image layer 4 (app.py)    │  read-only
├─────────────────────────────┤
│   Image layer 3 (Flask)     │  read-only
├─────────────────────────────┤
│   Image layer 2 (Python)    │  read-only
├─────────────────────────────┤
│   Image layer 1 (Ubuntu)    │  read-only
└─────────────────────────────┘

When the container is removed, the writable layer disappears with it. The image is exactly as it was. Start another container from the same image and you get a fresh writable layer — a clean slate.

This is what makes containers ephemeral by design. They’re not meant to be permanent homes for data. Anything you want to persist beyond a container’s lifetime goes in a volume — but that’s a topic for later in the course.

Ten containers, one image

Let’s make this tangible. You can have ten containers running from a single image simultaneously:

# Start ten nginx containers, each on a different port
for i in $(seq 1 10); do
  docker run -d -p "808${i}:80" --name "nginx-${i}" nginx
done
docker ps
CONTAINER ID   IMAGE   COMMAND                  PORTS                  NAMES
a1b2c3d4e5f6   nginx   "/docker-entrypoint.…"   0.0.0.0:8081->80/tcp   nginx-1
b2c3d4e5f6a7   nginx   "/docker-entrypoint.…"   0.0.0.0:8082->80/tcp   nginx-2
...

Each one is completely isolated. Writing to the filesystem of nginx-1 doesn’t affect nginx-2. They all share the read-only Nginx image layers. On disk, the overhead of ten containers is ten tiny writable layers — not ten full copies of Nginx.

Clean up when done:

for i in $(seq 1 10); do
  docker stop "nginx-${i}" && docker rm "nginx-${i}"
done

Or more directly:

docker rm -f $(docker ps -aq --filter name=nginx-)

Pulling, listing, and removing images

A few commands you’ll use constantly:

# Download an image without running it
docker pull node:22-alpine

# List all locally available images
docker images
REPOSITORY   TAG          IMAGE ID       CREATED        SIZE
nginx        latest       a7be6198544f   2 weeks ago    192MB
node         22-alpine    f7d2a4e85d2c   3 weeks ago    141MB
ubuntu       22.04        3db8720ecbf5   4 weeks ago    77.9MB
python       3.12         b3a18c9e2f1d   3 weeks ago    1.02GB
# Remove an image (only works if no containers — running or stopped — reference it)
docker rmi nginx

# Force remove (also removes derived containers)
docker rmi -f nginx

# Remove all unused images
docker image prune
docker image prune -a    # Also removes images with no running containers (more aggressive)

⚠️ You can’t remove an image while a container (even a stopped one) references it. Remove the container first, then the image.

Naming containers: don’t skip this

If you don’t name your containers, Docker assigns random adjective-noun combinations (ecstatic_hopper, confident_kepler). Endearing, but useless for anything beyond a quick experiment.

# Always name your containers
docker run -d --name web-server nginx
docker run -d --name api-backend node:22-alpine node app.js
docker run -d --name db-primary postgres:16

Named containers are easier to reference in every subsequent command:

docker logs web-server
docker exec -it web-server bash
docker stop web-server

And when you use Docker Compose (coming later), naming becomes even more important because services discover each other by name over Docker’s internal network.


The image-container relationship is the foundation of everything Docker does. Layers explain the cache behavior, the efficiency of pulling images, and why modifying a container doesn’t affect others sharing the same image. The lifecycle explains why containers are designed to be disposable — and why data persistence requires a different mechanism.

In the next lesson we’ll go hands-on with interactive containers and exec: running shells inside containers, copying files in and out, and understanding when to use -it vs -d and why the difference matters.

Never stop coding!


💡 Challenge: Pull three different images (nginx, node:22-alpine, python:3.12-slim). Create two containers from nginx, stop one, remove the other. View the logs of the remaining container. Check what layers nginx and python:3.12-slim have in common using docker image inspect. Clean everything up with docker system prune.