Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXXV · HooksHooks

Server-side hooks — pre-receive, update, post-receive, post-commit

Intermediate⏱ ~20 mingit

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

Not yet marked complete on this device.

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-receive for branch-level rules.
  • Required status checks - the platform’s hosted equivalent of pre-receive for 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

  1. Use pre-receive for push-level policy. Branch protection, signing requirements, secret scanning, and commit-message conventions belong in pre-receive (or hosted equivalents) because they answer “should this push be accepted?”.
  2. Use update for per-ref policy. When a rule must apply to one ref but not another (e.g. “main requires signed commits, feature branches do not”), update gives the per-ref granularity.
  3. Use post-receive for side effects. Deployment, notification, cache invalidation, and CI trigger belong in post-receive because they are reactions to a completed push, not gates on the push.
  4. Recognise that hosted platforms replace custom hooks. On GitHub, GitLab, and Bitbucket, the equivalent of pre-receive is the policy API (branch protection, required status checks). Custom scripts are not installable.
  5. Test server-side hooks on a staging repository first. A pre-receive hook 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-receive hook.
  • Git, CI/CD & GitOps - Part XXII (ForcePush) lesson 06 covers a pre-receive hook that enforces --force-with-lease on 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

  1. 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?

  2. 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.

  3. Q3. Name the four server-side hooks and the lifecycle point each fires at, in the order they fire around a single push.

  4. 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.