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.
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
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
# 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
Common errors and how to fix them
| What you see | Likely cause | How to fix it |
|---|---|---|
| failed to compute cache key: "/requirements.txt" not found | You 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 crash | Python'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 unhealthy | The 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. |