Atomic Django Releases With Symlinked Release Directories

Deploy Django with timestamped release directories and an atomic current symlink: build each release in isolation, share media and env files, switch with ln -sfn, reload Gunicorn gracefully, and roll back instantly to the previous release.

Problem statement

Deploying by running git pull and pip install inside the live project directory changes files while Gunicorn is serving them. A failed install leaves a half-updated tree, a bad release has no clean way back, and requests during the deploy may import a mix of old and new code. The release directory pattern, popularised by Capistrano, fixes this: every release is built in its own directory, and a single current symlink is switched atomically to the new one when it is ready.

Symlinked releasesBuild releasereleases/2026-10-02Link sharedmedia, env, venvSwitch symlinkcurrent -> newReload Gunicornold dir keptThe live directory is never edited; switching and rolling back are one symlink change each.
  1. Lay out /srv/app/releases/<timestamp>/, /srv/app/shared/ (env file, media), and /srv/app/current → latest release.
  2. Build each release fully: checkout, virtualenv, pip install, collectstatic, check --deploy.
  3. Run backward-compatible migrations from the new release.
  4. Switch atomically: ln -sfn releases/<ts> current.tmp && mv -Tf current.tmp current.
  5. Reload Gunicorn gracefully (systemctl reload), whose unit points at /srv/app/current.
  6. Keep the last 5 releases; roll back by pointing current at the previous one and reloading.

Step-by-step solution

1. Directory layout

/srv/app/
├── current -> releases/20261002T101500
├── releases/
│   ├── 20261001T163000/
│   └── 20261002T101500/
│       ├── .venv/
│       ├── manage.py
│       ├── staticfiles/
│       └── media -> /srv/app/shared/media
└── shared/
    ├── env
    └── media/

Things that must survive releases (uploads, the environment file, logs) live in shared/ and are symlinked into each release.

2. Build the release in isolation

#!/usr/bin/env bash
set -euo pipefail
APP=/srv/app
TS=$(date -u +%Y%m%dT%H%M%S)
REL=$APP/releases/$TS
REV=${1:-main}

git clone --depth 1 --branch "$REV" git@github.com:you/myapp.git "$REL"
python3 -m venv "$REL/.venv"
"$REL/.venv/bin/pip" install --no-cache-dir -r "$REL/requirements.txt"
ln -sfn $APP/shared/media "$REL/media"
set -a; . $APP/shared/env; set +a
cd "$REL"
.venv/bin/python manage.py check --deploy
.venv/bin/python manage.py collectstatic --noinput

If any step fails, set -e stops the script and the live site is untouched; delete the broken directory and try again. A per-release virtualenv avoids changing packages under a running app. To save time, reuse a cached wheelhouse (pip download into shared/wheels) or copy the previous venv when requirements have not changed.

what happens during a deploygit pull in place✕files change under running workers✕failed install leaves a broken tree✕rollback means re-deploying old codereleases/ + current symlink✓new release built beside the old✓switch is one atomic rename✓rollback is the same rename backThe running code never changes until the switchdjango-deployment.com
Building beside the live release also lets you run checks before anything changes.

3. Migrate, switch, reload

.venv/bin/python manage.py migrate --noinput     # from the new release, backward-compatible changes only

ln -sfn "$REL" $APP/current.tmp
mv -Tf $APP/current.tmp $APP/current              # atomic rename on the same filesystem
sudo systemctl reload gunicorn

ln -sfn alone is not atomic (it unlinks then creates); creating a temporary link and renaming it over current is. The systemd unit references the symlink:

WorkingDirectory=/srv/app/current
ExecStart=/srv/app/current/.venv/bin/gunicorn config.wsgi:application --bind unix:/run/gunicorn/gunicorn.sock
ExecReload=/bin/kill -s HUP $MAINPID

On HUP, Gunicorn starts new workers, which resolve current to the new release. Use preload_app = False (the default) so new workers import fresh code; with preload, do a full restart or USR2-based binary upgrade instead. Point Nginx's static alias at /srv/app/current/staticfiles/.

4. Roll back

PREV=$(ls -1d /srv/app/releases/* | sort | tail -n 2 | head -n 1)
ln -sfn "$PREV" /srv/app/current.tmp && mv -Tf /srv/app/current.tmp /srv/app/current
sudo systemctl reload gunicorn

This works because migrations were backward compatible: the previous code still runs against the new schema. Reverting a migration is a separate, deliberate step.

5. Clean up old releases

ls -1d /srv/app/releases/* | sort | head -n -5 | xargs -r rm -rf

Keep enough releases for quick rollbacks, and never delete the one current points to.

one release, start to finish1Clone into releases/<ts>fresh directory2venv, pip install, collectstaticcheck --deploy3migrate (backward compatible)old code still works4Rename current.tmp over currentatomic switch5systemctl reload gunicornworkers pick up new codeFailures before step 4 never affect the live sitedjango-deployment.com
Wrap these steps in Fabric, Ansible, or a CI job so every release is identical.

Verification check: readlink /srv/app/current shows the new release, curl https://example.com/version/ returns the new commit, and a rehearsal rollback switches back within seconds without errors in the Gunicorn log.

Explanation

A symlink rename is atomic on POSIX filesystems: any process resolving current sees either the old target or the new one, never a partial state. Building each release in its own directory moves all slow and failure-prone work (dependency installation, static collection, checks) out of the live path. Gunicorn's graceful reload then starts workers from the new path while old workers finish their requests. The pattern needs no containers and works on any VPS, which is why it remains common for traditional Django deployments.

Edge cases / notes

  • Celery workers must be restarted (not just reloaded) to load new code; restart them after the switch.
  • Shared filesystem paths in settings should use /srv/app/shared/... or paths relative to current, not a specific release.
  • Disk space. Each release has its own venv; prune old releases and consider sharing a wheel cache.
  • Python version changes require a new venv anyway, which this pattern handles naturally.

Next steps

FAQ

Is ln -sfn atomic?

Not quite; it replaces the link in two steps. Create a temporary link and mv -T it over current for a truly atomic switch.

Yes, new workers started by HUP resolve the path again, as long as the app is not preloaded in the master.

Where do uploaded media files go?

In a shared directory outside the releases, symlinked into each release.

How many releases should I keep?

Three to five is typical: enough to roll back a few versions without filling the disk.

Keep going