Skip to content

Deploying with a post-receive Git Hook

Deploying through CI is great, but sometimes you just need to push code to a server with a single git push. The post-receive hook in a bare repository handles this without extra dependencies: push to the server, and the hook automatically checks out files into the working directory.

Schema: bare repo as a deploy trigger

The logic is straightforward:

  1. A bare repository is created on the server (e.g., /srv/deploy/app.git).
  2. The developer adds it as a remote and runs git push origin main.
  3. Git receives the data and triggers hooks/post-receive.
  4. The hook script runs git --work-tree=/var/www/app --git-dir=/srv/deploy/app.git checkout -f main.
Note

A bare repository has no working directory. That is why post-receive must explicitly pass --work-tree so checkout knows where to write files.

This is not CI — it is a direct trigger at the Git level. No pipelines, artifacts, or queues. It fits small services, VPS instances, and internal tools well.

Creating a bare repository on the server

SSH into the server and create the repo:

mkdir -p /srv/deploy
cd /srv/deploy
git init --bare app.git

The resulting structure is standard: hooks/, objects/, refs/, HEAD, config. Default hooks live in /srv/deploy/app.git/hooks/ with a .sample suffix — rename them or create your own.

Warning

Make sure /srv/deploy is owned by the user you are pushing as. Otherwise, write permissions on objects/ will be denied.

Writing the post-receive hook

Create /srv/deploy/app.git/hooks/post-receive:

#!/usr/bin/env bash
set -euo pipefail

REPO_DIR="/srv/deploy/app.git"
WORK_TREE="/var/www/app"
BRANCH="main"

while read oldrev newrev refname; do
  if [ "$refname" = "refs/heads/$BRANCH" ]; then
    echo "Deploying $BRANCH to $WORK_TREE..."
    git --work-tree="$WORK_TREE" --git-dir="$REPO_DIR" checkout -f "$BRANCH"
    echo "Deployment complete."
  fi
done

Key elements:

ElementPurpose
set -euo pipefailScript exits on any error; does not continue with bad data
while read oldrev newrev refnameThe hook passes three arguments per line for each updated ref
refs/heads/$BRANCHChecks that the pushed ref is the target branch
checkout -fForce-synchronizes the working tree with the reference
Tip

If you need to restart a service after deployment, append systemctl restart app or supervisorctl reload app to the end of the script. The hook runs in the context of the Git user, so verify it has permission to restart.

Setting up permissions and access

The most common issue is permissions. The Git user you push as must be able to write to WORK_TREE and execute commands from the hook.

Setup options:

  • Single user: Git user and web user are the same identity. Simply chown -R deploy:deploy /srv/deploy /var/www/app.
  • Group: Add the Git user to the web server group. usermod -aG www-data deploy, then chmod -R g+w /var/www/app.
  • SSH key: Push over SSH; the key authenticates as the correct user.
# Example: deploy user, web root /var/www/app
sudo useradd -m deploy
sudo mkdir -p /var/www/app
sudo chown -R deploy:deploy /var/www/app
sudo chmod -R 755 /var/www/app

Do not forget to make the hook executable:

chmod +x /srv/deploy/app.git/hooks/post-receive

Pushing from the client and verifying

On the developer machine, add the remote and push:

git remote add production deploy@server:/srv/deploy/app.git
git push production main

On the server, the hook log will show:

Deploying main to /var/www/app...
Deployment complete.

Verify files on the server:

ls -la /var/www/app
git --git-dir=/srv/deploy/app.git --work-tree=/var/www/app status
Warning

If the push fails with Permission denied, check the owner of hooks/post-receive and the target directory. If checkout does not write files, confirm that WORK_TREE points to an existing directory and the user has write access to it.

That is it. No additional tools — just Git and shell. For high-load production, this is obviously not a replacement for a proper CI pipeline, but for quick deploys to one or two servers it works reliably without unnecessary complexity.