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.

Fabric deploy flowfab deployfrom laptop or CINew release dircheckout commitBuild and migratevenv, staticSwitch symlinkrestart, checkEach deploy is a new directory; rollback points the symlink at the previous one.
  1. pip install fabric locally or in CI (not required on the server).
  2. Create fabfile.py with @task functions that take a Connection argument.
  3. The deploy task creates releases/<timestamp>, checks out a specific Git ref, builds a virtual environment, runs check --deploy, migrate, and collectstatic.
  4. Switch current to the new release with an atomic ln -sfn, restart Gunicorn, and verify a health URL.
  5. Keep the last few releases; a rollback task points current back to the previous one.
  6. 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
what the fabfile expects on the serverrepo/bare checkout, only fetchedreleases/<timestamp>/git archive of one commitcurrent → releases/…swapped atomically with mv -Tshared/.env and shared/medialinked into each releaseNothing is edited in place, so a half-finished deploy never serves trafficdjango-deployment.com
The REVISION file in each release records exactly which commit is running.
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 archive exports exactly one commit into a clean directory, without a .git folder in the release.
  • rev-parse {ref}^{commit} resolves branches and tags to a fixed commit, recorded in REVISION.
  • ln -sfn to a temporary name followed by mv -T is atomic: current always points at a complete release.
  • A restart (not a HUP reload) 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.
the lines that make it safec.run(f"git -C {APP}/repoarchive {sha}| tar -x -C {release}")_django(c, release, "check --deploy--fail-level WARNING")_django(c, release, "migrate --noinput")c.run(f"ln -sfn {release} {APP}/current.tmp &&mv -T…")c.run(f"sleep 3 &&{HEALTH}", hide=True)12341An exact commit, not whatever the branch points at later.2A warning stops the deploy before anything changes.3rename() is atomic: no moment without a current link.4A failed check raises, so CI marks the deploy failed.Every c.run raises on a non-zero exit, stopping the taskdjango-deployment.com
The migration runs before the switch, so it must be compatible with the running code.

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.

Fabric runs commands; it does not converge stateFabric✓plain Python calling SSH✓ideal for the release steps✕no idempotent server setupAnsible and friends✓desired state, safe to rerun✓installs packages, users, unitsheavier for a five-step deployMany teams use both: config management builds the server, Fabric ships codedjango-deployment.com
If the fabfile starts installing packages, it is time for a configuration tool.

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

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.

Keep going