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.
The Deployment Stack
| Layer | Tool | Purpose |
|---|---|---|
| Config | python-decouple | Read env vars from .env file; never hardcode secrets |
| Runtime | Gunicorn | Production WSGI server replacing runserver |
| Proxy | Nginx | Handles static files, TLS termination, load balancing |
| Container | Docker | Reproducible, isolated build that runs everywhere |
| Orchestration | Docker Compose | Runs web + db + nginx as a single declarative unit |
| Automation | GitHub Actions | Test → Build → Push → Deploy on every git push |
The Full Pipeline at a Glance
Environment Variables
Keep secrets out of source control by reading configuration from the environment — not from hardcoded strings.
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
pip install python-decouple
# add to requirements.txt
.env file in the project root# 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
.env to .gitignore.env
*.pyc
__pycache__/
db.sqlite3
staticfiles/
settings.pyfrom 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
.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.
Full Dockerfile
# ── 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
.env # never bake secrets into the image
.git
.gitignore
*.pyc
__pycache__/
*.md
tests/
.venv/
node_modules/
staticfiles/ # generated by collectstatic
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
# 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.
depends_on, and lets you override settings per-environment.
The Full docker-compose.yml
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:
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
# 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
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
GitHub Actions Workflow
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
${{ secrets.MY_SECRET }} — they never appear in logs. Required secrets for this workflow: SERVER_HOST, SERVER_USER, SERVER_SSH_KEY.
Job Dependency Graph
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
* wildcard.gitignore and not tracked in git historyDatabase
Docker & Containers
.env, .git, and test files from the image:latestrestart: always for automatic recoveryHTTP & Security
CI/CD & Monitoring
Knowledge Quiz
Six questions across environment variables, Docker, Compose, and CI/CD. Click to reveal the explanation.
config('DEBUG', default=False, cast=bool) in settings.py. Your .env file contains DEBUG=True. What value does Django receive?requirements.txt and run pip install before copying your full source code in a Dockerfile?docker-compose.yml, the db service has no ports: entry. How can the web service still connect to it?needs: test on the build-and-push job. What happens if the test job fails?SECRET_KEY to a public GitHub repo. You immediately delete the commit. Are you safe?