Purpose of this page
Be able to read any Dockerfile line by line — knowing which instructions run during the build, which only record settings, and which take effect when a container starts.
The shape of a Dockerfile orientation
Almost every Dockerfile follows the same arc: start from a base, install dependencies, add your own files, then declare how the thing should run. The API image is a textbook example of that arc, so it's worth seeing whole before breaking it apart.
metals_api/DockerfileFROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
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/
WORKDIR /app/metals_api
USER appuser
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=3)"
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "app:app"]
Instructions that build the filesystem these create layers
These four do actual work during docker build, and each one adds a layer to the image.
Names the image to build on top of, and must be the first instruction. Everything in that image — its Linux distribution, its installed runtime, its default user — becomes your starting filesystem. Covered in depth on the previous page.
Copies files from the build context on your machine into the image. A trailing slash on the destination means “into this directory.” COPY metals_api/ ./metals_api/ puts the source folder at /app/metals_api, because the working directory at that point is /app.
ADD does the same thing plus auto-extracting archives and fetching URLs. That extra magic makes it harder to predict, so COPY is the default choice — and the only one this project uses.
Runs a shell command inside the half-built image and keeps whatever it changed. This is how packages get installed. Chaining related commands with && into a single RUN, as the API image does with pip install and useradd, keeps them in one layer instead of two.
--no-cache-dir tells pip not to keep its download cache. In normal development that cache saves time; inside an image it's dead weight that ships to production forever.
Sets the directory that subsequent COPY, RUN, and CMD instructions operate in, creating it if needed. It's the Dockerfile equivalent of cd — but unlike running cd inside a RUN, it persists to later instructions and into the running container.
The API image uses it twice: /app while installing, then /app/metals_api so Gunicorn's app:app reference resolves against the right folder at startup.
Instructions that set metadata no files change
These record settings on the image rather than modifying its filesystem. They still create (empty) layers, and they still affect how the container behaves — they just don't add bytes.
Sets variables that exist during the rest of the build and inside every container started from the image. Crucially these are defaults — anything supplied at run time (docker run -e, a Compose environment: block, an Azure app setting) overrides them. That's exactly how the UI image ships a sensible local default for API_UPSTREAM that Azure then replaces.
Never put secrets in ENV. They're baked into the image, visible to anyone who can run docker history or pull the image.
Switches the account used for the remaining instructions and for the container's main process. The API image creates appuser with useradd first, then switches to it — placement matters, since the pip install above it still needs root to write into system directories.
Declares which port the application listens on. It does not publish the port or open a firewall — that's docker run -p or a Compose ports: entry. Think of it as a label for humans and tools; Azure, for instance, has its own WEBSITES_PORT setting and doesn't read this at all.
A command Docker runs periodically inside the container to decide whether it's healthy or unhealthy. Both images here call their own /health endpoint. The flags control timing: --start-period is a grace window during startup where failures don't count, and --retries is how many consecutive failures flip the status.
This is what lets Compose hold the API back until PostgreSQL is genuinely accepting connections, rather than merely started — see the Compose page.
Instructions that define startup what runs when it starts
The process to run when a container starts. Written as a JSON array (“exec form”), the command runs directly as process 1, which means it receives stop signals properly. Written as a bare string, it's wrapped in a shell, and signals go to the shell instead — a common cause of containers that take ten seconds to stop. Both images here use the array form, or inherit one that does.
ENTRYPOINT is the fixed part of the startup command; CMD is the replaceable part. If both exist, the CMD is appended as arguments to the ENTRYPOINT. The UI image declares neither — it deliberately inherits both from nginx-unprivileged, whose entrypoint does the config templating before handing off to Nginx.
Flask's built-in server is single-threaded and explicitly not meant for production traffic. Gunicorn is a production WSGI server that runs several worker processes — here --workers 2 — so the API can handle concurrent requests. The --access-logfile - and --error-logfile - flags point both logs at standard output, which is where a container is expected to log so that docker logs and Azure's log stream can pick them up.
The build context and .dockerignore what Docker can see
When you run docker build, the final argument is a directory — the build context. Docker packages that entire directory up and sends it to the build engine, and COPY can only reach files inside it. A .dockerignore file trims what gets sent.
metals_api/Dockerfile copies both requirements.txt (repo root) and metals_api/. Since COPY can't reach above the context, the context has to be the repo root — not the metals_api folder. The Dockerfile's own location is independent, passed with --file.
Because the context is the whole repository, this file starts by excluding everything with ** and then re-includes only what the build genuinely needs, with ! lines. Without it, every build would ship the .venv, the tutorials/ folder, and the Terraform state directory across to the builder.
.dockerignore# Only send API source and dependency manifests to the image builder.
**
!requirements.txt
!requirements-container.txt
!metals_api/
!metals_api/**
metals_api/Dockerfile
metals_api/tests/
**/__pycache__/
**/*.py[cod]
**/.env
**/.env.*
The last four lines matter for more than size: they guarantee a stray local .env file can never be baked into a published image.
Quick reference all nine
| Instruction | When it acts | One-line summary |
|---|---|---|
FROM | build | The image to start from; must come first. |
ENV | build + run | Default environment variables, overridable at run time. |
WORKDIR | build + run | Sets (and creates) the current directory. |
COPY | build | Copies files from the build context into the image. |
RUN | build | Executes a command and keeps the resulting filesystem changes. |
USER | build + run | Switches the account later steps and the main process run as. |
EXPOSE | documentation | Declares the listening port; publishes nothing by itself. |
HEALTHCHECK | run | Periodic command deciding healthy vs. unhealthy. |
CMD | run | The default process to start; use the JSON array form. |