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:
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 \
myappAdd 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.
services:
db: # postgres container
web: # django container
volumes:
pg-data: # persistent db dataThis post fills out that picture.
Starting point — a Django project #
Create a project and add a PostgreSQL driver too.
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 postsPull the DB settings out of blog/settings.py into env vars.
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.
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:
ENTRYPOINT— always runs first, regardless of what the container does. Boot work like migrations and collectstatic goes here.CMD— the main command the entrypoint hands off viaexecat the end.gunicornin production,runserverin dev.
#!/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:
chmod +x docker-entrypoint.shThings to clean from the build context:
.git
.venv
__pycache__/
*.pyc
.env
.env.*
db.sqlite3
staticfiles
node_modulescompose file — first skeleton #
Now for compose.yaml. The recommended file name for new projects is compose.yaml (docker-compose.yml still works).
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:
docker compose up --buildOn the first run:
- Pull the
dbimage and start it — empty database initialization takes a few seconds. - Build the
webimage (uses the Dockerfile). - The
webcontainer’s entrypoint tries to migrate. - 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).
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_healthyNow 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.
DJANGO_DEBUG=1
DJANGO_SECRET_KEY=dev-only-change-in-prod
POSTGRES_DB=blog
POSTGRES_USER=blog
POSTGRES_PASSWORD=secret
POSTGRES_HOST=dbservices:
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.
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=
POSTGRES_DB=blog
POSTGRES_USER=blog
POSTGRES_PASSWORD=
POSTGRES_HOST=dbThis 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.
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.
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.
services:
# ... db, web ...
manage:
build: .
env_file: .env
depends_on:
db:
condition: service_healthy
profiles: ["tools"]
entrypoint: ["python", "manage.py"]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 build —
RUN python manage.py collectstaticin 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.
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 host — web’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 data — down 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,networksare the skeleton. - External dependencies like a DB need healthcheck +
condition: service_healthyfor stable first boots. - Push secrets out to
.env, keep only.env.examplein the repo. compose reads viaenv_file. - Persist data in named volumes. Use host bind mounts for code only.
- Put boot work like migrations / collectstatic in
ENTRYPOINT, but useexec "$@"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 inprofiles+docker compose run --rmfor 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.