TutorialsDocker › The API Image

Docker · Page 4 of 6

metals_api/Dockerfile, Line by Line

The first of this project's images: a Python 3.11 environment containing the Flask application and its dependencies, served by Gunicorn on port 5000, running as a non-root user. Nineteen lines of Dockerfile, every one of which is here for a reason — this page goes through all of them, then covers how to build and run it on its own.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Image name → metals-api Base → python:3.11-slim Port → 5000 Server → Gunicorn, 2 workers Build context → the repo root

Purpose of this page

Read the API's Dockerfile with full understanding of why each instruction is there and in that order — including the two that exist purely to make Python behave well inside a container.

Codey typing on a laptop
nineteen deliberate lines

The whole file start here

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

The base and its environment lines 1–4

FROM python:3.11-slimthe starting filesystem

Debian with Python 3.11 and pip pre-installed. The version is pinned to 3.11 deliberately — it matches the version the project's tests run against in CI and the version the API was developed on. -slim keeps the image small while retaining glibc, so psycopg and argon2-cffi install as pre-built wheels instead of compiling from source.

PYTHONDONTWRITEBYTECODE=1no .pyc clutter

Stops Python writing __pycache__ folders next to the source. In a container those cached files are pure overhead: the code never changes after the image is built, so nothing is ever saved by caching it, and the files just add weight and noise to the writable layer.

PYTHONUNBUFFERED=1the one that saves you at 2am

By default Python buffers its output when it isn't writing to a terminal — which is exactly the situation inside a container. Without this, log lines sit in a buffer instead of appearing, and a crash can lose the very messages explaining it. Setting it makes docker logs and Azure's log stream show output the moment it's produced.

These two variables show up in nearly every production Python Dockerfile you'll read. Now you know why.

Dependencies before code lines 6–11

This is the build cache shaping the file. The dependency manifests are copied and installed first; the application source arrives afterward, in its own layer.

Codey holding up a sticky note
install once, edit freely
Why two requirements filesa small but important split

requirements.txt is the application's own dependency list, shared with local development and CI. requirements-container.txt simply includes it and adds the one package only the container needs:

requirements-container.txt-r requirements.txt
gunicorn>=23.0,<24.0

That keeps Gunicorn out of local development environments, where Flask's built-in server is used, while guaranteeing the container and the tests agree on every other version.

COPY metals_api/ ./metals_api/the source, last

The application code is the most frequently changed input, so it's copied as late as possible. Editing a route handler invalidates only this layer and the ones below it — the pip install above stays cached, and the rebuild takes a second or two instead of a minute.

Why the build context is the repo roota consequence worth noting

requirements.txt lives at the repository root while the Dockerfile lives in metals_api/. Since COPY can't reach outside the build context, the context must be the root — hence docker build -f metals_api/Dockerfile . with the dot at the end. The root .dockerignore then trims that context back down so the rest of the repository isn't shipped to the builder.

Running as a non-root user lines 8–13

The python base image runs as root, so this Dockerfile arranges its own account.

useradd --create-home --uid 10001 appusertucked into the RUN

Creating the user is chained onto the pip install with && so both land in a single layer. The explicit high --uid matters in Kubernetes-style environments that refuse to run containers whose user ID falls in the system range, and it keeps the ID stable across rebuilds rather than depending on whatever the system assigns next.

USER appuser, after the installorder is load-bearing

The switch happens after pip install, because installing into system site-packages needs root. Everything from that line onward — including the container's main Gunicorn process — runs as the unprivileged account. If the API were ever compromised, the attacker lands as appuser with no ability to modify the installed runtime.

This is the same protection the UI image gets for free by choosing the nginx-unprivileged base — see the next page.

Port, health check, and startup lines 15–19

The HEALTHCHECK calls /healtha real endpoint

The check fetches http://127.0.0.1:5000/health from inside the container, using Python's standard library so no extra tool has to be installed. That endpoint is defined in app.py and deliberately doesn't touch the database — it reports whether the HTTP server is alive, not whether every dependency is. A health check that fails when the database blips would restart a perfectly healthy API.

The same /health path is reused by Compose's depends_on gate and by Azure's healthCheckPath setting — one endpoint, three consumers.

--bind 0.0.0.0:5000not 127.0.0.1

Binding to 0.0.0.0 means “listen on every network interface in this container.” Binding to 127.0.0.1 instead would accept only connections originating inside the container, so published ports and requests from the UI container would both be refused — a classic first-container bug that looks like a networking failure but isn't.

app:appmodule, then variable

Gunicorn's final argument is module:callable: load app.py and serve the object named app inside it — the Flask instance created by create_app(). It resolves relative to the working directory, which is why the second WORKDIR /app/metals_api exists.

--workers 2sized for a B1

Two worker processes, each able to handle a request at a time. That's a modest, deliberate number matched to the single small Azure App Service instance this project runs on; a larger machine would warrant more. Logs from both workers are pointed at standard output with -, so the container's logs are the application's logs.

Building and running it on its own

Normally you'd start this image through Compose, which supplies the database and all the variables. But building it alone is a fast way to confirm the image itself is sound.

Codey pointing to the right
build, then verify
# from the project root — note the context is "." and the Dockerfile is named explicitly
docker build -t metals-api:local -f metals_api/Dockerfile .

# check the layers and final size
docker images metals-api

Running it standalone with docker run -p 5000:5000 metals-api:local will start successfully and answer /health, but any endpoint that touches data will fail — there's no database and no DB_* variables. That's the image behaving correctly: configuration comes from outside, and Compose is what supplies it.

Common errors and how to fix them

What you seeLikely causeHow to fix it
failed to compute cache key: "/requirements.txt" not foundYou built with metals_api as the context, so the root requirements.txt is out of reach.Build from the repo root with -f metals_api/Dockerfile . — the context is the dot.
Container exits immediately, ModuleNotFoundError: No module named 'app'Gunicorn is resolving app:app from the wrong directory.Confirm the second WORKDIR /app/metals_api is present — it's what puts app.py on the path.
No log output at all, even on a crashPython's output is being buffered.Ensure PYTHONUNBUFFERED=1 is set; it's in the ENV block for exactly this reason.
Health status stuck at starting, then unhealthyThe app isn't listening on 5000, or is bound to 127.0.0.1.Check docker logs for a startup traceback, and confirm the bind address is 0.0.0.0:5000.
could not translate host name "db"The container was started on its own, outside the Compose network.Expected when running standalone. Use docker compose up, where the db service name resolves.
Codey giving a thumbs up

One image down — the UI's is shorter, but it has the more interesting trick in it.