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êng
FROM python:3.14-slim AS builder
WORKDIR /app
COPY 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 compiler
FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY . .
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["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 CMD
RUN groupadd -r appuser && useradd -r -g appuser appuser \
&& mkdir -p /app/staticfiles /app/media \
&& chown -R appuser:appuser /app
USER 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 CMD
HEALTHCHECK --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.yml
services:
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-stopped
volumes:
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

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