Deployment
CI/CD pipelines

Deploy Django with Bitbucket Pipelines

Build a Bitbucket Pipelines CI/CD workflow for Django: tests against a PostgreSQL service, cached dependencies, deployment environments, SSH deploys, and a manual production gate.

Problem statement

Teams hosting code on Bitbucket often deploy Django by SSHing into the server and running commands by hand. That works until someone forgets migrate, deploys untested code, or runs two deploys at once. Bitbucket Pipelines can run tests on every push and deploy the same commit to staging and production with an audit trail, but a pipeline that simply runs git pull on the server keeps most of the old risks.

This guide builds a pipeline with a real PostgreSQL test database, dependency caching, deployment environments with their own variables, an SSH deploy that runs a versioned script on the server, and a manual approval step for production.

Bitbucket Pipelines for DjangoPushany branchTest stepPostgres serviceStagingautomatic deployProductionmanual triggerThe same commit that passed tests is deployed; the server script does the release.
  1. Add bitbucket-pipelines.yml with a test step using the python image, the pip cache, and a PostgreSQL service.
  2. Run manage.py check --deploy, makemigrations --check, and the test suite.
  3. Configure SSH keys and known hosts in the repository's Pipelines settings.
  4. Add staging and production deployment environments with their own secured variables.
  5. On main, deploy to staging automatically and to production with trigger: manual.
  6. Have the deploy step call a script on the server with the commit hash, so the server checks out exactly that commit.

Step-by-step solution

1. Test step with PostgreSQL

# bitbucket-pipelines.yml
image: python:3.12

definitions:
  services:
    postgres:
      image: postgres:16
      variables:
        POSTGRES_DB: test_db
        POSTGRES_USER: test_user
        POSTGRES_PASSWORD: test_password
  steps:
    - step: &test
        name: Test
        caches:
          - pip
        services:
          - postgres
        script:
          - pip install -r requirements.txt
          - export DJANGO_SETTINGS_MODULE=config.settings.test
          - export DATABASE_URL=postgres://test_user:test_password@localhost:5432/test_db
          - python manage.py makemigrations --check --dry-run
          - python manage.py check
          - python manage.py test --parallel

pipelines:
  default:
    - step: *test

Services run alongside the build container and are reachable on localhost. makemigrations --check fails the build when someone changed models without committing a migration, which is a common cause of failed production deploys.

Run the deploy checks with production-like settings as well:

          - DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_SECRET_KEY=ci-only-not-secret DJANGO_ALLOWED_HOSTS=example.com python manage.py check --deploy --fail-level WARNING

2. Prepare the server-side deploy script

Keep release logic on the server in a script committed to the repository, so the pipeline only has to call it:

#!/usr/bin/env bash
# scripts/deploy.sh — run on the server as the deploy user
set -euo pipefail
COMMIT="$1"
APP=/srv/myapp
RELEASE="$APP/releases/$(date +%Y%m%d%H%M%S)-${COMMIT:0:7}"

git -C "$APP/repo" fetch --quiet origin
git -C "$APP/repo" worktree add --detach "$RELEASE" "$COMMIT"
python3 -m venv "$RELEASE/.venv"
"$RELEASE/.venv/bin/pip" install --quiet -r "$RELEASE/requirements.txt"

set -a; . /etc/myapp.env; set +a
cd "$RELEASE"
.venv/bin/python manage.py check --deploy
.venv/bin/python manage.py migrate --noinput
.venv/bin/python manage.py collectstatic --noinput

ln -sfn "$RELEASE" "$APP/current"
sudo systemctl restart gunicorn
curl -fsS -H "Host: example.com" http://127.0.0.1:8000/healthz/ > /dev/null
echo "deployed $COMMIT"
deploy.sh, step by step1git worktree for the commitreleases/<time>-<sha>2Fresh venv and requirementsper release3check, migrate, collectstaticwith /etc/myapp.env4Switch current, restartln -sfn; systemctl restart5Health check or failcurl -fsS /healthz/set -euo pipefail stops the script at the first failuredjango-deployment.com
The pipeline only calls this script, so a deploy can also be run by hand in an emergency.

The script checks out the exact commit, builds a fresh environment, migrates, switches a symlink, and restarts Gunicorn. A restart (not a HUP reload) is needed because each release has its own virtual environment and the Gunicorn master resolved the old symlink target when it started. Grant the deploy user permission to run only the needed systemctl command via a sudoers rule.

3. Configure SSH access

In Repository settings → Pipelines → SSH keys, generate a key pair and add the public key to the deploy user's ~/.ssh/authorized_keys on each server. Add each server's host under Known hosts and fetch its fingerprint there. Pipelines then injects the private key into build containers automatically.

the pipeline key can only deployUnsafe✕your personal key as a variable✕StrictHostKeyChecking=no✕pipeline logs in as rootSafe✓repository SSH key from settings✓known_hosts pinned in settings✓deploy user, limited sudoPin the host key, or a spoofed server receives your deploydjango-deployment.com
Limit sudo to the one systemctl restart the script needs.

Restrict the key on the server so it can only run the deploy script:

command="/srv/myapp/repo/scripts/deploy.sh \"$SSH_ORIGINAL_COMMAND\"",no-port-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... bitbucket-pipelines

With a forced command, whatever the pipeline sends as the SSH command arrives in SSH_ORIGINAL_COMMAND. Here it is only the commit hash; the script should validate that it looks like a hash before using it.

4. Define deployment environments

In Repository settings → Deployments, configure staging and production and add variables for each, such as DEPLOY_HOST and DEPLOY_USER. Mark sensitive values as secured so they are masked in logs. Environment permissions can restrict who may run production deployments.

three environments, three triggersTriggerGuardTestevery pushtests must passStaging✓merge to main, automaticstaging variables onlyProduction!manual stepadmins only, branch restrictedEnvironment variables keep production secrets out of other stepsdjango-deployment.com
The Deployments dashboard then shows what commit each environment runs.

5. Add deploy steps

pipelines:
  default:
    - step: *test
  branches:
    main:
      - step: *test
      - step:
          name: Deploy to staging
          deployment: staging
          script:
            - ssh "$DEPLOY_USER@$DEPLOY_HOST" "$BITBUCKET_COMMIT"
      - step:
          name: Deploy to production
          deployment: production
          trigger: manual
          script:
            - ssh "$DEPLOY_USER@$DEPLOY_HOST" "$BITBUCKET_COMMIT"

$BITBUCKET_COMMIT is the commit that triggered the pipeline, so staging and production receive exactly what was tested. trigger: manual pauses the pipeline until someone with permission clicks Deploy. Because each step uses a different deployment, the same variable names resolve to different values.

6. Verify each deploy

Add a smoke test at the end of the deploy step:

            - curl -fsS https://example.com/healthz/
            - curl -fsSI https://example.com/ | head -1

Verification check:

  • The Deployments dashboard shows the commit deployed to each environment.
  • The server's current symlink points at a release directory containing the same commit hash.
  • journalctl -u gunicorn shows the restart without errors.

Explanation

The pipeline separates concerns: Bitbucket decides what to deploy and when (tested commit, environment, approval), and the server script decides how (checkout, install, migrate, switch, restart). Because the script is versioned in the repository and receives only a commit hash, the deploy is repeatable from a laptop in an emergency and auditable from the Deployments view. Release directories with a symlink switch make rollback a symlink change rather than a rebuild.

Edge cases / notes

Rollback

Point current at the previous release directory and restart Gunicorn, or rerun a previous pipeline's production step from the Deployments view to redeploy that commit. Migrations are not reverted, so keep them backward compatible.

Docker-based deploys

For container deployments, enable the docker service in a build step, build and push the image tagged with $BITBUCKET_COMMIT, and have the deploy step pull that tag on the server.

Pruning releases

Remove old release directories and worktrees in the deploy script (keep the last five) to avoid filling the disk.

When to automate this

This pipeline is already the automation for routine releases. The next candidates are database backups before production migrations, notifications to your team chat, and a post-deploy check that alerts if the health endpoint fails over the next few minutes.

Next steps

FAQ

How do I use PostgreSQL in Bitbucket Pipelines tests?

Define a postgres service under definitions.services with its image and variables, add it to the step's services, and connect to localhost:5432.

How do I stop Bitbucket from deploying to production automatically?

Add trigger: manual to the production step. The pipeline waits until an authorised user starts that step.

Where should deployment secrets live in Bitbucket?

In deployment environment variables marked as secured, or on the server itself in an environment file that the pipeline never reads.

Should the pipeline run git pull on the server?

Deploy a specific commit instead. git pull deploys whatever is on the branch at that moment, which may not be what was tested.

Keep going