TutorialsDocker › Images & Containers

Docker · Page 1 of 6

Images, Layers, and Choosing a Base

An image isn't one big file — it's a stack of read-only layers, each one the result of a single instruction in the Dockerfile. Understanding that stack explains almost everything else: why rebuilds are sometimes instant and sometimes slow, why the order of instructions matters, and why FROM python:3.11-slim means you inherit a working Python install without doing anything yourself.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
One instruction → one layer Layers are → read-only & cached This project's bases → python:3.11-slim, nginx-unprivileged:stable-alpine Never pin to → :latest

Purpose of this page

Understand what an image is made of and how a base image is picked, so the Dockerfiles later in this section read as deliberate choices rather than magic incantations.

Codey typing on a laptop
layers all the way down

An image is a stack of layers the core idea

Each instruction in a Dockerfile that changes the filesystem produces a new layer containing only what changed — the files added, modified, or deleted by that step. The finished image is those layers stacked in order, presented to the running container as one merged filesystem.

Why anyone should carethree consequences
  • Rebuilds can skip work. If a layer's inputs haven't changed, Docker reuses the cached layer instead of redoing it — this is what makes the second build of the API image take seconds instead of a minute.
  • Storage and pulls are shared. Both this project's images and thousands of others start from a common base; a machine that already has python:3.11-slim downloads only the layers built on top of it.
  • Deleting isn't undoing. A file added in one layer and deleted in a later one still occupies space in the first layer — and is still recoverable from the image. That's why secrets must never be copied in, even temporarily.
Seeing the stack yourselftry it

docker history metals-api:local prints one row per layer, newest first, with the instruction that created it and the space it added. Most rows will be 0B — metadata-only instructions like ENV, EXPOSE, and CMD don't touch the filesystem, so they add no bytes.

The build cache, and why order matters the practical payoff

Docker walks the instructions top to bottom. For each one it asks: “have I built this exact step, on top of this exact parent layer, before?” If yes, it reuses the cached result and moves on. The moment one instruction misses, every instruction after it rebuilds too — the cache can't resume once broken.

Codey holding up a sticky note
slow-changing things first

That single rule dictates the shape of this project's API Dockerfile: dependencies are copied and installed before the application code is copied in.

metals_api/Dockerfile (excerpt)COPY requirements.txt requirements-container.txt ./
RUN python -m pip install --no-cache-dir -r requirements-container.txt \
    && useradd --create-home --uid 10001 appuser

COPY metals_api/ ./metals_api/
Why not just copy everything at once?the thing this avoids

Application code changes constantly; the dependency list barely ever does. With this order, editing a route handler invalidates only the last COPY — the expensive pip install layer above it is still cached, so the rebuild is nearly instant. Flip the two, and every one-character code change would re-download and reinstall every Python package from scratch.

The general rule: order instructions from least likely to change to most likely to change.

What counts as “changed” for a COPYa common surprise

For COPY, Docker hashes the contents of the files being copied — so touching a file without editing it won't break the cache, but changing one byte will. For RUN, Docker only compares the command text: a command like apt-get update will happily serve a months-old cached result, because the string never changed.

What FROM actually pulls in inheritance

Every Dockerfile starts with FROM, naming the image to build on top of. You inherit that image's entire filesystem — its Linux distribution, its package manager, any runtime it ships with — plus its defaults for things like the working directory, the user, and the startup command.

FROM python:3.11-slimwhat the API inherits

A minimal Debian system with Python 3.11 and pip already installed and on the PATH. That's why the API's Dockerfile never installs Python — it starts from a machine that already has it. It does still have to install the application's libraries, because those are this project's concern, not the base image's.

FROM nginxinc/nginx-unprivileged:stable-alpinewhat the UI inherits

A working Nginx install and a startup script, already configured to run as a non-root user on port 8080. The UI's Dockerfile is unusually short precisely because almost everything it needs is inherited — it only has to drop its own files and config template into the right folders.

It also inherits an entrypoint that expands environment variables into config templates at startup — the mechanism covered on the UI image page.

Reading an image tag anatomy

An image reference packs several pieces of information into one string. Knowing where the boundaries are makes it obvious what you're actually pulling.

Codey holding a magnifying glass
every part means something
ReferenceHow it breaks down
python:3.11-slimNo registry and no namespace, so this is an official image on Docker Hub. Repository python, tag 3.11-slim.
nginxinc/nginx-unprivileged:stable-alpineNamespace nginxinc (the Nginx company's own account), repository nginx-unprivileged, tag stable-alpine.
expeditorsdzierzonmetalsacr.azurecr.io/metals-api:latestA private registry hostname, then repository metals-api, tag latest — this project's own image in Azure.
The variant suffixesslim, alpine, and friends

Most official images publish the same software on several base systems. -slim is a trimmed Debian — the usual default for Python, since it keeps glibc and therefore works with pre-built binary wheels. -alpine is Alpine Linux: far smaller, but built on musl instead of glibc, which can force Python packages to compile from source. A plain tag with no suffix (python:3.11) is the full Debian image — convenient, considerably larger.

Why not :latestthe reproducibility trap

latest isn't a version — it's just the tag applied when no tag is given, and whatever it points to changes over time. Building against it means two builds a month apart can silently produce different runtimes. Both bases in this project pin a real version line instead.

This project does tag its own images metals-api:latest. That's a deliberate, different trade-off — a moving pointer to “current deployment” — and it comes with a catch covered in Deployment: re-pushing the same tag doesn't restart anything on its own.

How this project chose its two bases decisions

Picking a base image is mostly answering four questions in order. Here's how each one landed for the two application images in this repository.

QuestionAPI imageUI image
What runtime does the app need?Python 3.11, matching what the code is developed and tested against.None — the UI is plain HTML/CSS/JS. It needs a web server, not a runtime.
Is there an official or vendor image?Yes — the official python image.Yes — Nginx's own nginxinc account publishes a hardened variant.
Which variant?-slim: small, but keeps glibc so psycopg and argon2-cffi install as pre-built wheels rather than compiling.-alpine: nothing here compiles, so the smallest option is free of downsides.
Does it run as root by default?Yes, so the Dockerfile creates and switches to an appuser account itself.No — that's the entire point of nginx-unprivileged, which is why it was chosen over plain nginx.

That last row is worth internalizing: a container that runs as root and gets compromised hands the attacker root inside the container. Running as an ordinary user is a cheap, large improvement, and every image here does it — one by inheriting it, one by arranging it explicitly.

What changes when a container runs image vs. instance

Starting a container adds one thin, writable layer on top of the image's read-only stack. Everything the process writes — log files, temp files, rows in a database — lands in that layer, and that layer is destroyed with the container.

Codey thinking with a rubber duck
writable, but temporary
Which is why the database needs a volumethe exception that proves it

The API and UI containers hold nothing worth keeping — delete and recreate them freely. PostgreSQL is the opposite, so docker-compose.yml attaches a named volume to it: storage that lives outside any container's writable layer and survives being stopped, removed, and recreated. Data you actually care about always belongs in a volume, never in the container's own filesystem.

Configuration comes in from outsidesame image, many environments

If an image is fixed, how does the same metals-api image talk to a local Postgres container on your laptop and a managed Azure database in production? Environment variables, supplied at run time. The image contains no hostnames, no passwords, no connection strings — only the code that reads DB_HOST, DB_PASSWORD and friends from its environment.

Codey giving a thumbs up

Layers, bases, and tags covered — next, the instructions that actually build them.