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.

Pull request preview lifecyclePR openedworkflow runsBuild imagetag pr-42Preview hostcompose project pr-42PR closedteardown jobEvery preview is an isolated Compose project with its own database, removed when the PR closes.
  1. Run a dedicated preview host with Traefik and a wildcard DNS record *.preview.example.com.
  2. In GitHub Actions, on pull_request events, build the image tagged pr-<number> and push it to a registry.
  3. SSH to the preview host and start a Compose project named pr-<number> with its own PostgreSQL container and Traefik labels for pr-<number>.preview.example.com.
  4. Migrate and load seed data (never a copy of production personal data).
  5. Use preview-only secrets, a console or sandbox email backend, basic auth, and noindex.
  6. On pull_request closed, run docker compose down -v for 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
one preview per pull requestpr-123.preview.example.comproxy route and wildcard certweb containerthe PR image, DEBUG offdb containerown database, seed dataCompose project pr-123isolated names and volumesA Compose project name keeps each preview fully separatedjango-deployment.com
Previews never connect to production services or data.

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"
from pull request to preview URLPR opened or pushedworkflow startsImage builttagged pr-123compose up -p pr-123on the preview hostmigrate and seedfresh databaseURL commented on PRreviewers clickEach push updates the same previewdjango-deployment.com
Limit previews to trusted branches; a fork PR must not receive deploy secrets.

Important details:

  • The if condition skips pull requests from forks. Fork PRs do not receive repository secrets on pull_request, and building untrusted code with secrets (for example via pull_request_target) would expose them.
  • loaddata is 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-keyscan trusts whatever key the host presents on first use. Storing the host key in a secret and writing it to known_hosts is 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.

previews must not pile upOn PR close: compose down -vcontainers and volumesRemove the PR imagefrom the host and registryNightly sweeppreviews older than 7 daysDisk usage alerton the preview hostThe nightly sweep catches previews whose close event was misseddjango-deployment.com
docker system df shows how much space previews are really using.

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-42 is empty).
  • curl -sI https://pr-42.preview.example.com/ shows X-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

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.

Keep going