Before you start
- Docker installed (Docker Desktop or the engine) and a Python project with a requirements.txt
- A command that starts your app, for example gunicorn app:app or python -m myapp
A Dockerfile is a recipe for an image: a base, your dependencies, your code and a command. A first attempt usually works and is also large, slow to rebuild and runs as root. A few habits fix all three.
A starting point
FROM python:3.12-slim
# keep Python from writing .pyc files and buffering output, which helps container logs
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# 1. dependencies first: this layer only rebuilds when requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 2. then the code, which changes far more often
COPY . .
# 3. do not run as root
RUN useradd --create-home --uid 10001 appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Why it is written this way
A slim, pinned base. python:3.12-slim is a Debian-based image with Python and little else, so it is much smaller than the full image and has fewer things to patch. Naming the version (3.12) instead of leaving it out means the base only changes when you decide.
Dependencies before code. Docker builds in layers and reuses a layer when nothing above it has changed. If you copy all your code first, every edit invalidates the dependency install and every build re-downloads every package. Copying only requirements.txt first means a code-only change rebuilds just the last layers, in seconds.
--no-cache-dir. Pip's download cache is useless inside an image and only makes it bigger.
A non-root user. By default a container's process runs as root. If an attacker finds a flaw in the app, they then act as root inside the container. A normal user shrinks what they can do. Create the user after installing packages, and give it a fixed numeric id so file permissions are predictable.
The exec form of CMD. The list form (["gunicorn", ...]) runs the program directly as the main process, so it receives stop signals and shuts down cleanly. The plain string form runs through a shell that may swallow them.
Add a .dockerignore file
COPY . . copies everything in the folder, including things that should never be in an image. A .dockerignore next to the Dockerfile keeps them out and shrinks the build context:
.git
.venv
__pycache__/
*.pyc
.env
*.env
node_modules
tests/
Dockerfile
docker-compose*.yml
The line that matters most is .env. Without it, your secrets are copied into a layer that anyone who can pull the image can read.
Secrets and configuration
- Never put secrets in the image, whether through
COPY,ENVorARG. Image layers are stored and shared, and deleting a file in a later layer does not remove it from the earlier one. - Pass configuration at run time:
docker run --env-file .env ..., or your orchestrator's secret mechanism. - If a build needs a credential (a private package index), use a build secret (
RUN --mount=type=secret,...) so it is never saved in a layer.
Build, run, check
docker build -t myapp:1.0 .
docker run --rm -p 8000:8000 --env-file .env myapp:1.0
docker run --rm myapp:1.0 id # the user the app runs as: not root
docker images myapp # the size
Change one line of code and build again: the dependency step should report that it used the cache.
Going further
- Multi-stage builds compile or build in one stage and copy only the result into a clean final image, keeping compilers and build tools out of production.
- Scan the image for known vulnerabilities (your registry or a scanner) and rebuild regularly to pick up base-image fixes.
- Add a health check so the platform knows when the app is really ready.
- Keep one process per container and let the platform handle restarts and scaling.
Expected result
docker build produces an image of modest size, the container starts your app as a non-root user, and rebuilding after a code-only change reuses the cached dependency layer.
Frequently asked questions
Why not just use the 'latest' tag?
latest moves. A build now and a build in a month can use different bases, and you cannot tell which one a bug came from. Pin at least the Python version, and pin the full digest if you need exact reproducibility.
Alpine or slim?
Slim (Debian-based) images are the safer default for Python, because many packages ship ready-built wheels for them. Alpine uses a different C library, and packages with compiled parts often need to be built from source, which makes builds slower and sometimes harder.
References
From Grokem: https://www.grokem.com/devops-and-platform/containers/python-dockerfile-small-and-safe



