Docker in Practice #2: Django + PostgreSQL compose — Two Containers as One

If #1 tuned one container, this post covers running two as a unit. The most common pairing — a web app and a DB.

This post in Docker in Practice:

  • #1 Containerizing FastAPI
  • #2 Django + PostgreSQL compose — two containers as one ← this post
  • #3 React/Next.js build container
  • #4 Building images in CI — GitHub Actions
  • #5 Registry push and tag strategy
  • #6 Cloud deploy — Fly.io / Railway / ECS

The domain is a standard Django project, the kind covered in the Django basics series. The point is the compose structure, not Django, so the model / view code is minimal.

Why compose #

Two containers can be brought up with docker run, but you’d do all this by hand:

Without compose — by hand
docker network create app-net
docker run -d --name db --network app-net \
    -e POSTGRES_PASSWORD=secret \
    -v pg-data:/var/lib/postgresql/data \
    postgres:17
docker run -d --name web --network app-net \
    -e DATABASE_URL=postgres://postgres:secret@db:5432/postgres \
    -p 8000:8000 \
    myapp

Add healthchecks, start order, automated migrations, and tidy env var handling on top of that, and you end up writing a shell script. Compose takes over that job.

The picture compose draws — in one file
services:
  db:    # postgres container
  web:   # django container
volumes:
  pg-data:    # persistent db data

This post fills out that picture.

Starting point — a Django project #

Create a project and add a PostgreSQL driver too.

Project setup
uv init blog-docker
cd blog-docker
uv add django 'psycopg[binary]' gunicorn
uv run django-admin startproject blog .
uv run python manage.py startapp posts

Pull the DB settings out of blog/settings.py into env vars.

blog/settings.py (excerpt)
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

DEBUG = os.getenv("DJANGO_DEBUG", "0") == "1"
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only")
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",")

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.getenv("POSTGRES_DB", "blog"),
        "USER": os.getenv("POSTGRES_USER", "blog"),
        "PASSWORD": os.getenv("POSTGRES_PASSWORD", ""),
        "HOST": os.getenv("POSTGRES_HOST", "db"),
        "PORT": os.getenv("POSTGRES_PORT", "5432"),
    }
}

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

Note the default host is db. In compose, when our service is named db, other containers on the same network can reach it as db. (Basics #4 networks.)

Dockerfile for Django #

Reuse the FastAPI Dockerfile structure from #1 almost as-is. The differences: launch with gunicorn, and run migrations + collectstatic in the entrypoint.

Dockerfile
FROM python:3.14-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

WORKDIR /app

ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY . .
RUN uv sync --frozen --no-dev


FROM python:3.14-slim AS runtime

RUN groupadd --system app && useradd --system --gid app --no-create-home app

WORKDIR /app
COPY --from=builder --chown=app:app /app /app
COPY --chown=app:app docker-entrypoint.sh /usr/local/bin/

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=blog.settings

USER app
EXPOSE 8000

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["gunicorn", "blog.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

The key is the ENTRYPOINT + CMD split:

  • ENTRYPOINTalways runs first, regardless of what the container does. Boot work like migrations and collectstatic goes here.
  • CMD — the main command the entrypoint hands off via exec at the end. gunicorn in production, runserver in dev.
docker-entrypoint.sh
#!/bin/sh
set -e

echo ">>> applying migrations"
python manage.py migrate --noinput

if [ "${DJANGO_COLLECTSTATIC:-1}" = "1" ]; then
    echo ">>> collecting static files"
    python manage.py collectstatic --noinput
fi

echo ">>> starting: $*"
exec "$@"

exec "$@" is critical. Without exec, the entrypoint shell holds PID 1 and the main process is its child — signals like SIGTERM never reach the main process (Advanced #6 PID 1). exec replaces the shell with the main process and hands over PID 1.

The script needs execute permissions:

Permissions
chmod +x docker-entrypoint.sh

Things to clean from the build context:

.dockerignore
.git
.venv
__pycache__/
*.pyc
.env
.env.*
db.sqlite3
staticfiles
node_modules

compose file — first skeleton #

Now for compose.yaml. The recommended file name for new projects is compose.yaml (docker-compose.yml still works).

compose.yaml — first version
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: secret
    volumes:
      - pg-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  web:
    build: .
    environment:
      DJANGO_DEBUG: "1"
      DJANGO_SECRET_KEY: dev-only
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: secret
      POSTGRES_HOST: db
    ports:
      - "8000:8000"
    depends_on:
      - db

volumes:
  pg-data:

Bring it up:

Start compose
docker compose up --build

On the first run:

  1. Pull the db image and start it — empty database initialization takes a few seconds.
  2. Build the web image (uses the Dockerfile).
  3. The web container’s entrypoint tries to migrate.
  4. Almost certainly — the DB isn’t ready to accept connections yet, and the migration breaks.

That’s where the next section comes in.

depends_on alone isn’t enough — needs healthcheck #

depends_on: [db] only guarantees container start order. The db container being up doesn’t mean PostgreSQL is accepting connections — Postgres takes a few seconds after pg_ctl start to finish initialization.

The fix: add a healthcheck to db and tie web’s depends_on to it (Intermediate #4 compose deep dive).

compose.yaml — tied to healthcheck
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: secret
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U blog -d blog"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 5s

  web:
    build: .
    env_file: .env
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

Now web doesn’t even start until db’s health flips to healthy. The first-boot migration collision goes away.

pg_isready ships in the Postgres image and is a lightweight check that asks “are you ready to accept connections?”.

.env for secret separation #

The compose file above has the password in plaintext. Compose can read external files via env_file or variable substitution.

.env (gitignored)
DJANGO_DEBUG=1
DJANGO_SECRET_KEY=dev-only-change-in-prod
POSTGRES_DB=blog
POSTGRES_USER=blog
POSTGRES_PASSWORD=secret
POSTGRES_HOST=db
compose.yaml — using env_file
services:
  db:
    image: postgres:17
    env_file: .env
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 3s
      retries: 10

  web:
    build: .
    env_file: .env
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pg-data:

The $$ in $$POSTGRES_USER escapes compose’s substitution so it passes through to the container’s shell. compose substitutes $VAR itself, so escape once for the inner shell to see it.

.env belongs in .gitignore; the repo carries only .env.example.

.env.example
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=
POSTGRES_DB=blog
POSTGRES_USER=blog
POSTGRES_PASSWORD=
POSTGRES_HOST=db

This is the pattern from Intermediate #5 env vars and secrets — exactly the same in practice.

Data persistence — named volume #

The volumes: - pg-data:/var/lib/postgresql/data entry on db is what matters. Drop it and the DB resets every time you bring the container down.

pg-data is a named volume, stored in Docker-managed storage. Inspect with docker volume.

Inspect volumes
docker volume ls
# DRIVER    VOLUME NAME
# local     blog-docker_pg-data

docker volume inspect blog-docker_pg-data
# Mountpoint, CreatedAt, etc.

Bind-mounting a host directory (./data:/var/lib/postgresql/data) works but isn’t recommended. On macOS / Windows, I/O between host and container is slow and permission collisions are common. The natural split is named volume for data, bind mount for code.

Dev mode — instant code reflection #

The compose setup above builds the web image on start. To reflect a code change, you’d have to run docker compose up --build every time — bad for dev flow.

An override file lets you change behavior in dev only.

compose.override.yaml — dev only
services:
  web:
    volumes:
      - .:/app             # mount host code into the container
      - /app/.venv         # but keep the container's .venv intact
    command:
      ["python", "manage.py", "runserver", "0.0.0.0:8000"]

docker compose up automatically merges compose.yaml + compose.override.yaml. CI / deploy doesn’t have the override, so production mode (gunicorn) stays.

The empty /app/.venv volume trick prevents the host’s .venv (or its absence) from clobbering the container’s venv.

profiles to separate management tools #

Operational commands (makemigrations, shell, dbshell) shouldn’t run by default. profiles makes them only run when you ask.

compose.yaml — profiles
services:
  # ... db, web ...

  manage:
    build: .
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    profiles: ["tools"]
    entrypoint: ["python", "manage.py"]
Invoke management commands
docker compose run --rm manage migrate
docker compose run --rm manage createsuperuser
docker compose run --rm manage shell

--rm auto-removes the container after the command. Services in a profile aren’t started by docker compose up by default — they need --profile tools to come up. Since they’re for one-shot use, it’s wasteful to have them running.

collectstatic and static files #

Running collectstatic from docker-entrypoint.sh on every boot is clean, but moving thousands of files on every container start can add up. Two options:

  • At boot — files aren’t baked into the image; created as the container starts. Each container repeats the work on horizontal scale-out.
  • At buildRUN python manage.py collectstatic in the Dockerfile bakes them in. Boot is faster, all containers share the same files.

In production the latter is usually cleaner. You may need a settings split so building doesn’t require SECRET_KEY etc. For the simple setup here, running it in the entrypoint is fine.

Running it and common pitfalls #

docker compose up is the whole loop.

Up / down
docker compose up -d        # background
docker compose logs -f web  # follow logs
docker compose ps           # status
docker compose down         # stop + remove containers/networks (volumes survive)
docker compose down -v      # also remove volumes (careful)

Common encounters:

connection refused — db hostweb’s env var was set to POSTGRES_HOST=localhost by mistake, or POSTGRES_HOST=db wasn’t picked up. Inside a container, localhost is the container itself.

psycopg / OperationalError on first boot — missing healthcheck, or one with too short a start period. Use a start_period of 5–10 seconds.

Migrations seem to run twice — when you horizontally scale web, each entrypoint runs them. Migrations are idempotent so concurrency isn’t usually catastrophic, but in production, run them as a separate one-shot job.

Code in compose.override.yaml not reflecting — Docker Desktop’s File Sharing may not include that directory. Check Settings → Resources → File Sharing.

docker compose down wiped DB datadown alone doesn’t remove named volumes. If they vanished, down -v was used. Never -v in production.

Wrap-up #

  • compose declares the relationships between containers in one file. services, volumes, networks are the skeleton.
  • External dependencies like a DB need healthcheck + condition: service_healthy for stable first boots.
  • Push secrets out to .env, keep only .env.example in the repo. compose reads via env_file.
  • Persist data in named volumes. Use host bind mounts for code only.
  • Put boot work like migrations / collectstatic in ENTRYPOINT, but use exec "$@" to hand PID 1 to the main process.
  • Separate dev mode via compose.override.yaml. Bind-mount code, keep the container’s venv.
  • Management commands (makemigrations, shell) live in profiles + docker compose run --rm for one-shot use.

In the next post (#3 React/Next.js build container) we leave the backend and head to the frontend. Next.js standalone output, the deps → build → runner three-stage pattern, the build/runtime split for NEXT_PUBLIC env vars, and static-hosting alternatives.

X