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.
- Lay out
/srv/app/releases/<timestamp>/,/srv/app/shared/(env file, media), and/srv/app/current→ latest release. - Build each release fully: checkout, virtualenv,
pip install,collectstatic,check --deploy. - Run backward-compatible migrations from the new release.
- Switch atomically:
ln -sfn releases/<ts> current.tmp && mv -Tf current.tmp current. - Reload Gunicorn gracefully (
systemctl reload), whose unit points at/srv/app/current. - Keep the last 5 releases; roll back by pointing
currentat 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.
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.
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 tocurrent, 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
- Script the whole flow with Django Deployment Bash Script Guide.
- Plan reversals with Roll Back a Django Release Safely.
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.
Does Gunicorn follow the new symlink on reload?
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.