Episode 15 set up automated backups and restore drills — but a backup is only useful if the application can actually run again. This episode packages the expense-tracker app into a proper production Docker image: multi-stage builds for a lean image, a non-root user at runtime, and a healthcheck so the system knows whether the app is alive or hung.
1. Multi-stage builds: a smaller image, a smaller attack surface
A single-stage Dockerfile usually drags the compiler and the pip cache into the final image — the image balloons to over a gigabyte and carries tools an attacker could use. A multi-stage build separates the build step into its own stage, so the runtime stage keeps only what it actually needs.
# Dockerfile# Tầng build: cài dependencies vào venv riêngFROM python:3.14-slim AS builderWORKDIR /appCOPY requirements.txt .RUN python -m venv /opt/venv \ && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt# Tầng chạy: chỉ giữ venv + code, không còn pip cache hay compilerFROM python:3.14-slimWORKDIR /appCOPY --from=builder /opt/venv /opt/venvCOPY . .ENV PATH="/opt/venv/bin:$PATH"ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1EXPOSE 8000CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
The resulting image is usually only around 150–200 MB instead of more than 1 GB. Use a pinned tag such as python:3.14-slim instead of latest so the next build doesn’t silently switch the Python version.
2. Run as a non-root user
By default, processes inside a container run as root. If an attacker escapes the container, they have root on the host. Create a dedicated user and switch to it before running the app.
# Thêm vào tầng chạy, trước dòng CMDRUN groupadd -r appuser && useradd -r -g appuser appuser \ && mkdir -p /app/staticfiles /app/media \ && chown -R appuser:appuser /appUSER appuser
Every command that needs root must run before the USER line. The static and media directories must belong to appuser, otherwise collectstatic and file uploads will fail with Permission denied on the very first deploy.
3. Healthcheck: let the system detect a hung app on its own
An app can still be running yet stop answering requests — a stuck database pool, for example — and a manual restart is too slow. A healthcheck periodically calls the /health endpoint built in episode 8; if the endpoint keeps failing, Docker marks the container unhealthy so the orchestrator can restart it.
# Thêm vào tầng chạy, trước dòng CMDHEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/')"
Use Python’s built-in urllib instead of installing curl, so the image doesn’t grow. The start-period=40s parameter gives the app time to start up and run migrations, so it isn’t wrongly declared dead the moment it comes up.
4. Putting it together with docker-compose for production
A single compose file describes web, database, and Redis, with depends_on making sure the web service only starts once the database is fully healthy.
# docker-compose.prod.ymlservices: web: build: . env_file: .env.prod ports: - "8000:8000" depends_on: db: condition: service_healthy restart: unless-stopped db: image: postgres:17 env_file: .env.prod volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"] interval: 10s timeout: 5s retries: 5 redis: image: redis:8-alpine restart: unless-stoppedvolumes: pgdata:
Run it with docker compose -f docker-compose.prod.yml up -d --build, and check with docker compose ps. Remember to run migrations inside the web container before opening traffic: docker compose exec web python manage.py migrate.
Applying it to your project
Apply this to the expense-tracker app right now: rewrite the Dockerfile as a multi-stage build following the template above, run it as the appuser user, attach a healthcheck to the /health endpoint from episode 8, and deploy with the production compose file. After deploying, verify: the image is under 250 MB, docker compose ps reports web as healthy, and /health returns 200. From the next episode on, the CI/CD pipeline will build this image automatically on every code push.
Related posts
- Django in Production #15: Backup and Restore — Automated pg_dump, Media Backups, and Regular Restore Drills
- Django Running in Production #14: Secure File and Media Uploads — Validate File Types, Limit Size, Signed URLs
- Django Production Operations #3: Gunicorn + Nginx — Running Django Behind a Reverse Proxy
Frequently asked questions
How small should a production image be?
Around 150–250 MB with a Python slim image is reasonable. An image over 1 GB usually means it carries build tools, the pip cache, or a copy of the whole .git directory — add a .dockerignore that excludes .git, __pycache__, .env, and node_modules if present.
Does running as non-root lose any features?
No, as long as you chown the directories the app needs to write to. The most common error is collectstatic and media uploads failing with Permission denied — create the directories and chown them before the USER line and you’re done.
How is a healthcheck different from the /health endpoint in episode 8?
The /health endpoint is where the app reports its own status; the healthcheck is the mechanism by which Docker calls that endpoint on a schedule. Without a healthcheck, Docker only knows the container is still running — not whether the app inside has hung.
References
- Docker — Multi-stage builds: https://docs.docker.com/build/building/multi-stage/
- Docker — HEALTHCHECK instruction: https://docs.docker.com/reference/dockerfile/#healthcheck
- Django — Deployment checklist: https://docs.djangoproject.com/en/stable/howto/deployment/checklist/
