TutorialsDocker › Inside a Dockerfile

Docker · Page 2 of 6

Every Instruction This Project Uses

A Dockerfile is a plain text recipe read top to bottom, one instruction per step. There are only about a dozen instructions in total, and this project uses nine of them. This page is the reference: what each one does, the gotcha that comes with it, and where it shows up in the two real Dockerfiles covered later in this section.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Instructions used here → 9 Runs at build time → RUN Runs at start time → CMD Controls the context → .dockerignore

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.

Codey typing on a laptop
nine instructions, that's all

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"]

Three groups, in order: instructions that change the filesystem (and therefore create layers), instructions that just record metadata, and instructions that describe what happens when a container starts. The rest of this page takes them group by group.

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.

FROMthe starting point

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.

COPYbringing your files in

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.

RUNexecuting during the build

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.

WORKDIRthe current directory

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.

Codey holding up a sticky note
settings, not files
ENVdefault environment variables

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.

USERwho the process runs as

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.

EXPOSEdocumentation, mostly

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.

HEALTHCHECKis it actually working?

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

CMDthe default command

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.

CMD vs. ENTRYPOINTthe pairing

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.

Why gunicorn and not python app.pya real decision

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.

Codey pointing to the left
the folder Docker is handed
Why the API builds from the repo rootcontext ≠ Dockerfile location

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.

The root .dockerignoreallow-list style

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

InstructionWhen it actsOne-line summary
FROMbuildThe image to start from; must come first.
ENVbuild + runDefault environment variables, overridable at run time.
WORKDIRbuild + runSets (and creates) the current directory.
COPYbuildCopies files from the build context into the image.
RUNbuildExecutes a command and keeps the resulting filesystem changes.
USERbuild + runSwitches the account later steps and the main process run as.
EXPOSEdocumentationDeclares the listening port; publishes nothing by itself.
HEALTHCHECKrunPeriodic command deciding healthy vs. unhealthy.
CMDrunThe default process to start; use the JSON array form.
Codey giving a thumbs up

That's the whole instruction set — next, the commands that turn a Dockerfile into something running.