Git, CI/CD & GitOpsXXV · HooksHooks
Server-side hooks — pre-receive, update, post-receive, post-commit
What you'll learn
- Identify the four server-side hooks and the lifecycle point each fires at
- Read the input contract for pre-receive (full ref list on stdin) and update (one ref per invocation)
- Deploy from a bare repository using a post-receive hook that checks out the working tree and runs the deployment
- Recognise when hosted platforms disable custom server-side hooks and how their hosted policy API replaces them
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
Server-side hooks run on the receiving Git server - the bare
repository that holds the canonical history. They are the
mechanism that lets the server enforce policy on push, regardless
of which client made the push. The four server-side hooks are
pre-receive, update, post-receive, and post-commit (the
last only meaningful in non-bare repositories, which is rare on
a server). This lesson covers the input contract for each, the
canonical deployment pattern, and the practical reality that
most hosted Git platforms disable custom server-side hooks.
The four server-side hooks
The hooks fire in this order around a single push:
flowchart TB
PUSH["git push received"] --> PR["pre-receive\n(fires once per push)"]
PR -->|"exit 0"| U["update\n(fires once per ref)"]
PR -->|"exit non-zero"| RAB["push refused"]
U -->|"exit 0 per ref"| RU["ref-update protocol"]
U -->|"exit non-zero for any ref"| RAB2["that ref refused, others may proceed"]
RU --> PRH["post-receive\n(fires once per push, after all refs)"]
The three hooks (excluding post-commit, which is a
non-bare-repository artefact) answer three different questions:
- pre-receive answers “should this push be accepted at all?”
Fires once per push, before any ref is updated. Receives the
full list of ref updates on stdin (
<old-oid> <new-oid> <ref-name>, one per line). Exit non-zero refuses the entire push. - update answers “should this specific ref be updated?”
Fires once per ref being updated. Receives the ref name as
$1, the old OID as$2, and the new OID as$3. Exit non-zero refuses that ref only; other refs in the same push may proceed. - post-receive answers “the push is complete; what should
the server do now?” Fires once per push, after all refs have
been updated. Receives the full ref list on stdin (same format
as
pre-receive). Cannot refuse anything; the push has already happened.
The three hooks are complementary. pre-receive is the cheapest
hook to write because it inspects the whole push at once; update
gives per-ref granularity (one script invocation per ref);
post-receive is for side effects after acceptance.
pre-receive — the enforcement hook
pre-receive is the hook that implements branch protection. It
fires before any ref is updated; its exit code determines whether
the entire push is accepted or rejected. The hook receives the
full ref list on stdin, which is the canonical input for
cross-ref checks (e.g. “no push may update both main and
feature/x in the same push”).
#!/usr/bin/env bash
# .git/hooks/pre-receive on the bare repository
# Enforce: no force-push to protected branches
set -euo pipefail
while read oldrev newrev refname; do
case "$refname" in
refs/heads/main|refs/heads/master|refs/heads/production)
# Detect non-fast-forward (force-push)
if [ "$oldrev" != "0000000000000000000000000000000000000000" ] && \
! git merge-base --is-ancestor "$oldrev" "$newrev"; then
echo "Push refused: force-push to $refname is not allowed" >&2
exit 1
fi
;;
esac
done
exit 0
The script reads each ref update from stdin, checks the ref name
against a protected-branches list, and uses git merge-base --is-ancestor to detect non-fast-forward updates. A force-push
to a protected branch causes the hook to exit non-zero, which
refuses the entire push before any ref is updated.
update — the per-ref hook
update is the same logic as pre-receive but with per-ref
granularity. It fires once per ref being updated; the script
receives the ref name, old OID, and new OID as positional
arguments. Exit non-zero refuses that specific ref; other refs
in the same push may still proceed.
#!/usr/bin/env bash
# .git/hooks/update on the bare repository
# Enforce: signed commits only on main
set -euo pipefail
refname="$1"
oldrev="$2"
newrev="$3"
if [ "$refname" = "refs/heads/main" ]; then
# Check every commit in the update range for a valid signature
for commit in $(git rev-list "$oldrev..$newrev"); do
if ! git verify-commit "$commit" >/dev/null 2>&1; then
echo "Push refused: commit $commit is not signed" >&2
exit 1
fi
done
fi
The advantage of update over pre-receive for this check is
that the hook can exit non-zero for one ref without refusing
the whole push. If an engineer is pushing two branches at once,
the update script can reject the push to main while
allowing the push to a feature branch.
post-receive — the deployment hook
post-receive is the canonical deployment hook. It fires after
all refs have been updated; its job is to react to the
completed push. The classic pattern is “deploy from a bare
repository” - the server holds a bare repository, the working
tree lives in a separate directory, and post-receive checks
out the new tip into the working tree and runs the deployment.
#!/usr/bin/env bash
# .git/hooks/post-receive on the bare repository
# Deploy to /var/www/app on every push to main
set -euo pipefail
while read oldrev newrev refname; do
if [ "$refname" = "refs/heads/main" ]; then
echo "Deploying $newrev to /var/www/app..."
git --work-tree=/var/www/app --git-dir=/var/lib/repos/app.git \
checkout -f main
cd /var/www/app
make deploy
fi
done
The pattern is the original “GitOps” - the push is the
deployment trigger, and post-receive is the deploy script. It
works for a single-server deployment; for a fleet, the
post-receive hook calls out to an orchestration system
(Ansible, Kubernetes, a CI runner) rather than deploying
locally.
Hosted platforms disable custom server-side hooks
The practical reality of GitHub, GitLab, Bitbucket, and other
hosted platforms is that custom server-side hooks are disabled.
The platform runs its own pre-receive to enforce branch
protection, signing requirements, and other policy, and the
user cannot install arbitrary scripts in that hook chain.
The hosted replacement is a policy API and webhook model:
- Branch protection rules - the platform’s hosted
equivalent of
pre-receivefor branch-level rules. - Required status checks - the platform’s hosted
equivalent of
pre-receivefor CI gating. - Webhooks - the platform’s outbound HTTP POST that notifies an external system (CI runner, deployment bot, chat integration) of completed events. The external system cannot refuse the push; the webhook is a notification, not an enforcement point.
- GitHub Apps / GitLab integrations - long-running applications that the platform authorises to act on repository events. Some apps implement pre-receive-like enforcement via the Checks API.
# GitHub: configure branch protection via the API
# (this is the hosted equivalent of a custom pre-receive hook)
gh api -X PUT /repos/acme/iac/branches/main/protection \
-f required_status_checks='{"strict":true,"contexts":["ci"]}' \
-f enforce_admins=true \
-f required_pull_request_reviews='{"required_approving_review_count":2}' \
-f restrictions=null \
-f allow_force_pushes=false
The discipline is to recognise that hosted platforms replace custom server-side hooks with a hosted policy API plus webhooks plus GitHub Apps. The mechanism is different; the role (policy enforcement on push) is the same.
Production discipline
- Use
pre-receivefor push-level policy. Branch protection, signing requirements, secret scanning, and commit-message conventions belong inpre-receive(or hosted equivalents) because they answer “should this push be accepted?”. - Use
updatefor per-ref policy. When a rule must apply to one ref but not another (e.g. “main requires signed commits, feature branches do not”),updategives the per-ref granularity. - Use
post-receivefor side effects. Deployment, notification, cache invalidation, and CI trigger belong inpost-receivebecause they are reactions to a completed push, not gates on the push. - Recognise that hosted platforms replace custom hooks.
On GitHub, GitLab, and Bitbucket, the equivalent of
pre-receiveis the policy API (branch protection, required status checks). Custom scripts are not installable. - Test server-side hooks on a staging repository first.
A
pre-receivehook that refuses every push by accident is a denial of service for the team.
Cross-course references
- Git, CI/CD & GitOps - Part XXII (ForcePush) lesson 05
covers branch protection, which is the hosted equivalent
of a
pre-receivehook. - Git, CI/CD & GitOps - Part XXII (ForcePush) lesson 06
covers a
pre-receivehook that enforces--force-with-leaseon non-protected branches. - GitOps for Production Sysadmins - Parts I-III
(RepoPattern, ArgoCD, FluxCD) cover the modern GitOps
pattern where
post-receive(or a webhook equivalent) triggers a controller that reconciles the cluster.
Quiz
Knowledge check · 4 questions
Q1. A team uses a self-hosted bare repository at `/var/lib/repos/app.git` and wants to deploy to `/var/www/app` on every push to `main`. Which hook should host the deploy script, and what is the deployment pattern?
Q2. A `pre-receive` hook that exits non-zero refuses the entire push before any ref is updated, while an `update` hook that exits non-zero refuses only the specific ref being processed and allows other refs in the same push to proceed.
Q3. Name the four server-side hooks and the lifecycle point each fires at, in the order they fire around a single push.
Q4. Diagnose why a team migrating from a self-hosted bare repository to GitHub loses its custom pre-receive hook, and recommend the hosted equivalent that preserves the policy.
A team self-hosts a bare repository on a Linux server with a custom `pre-receive` hook that enforces: (1) no force-push to `main`, (2) every commit on `main` must be GPG-signed, and (3) every commit message must match a Conventional Commits regex. The team migrates the repository to GitHub for collaboration with a partner org. After the migration, the team discovers that custom server-side hooks are not installable on GitHub, and asks how to preserve the three rules.
Passing score: 75%. Answers are checked in this browser.