RizTech Academy logo
RizTech Academy
DeploymentLesson 4 of 535 min

Containerising a Django app

"It works on my machine" is the oldest problem in software, and containers are the modern answer to it. A Docker container packages your application with its exact Python version, dependencies and system libraries, so it runs identically on your laptop, in CI, and in production. For deploying Django — and for running PostgreSQL locally so development matches production — Docker has become the common baseline. This lesson is a practical, honest introduction: enough to containerise Nidaan and understand what you are doing.

What a container is, and why it helps

A container is a lightweight, isolated package containing your app and everything it needs to run — the Python runtime, your pip dependencies, system libraries — defined by a text file (Dockerfile) and built into an image you can run anywhere Docker runs. Unlike a virtual machine, it shares the host's kernel, so it is fast to start and light on resources. The benefit for Django: the environment is reproducible and explicit. No "which Python is installed here?", no "did you pip install the new dependency?", no drift between your machine and the server — the image is the same everywhere. It also makes running dependent services (PostgreSQL, Redis) locally trivial, so your development environment can mirror production exactly.

A Dockerfile for Django

A Dockerfile is the recipe for your image. A straightforward one for Nidaan:

FROM python:3.12-slim                      # a specific, slim Python base image

ENV PYTHONUNBUFFERED=1 \                    # logs appear immediately, not buffered
    PYTHONDONTWRITEBYTECODE=1               # no .pyc files in the container

WORKDIR /app

# Install dependencies FIRST, as their own layer, so they are cached
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Then copy the application code
COPY . .

# Collect static files at build time
RUN python manage.py collectstatic --noinput

# Run with a production server (NOT runserver)
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

Three deliberate choices worth understanding:

  • A specific base image (python:3.12-slim), not latest — pin the Python version so the image is reproducible, and slim to keep it small.
  • Copy requirements.txt and install before copying the code. Docker caches each step as a layer; if your code changes but dependencies do not, Docker reuses the cached dependency layer and rebuilds fast. Copy code first and every code change reinstalls everything — a common, slow mistake.
  • gunicorn, not runserver. The container runs a real production WSGI server (Gunicorn); runserver is the development server and must never face production traffic (the deploying lesson covers the server).

docker compose for the whole stack

A real Django app is not one container — it is the app plus a database plus maybe Redis. Docker Compose describes the whole set in one docker-compose.yml and runs them together:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: nidaan
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data      # persist the database across restarts
  web:
    build: .
    command: gunicorn config.wsgi:application --bind 0.0.0.0:8000
    environment:
      DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/nidaan
    depends_on:
      - db
    ports:
      - "8000:8000"

volumes:
  pgdata:

docker compose up starts PostgreSQL and the web app together, wired so the app reaches the database at the hostname db. This is how you run a production-matching stack locally — Postgres included — so "develop on the same database you deploy on" (the postgres lesson) becomes effortless. Note the volume on the database: without it, your data lives inside the container and is lost when the container is removed; the named volume persists it.

The things people get wrong

Containers introduce their own footguns; the common ones for Django:

  • Secrets baked into the image. Never COPY a .env with real secrets into the image, or hard-code them in the Dockerfile — the image is shareable and the secret would travel with it. Pass secrets as environment variables at run time (Compose environment:, or the platform's secret manager), never build them in.
  • Data in the container. A container's filesystem is ephemeral — remove the container and its internal files vanish. The database must use a persistent volume (above), and user-uploaded media must go to an object store (the static-files lesson), never the container's disk.
  • runserver in the container. Using the development server in production for "simplicity" — do not; use Gunicorn/Uvicorn.
  • A giant image. Copying the whole project including .git, the venv, and node modules bloats the image; use a .dockerignore (like .gitignore) to exclude them.

Is Docker required? An honest note

No. You can deploy Django without Docker — to a platform-as-a-service that builds from your repository, or to a server you configure directly. Docker's value is reproducibility and portability: the same image runs everywhere, and the whole stack (app + Postgres + Redis) spins up with one command. That is genuinely worth it for most teams, and it is why Docker is the common baseline — but it is a means, not a requirement. If a platform gives you reproducible builds without you writing a Dockerfile, that is fine too. Use containers because they make your deploys reliable and your local environment match production — not because a checklist said to.

Check your work

What a container is. A lightweight, isolated package of your app plus its exact runtime, dependencies and libraries, defined by a Dockerfile and built into an image that runs identically everywhere — solving "works on my machine".

The Dockerfile choices. A pinned slim base image (reproducible, small); install dependencies before copying code (layer caching); run Gunicorn, not runserver.

Docker Compose. Describes the whole stack (web + Postgres + Redis) in one file, run with compose up, wired by service name — the easy way to run a production-matching stack, including Postgres, locally. Use a volume so the database persists.

The footguns. No secrets baked into the image (pass as runtime env vars); no data in the ephemeral container filesystem (volumes for the DB, object store for media); no runserver in production; a .dockerignore to keep the image small.

Honest scope. Docker is not required — its value is reproducibility and portability, worth it for most teams, but a means, not an end.

Practice

  1. Write a Dockerfile for Nidaan with a pinned base image, dependency-layer caching, collectstatic, and a Gunicorn CMD; build the image.
  2. Reorder the Dockerfile to copy code before installing dependencies, rebuild after a code change, and observe the slower rebuild — then fix the order.
  3. Write a docker-compose.yml with web and db services; run compose up and confirm the app connects to PostgreSQL at hostname db.
  4. Remove the database volume, restart, and watch the data vanish; add the volume back and confirm it persists.
  5. Add a .dockerignore excluding .git, the venv and other cruft; compare image sizes.
  6. Reason about why baking a .env with real secrets into an image is dangerous, and how to pass secrets instead.

Official documentation

Next: deploying, HTTPS and the launch checklist.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship