Deploy Django with Fabric
Automate Django deployments over SSH with Fabric: a fabfile with release directories, dependency install, migrations, collectstatic, atomic symlink switch, health checks, and rollback.
Problem statement
A Django deploy on a single server usually starts as a sequence of commands typed over SSH: pull, install, migrate, collect static files, restart. The sequence is easy to get wrong under pressure, and it differs slightly each time. A Bash script helps, but many Django teams prefer Python for anything with logic in it.
Fabric is a Python library for running commands over SSH. A fabfile.py in the repository turns the deploy into a reviewed, repeatable Python function you run from your machine or CI. This guide builds one with release directories, an atomic switch, a health check, and a rollback task.
pip install fabriclocally or in CI (not required on the server).- Create
fabfile.pywith@taskfunctions that take aConnectionargument. - The
deploytask createsreleases/<timestamp>, checks out a specific Git ref, builds a virtual environment, runscheck --deploy,migrate, andcollectstatic. - Switch
currentto the new release with an atomicln -sfn, restart Gunicorn, and verify a health URL. - Keep the last few releases; a
rollbacktask pointscurrentback to the previous one. - Run
fab -H deploy@example.com deploy --ref v1.4.0.
Step-by-step solution
1. Server layout
Prepare the server once:
/srv/myapp/
repo/ # bare-ish clone used as the source for checkouts
releases/ # one directory per deploy
current -> releases/20260925T101500
shared/ # media, .env, anything that survives releases
sudo mkdir -p /srv/myapp/{releases,shared/media}
sudo chown -R deploy:www-data /srv/myapp
git clone --mirror git@github.com:example/myapp.git /srv/myapp/repo
The systemd unit for Gunicorn should use the current path:
WorkingDirectory=/srv/myapp/current
ExecStart=/srv/myapp/current/.venv/bin/gunicorn config.wsgi:application --bind unix:/run/gunicorn/gunicorn.sock --workers 3
2. Install Fabric locally
pip install "fabric>=3"
Fabric uses Paramiko for SSH and reads your ~/.ssh/config, so host aliases, keys, and jump hosts configured there work unchanged.
3. Write the fabfile
# fabfile.py
from datetime import datetime, timezone
from fabric import task
APP = "/srv/myapp"
KEEP = 5
HEALTH = "curl -fsS -H 'Host: example.com' --unix-socket /run/gunicorn/gunicorn.sock http://localhost/healthz/"
def _django(c, release, command):
c.run(
f"cd {release} && set -a && . {APP}/shared/.env && set +a && "
f".venv/bin/python manage.py {command}"
)
@task
def deploy(c, ref="main"):
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
release = f"{APP}/releases/{stamp}"
c.run(f"git -C {APP}/repo fetch --quiet --prune")
sha = c.run(f"git -C {APP}/repo rev-parse {ref}^{{commit}}", hide=True).stdout.strip()
print(f"Deploying {ref} ({sha[:7]}) to {release}")
c.run(f"mkdir -p {release}")
c.run(f"git -C {APP}/repo archive {sha} | tar -x -C {release}")
c.run(f"echo {sha} > {release}/REVISION")
c.run(f"ln -s {APP}/shared/media {release}/media")
c.run(f"python3 -m venv {release}/.venv")
c.run(f"{release}/.venv/bin/pip install --quiet -r {release}/requirements.txt")
_django(c, release, "check --deploy --fail-level WARNING")
_django(c, release, "migrate --noinput")
_django(c, release, "collectstatic --noinput")
c.run(f"ln -sfn {release} {APP}/current.tmp && mv -T {APP}/current.tmp {APP}/current")
c.sudo("systemctl restart gunicorn", pty=True)
c.run(f"sleep 3 && {HEALTH}", hide=True)
print("Health check passed")
cleanup(c)
@task
def cleanup(c):
c.run(f"cd {APP}/releases && ls -1t | tail -n +{KEEP + 1} | xargs -r rm -rf")
@task
def rollback(c):
releases = c.run(f"ls -1t {APP}/releases", hide=True).stdout.split()
current = c.run(f"readlink -f {APP}/current", hide=True).stdout.strip()
older = [r for r in releases if f"{APP}/releases/{r}" != current]
if not older:
raise SystemExit("No previous release to roll back to")
target = f"{APP}/releases/{older[0]}"
c.run(f"ln -sfn {target} {APP}/current.tmp && mv -T {APP}/current.tmp {APP}/current")
c.sudo("systemctl restart gunicorn", pty=True)
c.run(HEALTH, hide=True)
print(f"Rolled back to {target}")
Key choices:
git archiveexports exactly one commit into a clean directory, without a.gitfolder in the release.rev-parse {ref}^{commit}resolves branches and tags to a fixed commit, recorded inREVISION.ln -sfnto a temporary name followed bymv -Tis atomic:currentalways points at a complete release.- A restart (not a
HUPreload) makes Gunicorn start from the new release's virtual environment. - Any failing command raises an exception and stops the task before the symlink switch, leaving the running release untouched.
Allow the deploy user to restart only Gunicorn without a password:
# /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart gunicorn
4. Run it
fab -H deploy@example.com deploy --ref v1.4.0
fab -H deploy@example.com rollback
fab -H deploy@web1.example.com,deploy@web2.example.com deploy --ref v1.4.0
With several hosts, Fabric runs the task on each in turn. For multiple servers sharing one database, run migrations on only one of them (for example with a migrate task run against a single host before the others).
5. Run it from CI
# GitHub Actions excerpt
- run: pip install "fabric>=3"
- run: |
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
echo "$KNOWN_HOSTS" >> ~/.ssh/known_hosts
fab -H deploy@example.com deploy --ref "$GITHUB_SHA"
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }}
Verification check:
ssh deploy@example.com 'readlink -f /srv/myapp/current; cat /srv/myapp/current/REVISION'
curl -sI https://example.com/ | head -1
Explanation
Fabric connects over SSH and runs each command in sequence, raising an exception on a non-zero exit status. That gives Python control flow around a shell-based deploy: the release is built completely in a new directory while the old release keeps serving, and only when every step succeeds does the symlink switch. Because releases are immutable directories, rollback does not rebuild anything; it just points current back and restarts.
Edge cases / notes
Migrations and rollback
rollback switches code only. If the failed release applied migrations, the previous code runs against the newer schema, which is safe only for backward-compatible migrations. Reverse a migration explicitly with migrate app_name <previous_migration> when needed.
Shared files
Anything that must survive releases (media, the environment file, local sockets) lives in shared/ and is symlinked or referenced by absolute path. Never write uploads into a release directory.
Fabric versions
Fabric 2 and later have a different API from the old Fabric 1 (from fabric.api import run examples you may find online). This guide uses the current API with @task functions receiving a connection.
When to automate this
The fabfile is the automation. Once it is stable, trigger it from CI after tests pass, add a database backup task before production migrations, and send a notification with the deployed revision.
Next steps
- Compare with a shell-based approach in How to Write a Django Deployment Bash Script.
- For configuration management as well as deploys, see Deploy Django with Ansible: Step-by-Step.
- Plan rollbacks in depth with How to Roll Back a Django Release Safely.
- If a deploy seems to have no effect, check Fix Django Code Changes Not Showing After Deploy.
FAQ
Is Fabric still maintained for Django deployments?
Yes. Current Fabric releases (2.x and 3.x) are maintained and built on Invoke and Paramiko. Old Fabric 1 examples use a different API.
Does Fabric need to be installed on the server?
No. Fabric runs on your machine or CI runner and connects over SSH. The server only needs SSH and the tools the commands use.
Fabric or Ansible for deploying Django?
Fabric is simpler for release scripts on a few servers. Ansible is better when you also manage server configuration declaratively across many hosts.
Why use release directories instead of git pull?
A new directory is built completely before traffic switches, so a failed install or migration never leaves the live code half-updated, and rollback is instant.