post-receive hooks for notifications and deploys Jump to heading

Once a push has been accepted and every ref updated, Git runs post-receive. It cannot reject anything β€” the push has already happened β€” but it is the natural place to react: notify a chat channel, start a CI build on a server with no forge, mirror to another remote, or deploy a static site straight from a branch. It is also easy to misuse. The client waits for the hook to finish before git push returns, so a slow deploy in post-receive makes every push hang; a failure prints alarming output for a push that actually succeeded; and running the same push twice must not deploy twice. This page builds post-receive hooks that react quickly and safely, within server-side hook enforcement.

When to use this approach Jump to heading

  • You run a self-hosted Git server without a forge’s webhooks, or you want reactions that happen on the server itself.
  • Pushes to certain branches should notify people, trigger a build or update a deployment.
  • An existing post-receive hook makes pushes slow, or deploys twice.
  • On hosted forges, webhooks and CI triggers do this job; see CI/CD pipeline trigger mapping.

Step 1 β€” Read the updates, like pre-receive Jump to heading

post-receive receives the same standard input as pre-receive: one line per updated ref with old value, new value and ref name. All of them have been applied by the time it runs.

#!/bin/sh
# hooks/post-receive
zero=$(git hash-object --stdin </dev/null | tr '0-9a-f' '0')
while read -r old new ref; do
  case "$ref" in
    refs/heads/main)       [ "$new" != "$zero" ] && echo "main updated to ${new%${new#???????}}" ;;
    refs/tags/v*)          [ "$old" = "$zero" ] && echo "new release tag ${ref#refs/tags/}" ;;
  esac
done
Where post-receive sits in a pushThe client sends objects and ref updates. pre-receive and update run and may reject. Git updates the refs. post-receive then runs with the same ref list, and the client waits for it to finish before the push command returns, even though the push has already succeeded.clientgit serverpre-receivepost-receivepack + ref updatescheck (may reject)refs updatedsame ref listoutput shown, client waitsthe push is already done β€” post-receive can only react, and should do so quickly

Step 2 β€” Hand slow work to a background queue Jump to heading

Anything that takes more than a second β€” building, deploying, calling a slow API β€” should not run inside the hook. Write a job to a queue and return. A separate worker processes it.

# Enqueue a job per relevant update and return immediately
queue=/var/spool/git-jobs
while read -r old new ref; do
  case "$ref" in
    refs/heads/main)
      job="$queue/$(date +%s)-$new.job"
      printf 'repo=%s\nref=%s\nsha=%s\n' "$(pwd)" "$ref" "$new" > "$job.tmp" && mv "$job.tmp" "$job"
      echo "queued deploy of ${new%${new#???????}}" ;;
  esac
done

The mv from a temporary name makes each job appear atomically, so the worker never reads a half-written file. A systemd path unit, a cron job or any small daemon can process the directory.

Work inside post-receive against a queueRunning a deploy inside post-receive makes the pushing client wait minutes and ties the deploy's failure output to a push that succeeded. Enqueuing a job returns in milliseconds, lets a worker retry failures, and keeps push output short.Deploy in the hookEnqueue, worker deployspush returns afterthe deploymillisecondsdeploy failure showsin the pusher's terminalin the worker's logretriesmanual re-pushworker retriesthe hook's only job is to record that something should happen

Step 3 β€” Make every reaction idempotent Jump to heading

The same commit can arrive at a reaction more than once: a branch is pushed, deleted and pushed again, or a worker restarts mid-job. Key every reaction on the commit hash and skip work already done.

#!/bin/sh
# worker: deploy one job, skipping commits already deployed
. "$1"                                             # repo=, ref=, sha=
state=/var/lib/git-deploy/deployed-shas
grep -qx "$sha" "$state" 2>/dev/null && { echo "already deployed $sha"; exit 0; }
mkdir -p /srv/www/releases/"$sha"
git --git-dir="$repo" archive "$sha" | tar -x -C /srv/www/releases/"$sha"
ln -sfn /srv/www/releases/"$sha" /srv/www/current
echo "$sha" >> "$state"

Deploying by switching a symlink to a fresh directory, as above, also makes rollback a single command: point the link at the previous release.

Step 4 β€” Send notifications without leaking or flooding Jump to heading

Notifications should say what changed, link to it, and be skipped for noise. Include the pusher and a short log, never the full diff, and batch large pushes.

while read -r old new ref; do
  [ "$ref" = refs/heads/main ] || continue
  [ "$old" = "$zero" ] && continue
  n=$(git rev-list --count "$old..$new")
  log=$(git log --format='β€’ %s (%an)' "$old..$new" | head -5)
  [ "$n" -gt 5 ] && log="$log
…and $((n-5)) more"
  curl -fsS -m 3 -X POST "$CHAT_WEBHOOK" -H 'Content-Type: application/json' \
    -d "$(jq -n --arg t "main: $n new commit(s) by ${GL_USERNAME:-$USER}" --arg l "$log" '{text: "\($t)\n\($l)"}')" \
    >/dev/null 2>&1 || true
done

The -m 3 timeout and || true are deliberate: a chat outage must never make pushes hang or print errors.

Step 5 β€” Mirror to another remote Jump to heading

A common post-receive task is keeping a mirror up to date β€” a backup server, a read-only public copy. Read the input once, then push in a detached background process with its output redirected, so a slow or unreachable mirror never delays the client.

# post-receive: mirror updated refs in the background
input=$(cat)
(
  printf '%s\n' "$input" | while read -r old new ref; do
    git push --quiet backup "+$ref:$ref"
  done >>/var/log/git-mirror.log 2>&1 &
)

The mirror is a copy, so overwriting its refs (+) is intended. If the mirror might receive pushes from elsewhere, alert on divergence instead of forcing.

What should a post-receive reaction look like?Notifications can run inline if they have a short timeout and never fail the hook. Mirroring and builds should run in the background. Deploys should go through a queue with an idempotent worker, so pushes stay fast and duplicates are harmless.What does the reaction do?notify (≀3 s)Inlinetimeout, never failmirror / trigger CIBackgrounddetach, log errorsdeployQueue + workeridempotent by SHAnothing in post-receive should be able to make a push look like it failed

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Can post-receive undo a push it does not like? Jump to heading

It could move refs back, but it should not: the client has already been told the push succeeded. Put rejection logic in pre-receive or update, where a refusal is clean.

Why does my push hang even though the hook backgrounds its work? Jump to heading

The background process still holds the hook’s output streams open, so Git waits for them. Redirect the background job’s standard output and error to a file or /dev/null, as in the examples.

Is a forge webhook better than post-receive? Jump to heading

On hosted forges, webhooks are the only option and they are well suited: delivered asynchronously, with retries and delivery logs. On self-hosted servers, post-receive plus a queue gives you the same shape with no extra service.