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.
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.
- 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.
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.
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/
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.
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.
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.
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.
| Reference | How it breaks down |
|---|---|
python:3.11-slim | No registry and no namespace, so this is an official image on Docker Hub. Repository python, tag 3.11-slim. |
nginxinc/nginx-unprivileged:stable-alpine | Namespace nginxinc (the Nginx company's own account), repository nginx-unprivileged, tag stable-alpine. |
expeditorsdzierzonmetalsacr | A private registry hostname, then repository metals-api, tag latest — this project's own image in Azure. |
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.
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.
| Question | API image | UI 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. |
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.
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.
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.