Setting Up a Production CI/CD Pipeline for a Python/Django App
A practical walkthrough of building a complete GitHub Actions pipeline for a Django project, from a multi-stage Dockerfile through vulnerability scanning to zero-downtime deploys via SSH. This is the exact four-job pattern I now use across every Python service I ship, adapted from the same standard

A practical walkthrough of building a complete GitHub Actions pipeline for a Django project, from a multi-stage Dockerfile through vulnerability scanning to zero-downtime deploys via SSH. This is the exact four-job pattern I now use across every Python service I ship, adapted from the same standard I run for Node/React projects. The end state: push to develop, and within a few minutes you have a scanned, versioned image running on your server, with your database and cache layers never touched by the automation. Four jobs, each gating the next: Dependency check: fail fast on known-vulnerable packages before you spend CI minutes building Build & push: compile the image once, tag it with the exact commit SHA, push to GHCR Image scan: Trivy scans the built image for OS and package CVEs Deploy: SSH into the server, pull the exact scanned tag, restart only the application containers Each job only runs if the previous one succeeds. Nothing reaches production without passing every gate. Python images benefit enormously from a multi-stage build, because compiling native extensions (psycopg2, Pillow, lxml, and friends) needs a full build toolchain that has no business existing in your production image. # ========================= # 1. Base Stage # Shared env/config for both builder and runtime, kept DRY # so these settings can't drift out of sync between stages. # ========================= FROM python:3.9-slim-bookworm AS base WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 # ========================= # 2. Builder Stage # Compiles wheels for all deps requiring native extensions # so the runtime image doesn't need a full build toolchain. # ========================= FROM base AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential gcc g++ make pkg-config \ libpq-dev libffi-dev libmagic-dev libssl-dev zlib1g-dev \ libjpeg-dev libxml2-dev libxslt1-dev \ && rm -rf /var/lib/apt/lists/* COPY requirements ./requirements RUN python -m pip install --upgrade pip setuptools wheel RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements/prod.txt # ========================= # 3. Runtime Stage # Only the shared libs the compiled wheels actually need. # no compilers, no -dev headers. # ========================= FROM base AS runtime RUN apt-get update && apt-get install -y --no-install-recommends \ libpq5 libmagic1 libjpeg62-turbo libxml2 libxslt1.1 netcat-openbsd \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /wheels /wheels COPY requirements/prod.txt ./requirements/prod.txt RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements/prod.txt \ && rm -rf /wheels requirements COPY docker/prod/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh COPY . . RUN addgroup --system --gid 1001 django \ && adduser --system --uid 1001 --gid 1001 django \ && chown -R django:django /app USER django EXPOSE 8000 ENTRYPOINT ["/entrypoint.sh"] CMD ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] A few decisions worth explaining: Why a shared base stage? Both builder and runtime need the same WORKDIR and ENV settings. Declaring them twice means they can silently drift apart. One base stage, two things extending it. Same idea as a shared config file. Why --no-index --find-links=/wheels instead of just pip install -r requirements? The runtime stage never touches the internet or a package index. It installs only the exact wheels the builder already compiled. This makes builds reproducible and avoids the runtime stage accidentally pulling in a different resolved version than what was actually tested. Why a non-root user? Running as django (uid 1001) rather than root limits blast radius if the container is ever compromised. chown -R on the app directory before switching users is required, or the process won't have permission to write anything (log files, temp files, etc.). Never bake runserver into a production image. Django's development server isn't built for concurrent connections or production traffic. Gunicorn, already installed via requirements/prod.txt in most Django boilerplates, is the standard WSGI server for this. The entrypoint's only jobs: wait for the database to be reachable, apply already-committed migrations, then hand off to whatever command the container was actually started with. #!/bin/sh set -e if [ "$DATABASE" = "postgres" ]; then echo "Waiting for PostgreSQL at $SQL_HOST:$SQL_PORT..." while ! nc -z "$SQL_HOST" "$SQL_PORT"; do sleep 0.1 done echo "PostgreSQL is up." fi python manage.py migrate --no-input exec "$@" Two things to get right here, because both are easy to get subtly wrong: set -e so the script stops on the first failure instead of limping forward. exec "$@" at the end, not just "$@". Without exec, your app runs as a child process of the shell script, which means it never receives signals like SIGTERM directly, and Docker's graceful shutdown won't work correctly. Notably absent: makemigrations. That command should only ever run in development. Auto-generating schema changes at deploy time means production can apply migrations nobody reviewed. Migrations get written and committed in dev; production only ever runs migrate. name: Deploy My Django App on: workflow_run: workflows: ["CodeQuality Checks"] types: - completed branches: - develop env: REGISTRY: ghcr.io concurrency: group: deploy-my-django-app cancel-in-progress: false jobs: dependency-check: name: Dependency Vulnerability Check if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read steps: - uses: actions/checkout@v7 with: ref: ${{ github.event.workflow_run.head_sha }} - uses: actions/setup-python@v5 with: python-version: '3.9' - run: pip install pip-audit - run: pip-audit -r requirements/prod.txt continue-on-error: true Why pip-audit -r requirements/prod.txt instead of installing everything first? pip-audit can scan a requirements file directly against known vulnerability databases without needing a full working install. Faster, and it doesn't require your build toolchain just to run a security check. Why workflow_run instead of triggering directly on push? This chains the deploy pipeline behind a separate code-quality workflow (linting, tests); deploy only fires if that already succeeded. if: ${{ github.event.workflow_run.conclusion == 'success' }} is the gate that enforces it. build-and-push: name: Build & Push Image needs: dependency-check runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read packages: write outputs: image: ${{ steps.image-name.outputs.image }} tag: sha-${{ github.event.workflow_run.head_sha }} steps: - uses: actions/checkout@v7 with: ref: ${{ github.event.workflow_run.head_sha }} - name: Set lowercase image name id: image-name run: | echo "image=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract Docker image metadata id: meta uses: docker/metadata-action@v6 with: images: ${{ steps.image-name.outputs.image }} tags: | type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} - uses: docker/build-push-action@v7 with: context: . file: ./docker/prod/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max The single most important line in this whole pipeline: tag: sha-${{ github.event.workflow_run.head_sha }} This gets exposed as a job output and threaded through every job after it. The alternative, assuming a :latest tag exists and using it everywhere, silently breaks the moment your trigger branch isn't your repo's actual GitHub-configured default branch, because docker/metadata-action's enable={{is_default_branch}} only tags latest on the real default branch. Deploying by SHA means you always know exactly what commit is running in production, and rollbacks become "redeploy this specific tag" instead of guesswork. GitHub Actions cache (cache-from/cache-to: type=gha) persists Docker layer cache between runs. Your dependency-install layer won't rebuild from scratch every single push unless requirements/prod.txt actually changed. image-scan: name: Container Security Scan needs: build-and-push runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read packages: read steps: - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ needs.build-and-push.outputs.image }}:${{ needs.build-and-push.outputs.tag }} severity: CRITICAL,HIGH exit-code: '1' ignore-unfixed: true exit-code: '1' is what makes this a real gate rather than a report nobody reads. The job fails, the pipeline stops, nothing gets deployed. ignore-unfixed: true filters out CVEs with no available patch yet, since failing a build over something you can't currently fix just trains everyone to ignore the scanner. If this catches something, don't assume it's your application code. Base OS images (python:3.9-slim-bookworm here) accumulate CVEs in their bundled packages over time too. A quick apk update && apk upgrade --no-cache (Alpine) or apt-get update && apt-get upgrade -y (Debian-based) at the top of your runtime stage often clears these without touching a single line of application code. deploy: name: Deploy to Server runs-on: ubuntu-latest needs: [build-and-push, image-scan] timeout-minutes: 10 permissions: contents: read steps: - uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SERVER_IP }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} port: 22 script: | echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin docker pull ${{ needs.build-and-push.outputs.image }}:${{ needs.build-and-push.outputs.tag }} cd /home/apps/my-django-app IMAGE_TAG=${{ needs.build-and-push.outputs.tag }} docker compose -f docker-compose.prod.yml up -d --no-deps api celery celery-beat docker image prune -f The line that matters most for anything with a database: docker compose ... up -d --no-deps api celery celery-beat --no-deps, plus explicitly naming only the application services, means your database and cache containers are never included in the recreate. Compare that to a blanket docker compose down && docker compose up -d, which tears down and recreates every service in the file, including your database, on every single deploy. One flag is the difference between "safe to deploy fifty times a day" and "one bad merge away from an outage." version: "3.7" services: api: &api image: ghcr.io/your-org/your-app:${IMAGE_TAG:-latest} container_name: my-app-api restart: unless-stopped command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3 env_file: - ./.env ports: - "8005:8000" depends_on: - redis - db celery: <<: *api container_name: my-app-celery command: celery worker --app=core --loglevel=info ports: [] depends_on: - redis - api db: image: postgres:12.1-alpine container_name: my-app-db restart: unless-stopped volumes: - postgres_data:/var/lib/postgresql/data env_file: - ./.env redis: image: redis:alpine container_name: my-app-redis restart: unless-stopped volumes: postgres_data: The &api / <<: *api YAML anchor pattern means celery inherits everything from api (image, env file, restart policy) and only overrides what's different: the command and port mapping. One image built once, run with different startup commands for the web process versus the background worker. ${IMAGE_TAG:-latest} reads the IMAGE_TAG environment variable the deploy step sets, falling back to latest if it's ever run manually without that variable set, useful for local debugging on the server without breaking the syntax. Before pointing real traffic at this, worth confirming: # Does the volume actually persist across recreations of the app containers? docker volume ls | grep postgres_data # Does the entrypoint's DB wait-loop actually see the right env vars? docker exec -it my-app-api env | grep -E "DATABASE|SQL_HOST|SQL_PORT" # Does a manual run of the compose file work before letting CI do it automatically? docker compose -f docker-compose.prod.yml up -d --no-deps api celery celery-beat docker compose -f docker-compose.prod.yml logs -f api Required secrets in your repo settings: SERVER_IP, SERVER_USER, SERVER_SSH_KEY, GHCR_PAT (a personal access token with read:packages, used for the server to authenticate against GHCR independently of whatever's cached in CI). Every piece of this pipeline exists because of a failure mode it closes off: Dependency check before build: don't spend CI minutes building an image from packages you already know are vulnerable. SHA-based tags instead of latest: always know exactly what's running; rollbacks become trivial. Image scan after build, before deploy: you're scanning what will actually run, not just source code. --no-deps on deploy: stateful services are structurally protected from an automation mistake, not just protected by convention. Non-root runtime user: limits what an exploited container can actually do. Multi-stage Dockerfile: smaller final image, no build toolchain shipped to production, faster pulls. None of these individually are complicated. Together, they're the difference between a deploy you have to babysit and one you can trust to run unattended, multiple times a day, without holding your breath.
Key Takeaways
- •A practical walkthrough of building a complete GitHub Actions pipeline for a Django project, from a multi-stage Dockerfile through vulnerability scanning to zero-downtime deploys via SSH
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


