Preview Environments for Django Pull Requests
Spin up a temporary Django environment for every pull request: GitHub Actions, Docker Compose on a preview host, wildcard subdomains, per-PR databases with seed data, and automatic teardown.
Problem statement
A single staging environment becomes a bottleneck once several people work in parallel: branches overwrite each other, reviewers test the wrong code, and migrations from one branch break another. Preview environments give every pull request its own short-lived copy of the app at a URL like pr-42.preview.example.com, created when the PR opens and destroyed when it closes.
Doing this for Django requires answering a few questions carefully: where previews run, how each gets an isolated database with useful data, how secrets are scoped, how previews are kept private and out of search engines, and how they are cleaned up. This guide builds a practical setup with GitHub Actions and Docker Compose on one preview host.
- Run a dedicated preview host with Traefik and a wildcard DNS record
*.preview.example.com. - In GitHub Actions, on
pull_requestevents, build the image taggedpr-<number>and push it to a registry. - SSH to the preview host and start a Compose project named
pr-<number>with its own PostgreSQL container and Traefik labels forpr-<number>.preview.example.com. - Migrate and load seed data (never a copy of production personal data).
- Use preview-only secrets, a console or sandbox email backend, basic auth, and
noindex. - On
pull_requestclosed, rundocker compose down -vfor that project.
Step-by-step solution
1. Prepare the preview host
A single VM with enough memory for several small environments is usually sufficient (roughly 300 to 500 MB per preview for Django plus PostgreSQL). Install Docker and run Traefik with a wildcard certificate (DNS-01 challenge) or HTTP challenges per hostname:
*.preview.example.com A 203.0.113.50
Create a shared Docker network that Traefik and every preview join:
docker network create previews
2. Compose file for one preview
# deploy/preview/compose.yaml
services:
web:
image: ghcr.io/example/myapp:${PREVIEW_TAG}
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 2
environment:
DJANGO_SETTINGS_MODULE: config.settings.preview
DJANGO_ALLOWED_HOSTS: ${PREVIEW_HOST}
DJANGO_SECRET_KEY: ${PREVIEW_SECRET_KEY}
DATABASE_URL: postgres://app:app@db:5432/app
depends_on:
db:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.docker.network=previews
- traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${PREVIEW_HOST}`)
- traefik.http.routers.${COMPOSE_PROJECT_NAME}.entrypoints=websecure
- traefik.http.routers.${COMPOSE_PROJECT_NAME}.tls.certresolver=le
- traefik.http.routers.${COMPOSE_PROJECT_NAME}.middlewares=preview-auth@file
- traefik.http.services.${COMPOSE_PROJECT_NAME}.loadbalancer.server.port=8000
networks: [default, previews]
db:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
healthcheck:
test: ["CMD", "pg_isready", "-U", "app"]
interval: 5s
retries: 10
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:
networks:
previews:
external: true
Each Compose project gets its own default network and volumes, so previews cannot see each other's databases. Only the web container joins the shared previews network that Traefik uses. The preview-auth middleware (basic auth) is defined once in Traefik's file provider.
3. Preview settings
# config/settings/preview.py
from .production import * # noqa
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Keep previews out of search engines
MIDDLEWARE = ["config.middleware.NoIndexMiddleware", *MIDDLEWARE]
# config/middleware.py
class NoIndexMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response["X-Robots-Tag"] = "noindex, nofollow"
return response
Point third-party integrations (payments, SMS, webhooks) at sandbox accounts or disable them. A preview must never be able to charge a real card or email real customers.
4. Deploy workflow
# .github/workflows/preview.yml
name: preview
on:
pull_request:
types: [opened, synchronize, reopened, closed]
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
deploy:
if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions: { contents: read, packages: write, pull-requests: write }
env:
TAG: pr-${{ github.event.pull_request.number }}
HOST: pr-${{ github.event.pull_request.number }}.preview.example.com
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with: { registry: ghcr.io, username: "${{ github.actor }}", password: "${{ secrets.GITHUB_TOKEN }}" }
- uses: docker/build-push-action@v6
with: { push: true, tags: "ghcr.io/example/myapp:${{ env.TAG }}" }
- name: Start preview
env:
SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
SECRET_KEY: ${{ secrets.PREVIEW_DJANGO_SECRET_KEY }}
run: |
mkdir -p ~/.ssh && echo "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
ssh-keyscan preview.example.com >> ~/.ssh/known_hosts
scp deploy/preview/compose.yaml preview@preview.example.com:previews/$TAG.yaml
ssh preview@preview.example.com "
export COMPOSE_PROJECT_NAME=$TAG PREVIEW_TAG=$TAG PREVIEW_HOST=$HOST PREVIEW_SECRET_KEY='$SECRET_KEY'
docker compose -f previews/$TAG.yaml pull
docker compose -f previews/$TAG.yaml up -d
docker compose -f previews/$TAG.yaml exec -T web python manage.py migrate --noinput
docker compose -f previews/$TAG.yaml exec -T web python manage.py loaddata seed/preview.json
"
- name: Comment URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number,
body: `Preview: https://${process.env.HOST}` })
teardown:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Remove preview
env:
SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
TAG: pr-${{ github.event.pull_request.number }}
run: |
mkdir -p ~/.ssh && echo "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
ssh-keyscan preview.example.com >> ~/.ssh/known_hosts
ssh preview@preview.example.com "docker compose -p $TAG -f previews/$TAG.yaml down -v --remove-orphans; rm -f previews/$TAG.yaml"
Important details:
- The
ifcondition skips pull requests from forks. Fork PRs do not receive repository secrets onpull_request, and building untrusted code with secrets (for example viapull_request_target) would expose them. loaddatais idempotent only for fixtures with primary keys; for repeat deploys, use a seed command that checks whether data exists.- Posting a comment on every push creates noise; update a single comment in practice.
ssh-keyscantrusts whatever key the host presents on first use. Storing the host key in a secret and writing it toknown_hostsis stricter.
5. Seed data, not production data
Previews are accessed by more people and protected less than production. Load fixtures or run a management command that creates realistic fake data (for example with Faker). If you must use production-like data, use a sanitised dump with personal data removed, produced by a separate, reviewed job.
6. Scheduled cleanup
Missed webhooks and failed teardowns leave previews running. Add a nightly job that lists Compose projects matching pr-* and removes those whose pull requests are closed, plus docker image prune to reclaim space.
Verification check:
- Opening a PR produces a comment with a working URL behind basic auth.
- Pushing a new commit redeploys the same URL with the new image.
- Closing the PR removes the containers and the database volume (
docker ps -a --filter name=pr-42is empty). curl -sI https://pr-42.preview.example.com/showsX-Robots-Tag: noindex, nofollow.
Explanation
The design isolates previews by using Compose projects: the project name namespaces containers, networks, and volumes, so pr-42 and pr-43 can run the same file without colliding. Traefik routes each wildcard subdomain to the right project by label, and the workflow ties the preview's lifetime to the pull request's. The Django side only needs a preview settings module that accepts the dynamic host and disables side effects.
Edge cases / notes
Migrations that conflict
Two PRs with migrations numbered 0042 each work in their own preview but conflict after merging. Previews do not catch that; a CI check for multiple migration leaf nodes against main does.
Capacity
Limit the number of simultaneous previews, or stop previews after a period of inactivity, so the host does not run out of memory. Compose deploy.resources.limits can cap each preview's memory.
Managed platforms
Render, Heroku, Railway, and others offer built-in review apps. They trade control for convenience and follow the same principles: per-PR database, restricted secrets, and automatic cleanup.
When to automate this
This setup is itself automation. The next improvements are a seed command that builds realistic data quickly, a smoke test step after deploy, and cost controls (idle shutdown, maximum lifetime).
Next steps
- Keep a stable pre-production target with Set Up a Staging Environment for Django.
- Reuse the pipeline structure from Deploy Django with GitHub Actions CI/CD.
- Configure routing and certificates with Deploy Django with Traefik and Docker.
- If containers start without their configuration, see Fix Environment Variables Not Loaded in Docker Compose.
FAQ
Should preview environments use a copy of the production database?
Generally no. Use seed or synthetic data, or a sanitised dump without personal data. Previews are less protected than production.
How do I give each Django preview its own database?
Run a PostgreSQL container in each Compose project, or create a separate database per PR on a shared preview server and drop it on teardown.
Are preview environments safe for pull requests from forks?
Not with secrets. Skip forks or build them without secrets; never run untrusted code in a workflow context that has access to repository secrets.
How do I stop search engines indexing previews?
Protect previews with authentication and send an X-Robots-Tag: noindex, nofollow header.