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.
- Add
bitbucket-pipelines.ymlwith a test step using thepythonimage, thepipcache, and a PostgreSQL service. - Run
manage.py check --deploy,makemigrations --check, and the test suite. - Configure SSH keys and known hosts in the repository's Pipelines settings.
- Add
stagingandproductiondeployment environments with their own secured variables. - On
main, deploy to staging automatically and to production withtrigger: manual. - 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"
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.
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.
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
currentsymlink points at a release directory containing the same commit hash. journalctl -u gunicornshows 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
- Compare with other CI systems in Deploy Django with GitHub Actions CI/CD and Deploy Django with GitLab CI/CD.
- Extend the server script with How to Write a Django Deployment Bash Script.
- If a deploy fails mid-migration, use Django Migration Failed During Deploy: Recovery Playbook.
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.