Deploy Django With git push and a post-receive Hook
Deploy Django to your own server with git push production main: a bare repository with a post-receive hook that checks out the code, installs dependencies, runs migrations and collectstatic, and reloads Gunicorn, with safe failure handling.
Problem statement
Heroku made git push heroku main the simplest deploy experience. On your own VPS, you can get the same workflow without a platform: a bare Git repository on the server receives your push, and a post-receive hook builds and releases the code. It is ideal for solo developers and small teams who want one-command deploys without setting up a CI service, as long as the hook is written carefully so a failed build never breaks the live site.
- On the server, create a bare repo:
git init --bare /srv/git/myapp.gitowned by the deploy user. - Add a
hooks/post-receivescript that checks out the pushed branch into a new release directory. - In the hook: create or reuse the venv,
pip install,check --deploy,migrate,collectstatic, switch thecurrentsymlink,systemctl reload gunicorn. - Allow the deploy user to reload services with a narrow sudoers rule.
- Locally:
git remote add production deploy@server:/srv/git/myapp.git, thengit push production main. - Run tests before pushing (locally or in CI); the hook is not a test runner.
Step-by-step solution
1. Create the bare repository
sudo -u deploy mkdir -p /srv/git
sudo -u deploy git init --bare /srv/git/myapp.git
A bare repository has no working tree, only Git data, so pushing to it never conflicts with checked-out files.
2. Write the hook
#!/usr/bin/env bash
# /srv/git/myapp.git/hooks/post-receive
set -euo pipefail
APP=/srv/app
DEPLOY_BRANCH=main
while read -r oldrev newrev ref; do
[ "$ref" = "refs/heads/$DEPLOY_BRANCH" ] || { echo "Ignoring $ref"; continue; }
TS=$(date -u +%Y%m%dT%H%M%S)
REL=$APP/releases/$TS
echo "==> Building $newrev in $REL"
mkdir -p "$REL"
git --work-tree="$REL" --git-dir="$PWD" checkout -f "$newrev"
python3 -m venv "$REL/.venv"
"$REL/.venv/bin/pip" install -q -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 migrate --noinput
.venv/bin/python manage.py collectstatic --noinput -v 0
ln -sfn "$REL" $APP/current.tmp && mv -Tf $APP/current.tmp $APP/current
sudo /bin/systemctl reload gunicorn
sudo /bin/systemctl restart celery-worker
echo "$newrev" > "$REL/REVISION"
echo "==> Deployed $newrev"
done
chmod +x /srv/git/myapp.git/hooks/post-receive
The hook's output streams back to your terminal, so you see the deploy as it happens. With set -e, any failing command stops the hook before the symlink switch; the live release stays as it was.
3. Allow service reloads without full sudo
# /etc/sudoers.d/deploy (edit with visudo -f)
deploy ALL=(root) NOPASSWD: /bin/systemctl reload gunicorn, /bin/systemctl restart celery-worker
Only these exact commands are allowed without a password.
4. Push to deploy
git remote add production deploy@203.0.113.10:/srv/git/myapp.git
git push production main
You see the hook's output, ending in Deployed <sha>. To redeploy the same commit, push an empty commit or run the hook manually with the revision.
5. Keep tests in front of the push
The hook should build and release, not decide whether the code is good. Run the test suite locally or require CI to pass before merging to main, then push. For teams, a CI job that pushes to the server remote after tests pass gives the same flow with an automatic gate.
Verification check: git push production main prints the hook output and ends with the deployed SHA; cat /srv/app/current/REVISION matches git rev-parse main; pushing a commit with a deliberate error stops at check --deploy and leaves the site serving the previous release.
Explanation
Git runs hooks at defined points; post-receive runs on the server after a push has been accepted, receiving the old and new revisions on standard input. A bare repository plus git --work-tree=... checkout exports a specific commit into any directory without a persistent working copy. Combining that with release directories gives atomic switches and rollbacks, and the push itself becomes the deploy trigger. This is the same mechanism Dokku and early Heroku used, without the buildpack layer.
Edge cases / notes
- Long installs keep your terminal busy; reuse a wheel cache or copy the previous venv when requirements are unchanged.
- Force pushes deploy whatever commit you push; protect the production branch locally or deploy only tags.
- Multiple servers need the hook on each or a central CI step; git push deploys suit single servers best.
- Secrets stay in
/srv/app/shared/envon the server and never in the repository.
Next steps
- The release directory pattern in depth: Atomic Django Releases With Symlinked Release Directories.
- Want buildpacks and a PaaS feel on your server? See Deploy Django With Dokku.
FAQ
How do I deploy Django with git push?
Create a bare repository on the server with a post-receive hook that builds a release, migrates, and reloads Gunicorn.
What happens if the deploy fails halfway?
With release directories and set -e, the hook stops before switching current, so the live site keeps running the old release.
Can the hook run tests?
It can, but it slows every push. Run tests locally or in CI before pushing.
Do I need root for the hook?
No. The deploy user runs it, with a narrow sudoers rule for reloading services.