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 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.
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.
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.
Related Jump to heading
- Server-Side Hook Enforcement β the parent topic.
- Verifying Webhook Signatures β the receiving side of forge webhooks.
- Rolling Back a Deployment with Git β rollback beyond symlink switching.
- Mirroring Third-Party Repositories for Resilience β mirrors that preserve history.