Deployment &
Best Practices

Safely shipping your Django app to production — locking down secrets with python-decouple, packaging everything with Docker, and automating the pipeline with CI/CD.

A working app on your laptop is just the beginning. The gap between runserver and a real production deployment involves managing secrets, reproducible environments, and automated testing gates. These three tools close that gap.

📦 What we'll deploy The Books API from the CRUD module — a Django + DRF app backed by PostgreSQL, served by Gunicorn, reverse-proxied by Nginx, all orchestrated with Docker Compose, and deployed automatically on every push via GitHub Actions.

The Deployment Stack

LayerToolPurpose
Configpython-decoupleRead env vars from .env file; never hardcode secrets
RuntimeGunicornProduction WSGI server replacing runserver
ProxyNginxHandles static files, TLS termination, load balancing
ContainerDockerReproducible, isolated build that runs everywhere
OrchestrationDocker ComposeRuns web + db + nginx as a single declarative unit
AutomationGitHub ActionsTest → Build → Push → Deploy on every git push

The Full Pipeline at a Glance

💻
Code
git push
trigger
🧪
Test
pytest + lint
pass
🐳
Build
docker build
push
📦
Registry
ghcr.io / ECR
deploy
🚀
Production
docker pull

Environment Variables

Keep secrets out of source control by reading configuration from the environment — not from hardcoded strings.

⚠️ Never commit secrets SECRET_KEY, database passwords, and API keys must never appear in your git history. Even a single commit is enough for an attacker to find them via GitHub search or git log.

python-decouple Setup

1
Install
terminalbash
pip install python-decouple
# add to requirements.txt
2
Create a .env file in the project root
.env.env
# Django core
DEBUG=True
SECRET_KEY=your-very-secret-key-here
ALLOWED_HOSTS=localhost,127.0.0.1

# Database
DB_NAME=mydb
DB_USER=postgres
DB_PASSWORD=supersecret
DB_HOST=db
DB_PORT=5432

# Email (optional)
EMAIL_HOST_USER=noreply@example.com
EMAIL_HOST_PASSWORD=smtp-password
3
Add .env to .gitignore
.gitignoretext
.env
*.pyc
__pycache__/
db.sqlite3
staticfiles/
4
Read values in settings.py
settings.pyPython
from decouple import config, Csv

# ── Core ──────────────────────────────────────────────
SECRET_KEY    = config('SECRET_KEY')
DEBUG         = config('DEBUG', default=False, cast=bool)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv())

# ── Database ──────────────────────────────────────────
DATABASES = {
    'default': {
        'ENGINE':   'django.db.backends.postgresql',
        'NAME':     config('DB_NAME'),
        'USER':     config('DB_USER'),
        'PASSWORD': config('DB_PASSWORD'),
        'HOST':     config('DB_HOST', default='localhost'),
        'PORT':     config('DB_PORT', default='5432'),
    }
}

Key Variables Reference

SECRET_KEY
50-char random string
Signs cookies, sessions, and JWTs. Rotate immediately if leaked.
DEBUG
False (production)
When True, Django shows full stack traces. Never True in prod.
ALLOWED_HOSTS
yourdomain.com
Comma-separated hostnames Django will serve. Prevents host-header attacks.
DB_PASSWORD
random strong password
PostgreSQL password. Must match the value in docker-compose.yml.
DB_HOST
db (in Docker)
Service name in docker-compose resolves as a hostname inside the network.
DJANGO_SETTINGS_MODULE
mysite.settings
Tells Django which settings file to use. Useful for separate prod/dev configs.
💡 Decouple priority order python-decouple looks for values in this order: (1) actual OS environment variables, (2) .env file, (3) .ini file, (4) the default= argument. This means production servers can inject secrets via environment without needing a file on disk.

Dockerizing the App

Package your Django app into a container image — identical behaviour on every machine, from your laptop to the cloud.

Anatomy of the Dockerfile

Each instruction creates a new layer. Ordering matters — put things that change least at the top to maximise cache hits.

1
FROM
python:3.12-slim
base image
2
ENV
PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
no .pyc / live logs
3
WORKDIR
/app
all cmds run here
4
COPY
requirements.txt .
copy deps first
5
RUN
pip install --no-cache-dir -r requirements.txt
cached unless req changes
6
COPY
. .
copy app code last
7
EXPOSE
8000
document port
8
CMD
["gunicorn", "mysite.wsgi:application", "--bind", "0.0.0.0:8000"]
start server

Full Dockerfile

DockerfileDocker
# ── Stage: build ─────────────────────────────────────
FROM python:3.12-slim AS base

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Install system deps needed by psycopg2
RUN apt-get update && apt-get install -y \
    libpq-dev gcc \
    && rm -rf /var/lib/apt/lists/*

# Install Python deps first (better layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy source code
COPY . .

# Collect static files
RUN python manage.py collectstatic --no-input

EXPOSE 8000

# Production server
CMD ["gunicorn", "mysite.wsgi:application", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "4", \
     "--timeout", "120"]

.dockerignore — keep the image lean

.dockerignoretext
.env                 # never bake secrets into the image
.git
.gitignore
*.pyc
__pycache__/
*.md
tests/
.venv/
node_modules/
staticfiles/         # generated by collectstatic
🔑 Layer caching tip Copy requirements.txt and run pip install before copying your source code. Docker caches each layer; this way, adding a new Python file doesn't trigger a full dependency re-install on every build.

Build & Run

terminalbash
# Build the image
docker build -t mysite:latest .

# Run locally, injecting the .env file
docker run --env-file .env -p 8000:8000 mysite:latest

# Inspect layers (great for debugging size)
docker history mysite:latest

Docker Compose

Wire web, database, and reverse-proxy into one declarative file. Start everything with a single command.

ℹ️ What Compose adds Docker Compose creates an isolated network for your services, mounts volumes for persistent data, manages startup order with depends_on, and lets you override settings per-environment.

The Full docker-compose.yml

docker-compose.yml (dev)YAML
version: '3.9'

services:

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB:       ${DB_NAME}
      POSTGRES_USER:     ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"   # expose for local GUI tools

  web:
    build: .
    command: python manage.py runserver 0.0.0.0:8000
    env_file:
      - .env
    volumes:
      - .:/app         # live reload: mounts source code
    ports:
      - "8000:8000"
    depends_on:
      - db

volumes:
  pgdata:
docker-compose.yml (prod)YAML
version: '3.9'

services:

  db:
    image: postgres:16-alpine
    restart: always
    environment:
      POSTGRES_DB:       ${DB_NAME}
      POSTGRES_USER:     ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
        # no ports: — not exposed outside the Docker network

  web:
    image: ghcr.io/yourorg/mysite:latest   # pre-built image
    restart: always
    env_file:
      - .env.production
    depends_on:
      - db
    expose:
      - "8000"       # visible to nginx, not the host

  nginx:
    image: nginx:alpine
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - staticfiles:/app/staticfiles
      - certbot-etc:/etc/letsencrypt
    depends_on:
      - web

volumes:
  pgdata:
  staticfiles:
  certbot-etc:

Essential Commands

terminalbash
# Start all services in the background
docker compose up -d

# Run migrations inside the running web container
docker compose exec web python manage.py migrate

# Create a superuser
docker compose exec web python manage.py createsuperuser

# Tail logs from all services
docker compose logs -f

# Rebuild after Dockerfile changes
docker compose up -d --build

# Stop and remove containers (keeps volumes)
docker compose down

# Stop and remove containers AND volumes (wipes db!)
docker compose down -v

Nginx config for Django

nginx.confNginx
server {
    listen 80;
    server_name yourdomain.com;

    # Serve collected static files directly (no Django)
    location /static/ {
        alias /app/staticfiles/;
    }

    # Proxy everything else to Gunicorn
    location / {
        proxy_pass         http://web:8000;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }
}

CI/CD Pipelines

Automate the path from git push to live production — tests run, image builds, and the server updates, without a single manual step.

What CI/CD Actually Does

Continuous Integration
On every push, automatically run tests, linting, and security checks. Blocks merges on failure.
Continuous Delivery
Builds a Docker image and pushes it to a container registry. Ready to deploy, awaiting approval.
Continuous Deployment
Fully automated — pushes to main automatically trigger deployment to the production server.

GitHub Actions Workflow

.github/workflows/deploy.ymlYAML
name: Test → Build → Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE:    ghcr.io/${{ github.repository }}

jobs:

  # ── Job 1: Run tests ──────────────────────────────
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB:       testdb
          POSTGRES_USER:     postgres
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Run tests
        env:
          DEBUG:       True
          SECRET_KEY:  ci-only-not-real
          DB_HOST:     localhost
          DB_PASSWORD: postgres
        run: pytest --tb=short -q

  # ── Job 2: Build & push image (main only) ─────────
  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4
      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build & push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ${{ env.IMAGE }}:latest

  # ── Job 3: Deploy to server ───────────────────────
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: SSH & deploy
        uses: appleboy/ssh-action@v1
        with:
          host:     ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key:      ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /srv/mysite
            docker compose pull
            docker compose up -d --no-deps web
            docker compose exec -T web python manage.py migrate
🔐 GitHub Secrets Store sensitive values in Settings → Secrets → Actions on your repo. The workflow reads them as ${{ secrets.MY_SECRET }} — they never appear in logs. Required secrets for this workflow: SERVER_HOST, SERVER_USER, SERVER_SSH_KEY.

Job Dependency Graph

🧪
test
all branches
needs: test
🐳
build-push
main only
needs: build
🚀
deploy
main only
💡 PR Safety Net The test job runs on all branches including pull requests. Only build-push and deploy are gated to the main branch — this means every PR gets tested automatically before it can merge.

Deploy Checklist

Click each item as you complete it. Don't go live until everything is ticked.

Secrets & Configuration

DEBUG = False — confirmed via python-decouple reading from the prod .env
critical
SECRET_KEY is a unique, random 50-character string — not the Django default
critical
ALLOWED_HOSTS lists only your real domain(s) — no * wildcard
critical
.env is in .gitignore and not tracked in git history
critical
A .env.example file with dummy values exists for onboarding new devs
good practice

Database

All migrations applied on the production database
critical
Database volume is mounted so data persists across container restarts
critical
Database port not exposed to the host in production Compose file
security
Backup strategy in place — automated pg_dump or managed DB service
ops

Docker & Containers

.dockerignore excludes .env, .git, and test files from the image
security
Production Compose uses specific image tags, not :latest
good practice
All services have restart: always for automatic recovery
ops
collectstatic runs in Dockerfile so Nginx serves files directly
performance

HTTP & Security

HTTPS configured — TLS certificate via Let's Encrypt / Certbot
security
SECURE_SSL_REDIRECT = True — HTTP requests redirected to HTTPS
security
HSTS headers enabled — SECURE_HSTS_SECONDS set to ≥ 31536000
security
Nginx configured to serve static files — not proxied through Gunicorn
performance

CI/CD & Monitoring

All tests pass in the CI pipeline before deployment runs
critical
Server SSH keys stored as GitHub Secrets — not in the workflow file
security
Error logging set up — Sentry, Datadog, or Django's ADMINS email
ops
Uptime monitoring active — UptimeRobot, Betterstack, or similar
ops

Knowledge Quiz

Six questions across environment variables, Docker, Compose, and CI/CD. Click to reveal the explanation.

1. You call config('DEBUG', default=False, cast=bool) in settings.py. Your .env file contains DEBUG=True. What value does Django receive?
2. Why should you copy requirements.txt and run pip install before copying your full source code in a Dockerfile?
3. In the production docker-compose.yml, the db service has no ports: entry. How can the web service still connect to it?
4. The GitHub Actions workflow has needs: test on the build-and-push job. What happens if the test job fails?
5. You accidentally commit your SECRET_KEY to a public GitHub repo. You immediately delete the commit. Are you safe?
6. Which command applies database migrations inside an already-running Compose stack without restarting the web container?