“Works on my machine” is a backend problem, not a joke. Docker makes the environment part of the repository, so the Python version, the system libraries, and the way the app starts are the same for every developer and for production. Here is a setup that stays fast locally and lean in production.
First principles
Three facts about how Docker works shape every choice below.
- An image is a stack of layers, built in order. Each
RUN,COPYandADDinstruction in a Dockerfile adds a layer, and Docker reuses a cached layer until something it depends on changes. When one layer changes, every layer after it is rebuilt. - A container is a process. It runs the image’s command, with the image’s files, as whatever user the image names. Anything that process can do, someone who takes it over can do too.
- Code is the same everywhere; configuration is not. The database address, the secrets and the debug flag differ between a laptop and production. The code and its dependencies should not.
So: order layers from the slowest-changing to the fastest, keep configuration out of the image, run one image with different commands, and run the process as an unprivileged user.
A Dockerfile that builds small
A multi-stage build gives the dependency install its own stage, so it is cached apart from the application code, and the final image carries only what the app needs to run.
FROM python:3.12-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
FROM base AS deps
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM deps AS final
COPY . .
RUN python manage.py collectstatic --noinput
CMD ["gunicorn", "myproject.wsgi", "--bind", "0.0.0.0:8000"]
Layer order is a cache strategy
Requirements are copied and installed before the application code. A change to a view does not invalidate the dependency layer, so a rebuild skips the slow install entirely. Copy the slow-changing things first.
Compose for local development
Locally, the app needs a database and a cache beside it. Compose brings them up together, and a volume mount means a code change is picked up without a rebuild.
services:
web:
build: .
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
ports:
- "8000:8000"
depends_on:
- db
- redis
db:
image: postgres:16
redis:
image: redis:7
Config comes from the environment
Settings are read from the environment, never from a value baked into the image. The same image then runs in development, staging, and production, with nothing changed but the variables passed to it.
One image, different commands
There is no need for separate development and production images. Build one, and let the command differ: the development server locally, Gunicorn in production. Same code, same dependencies, fewer surprises.
Keep the image lean
A .dockerignore keeps the local virtualenv, the Git history, and editor files out of the build context. A slim base image and a final stage without build tools keep the shipped image small, which means faster pulls and a smaller attack surface.
Do not run as root
Add an unprivileged user and switch to it before the final command. If the container is ever compromised, an attacker lands as a limited user instead of root. It is a small change, and it is worth it.