Server-Side Hooks
Server-side hooks run on the remote Git server (the bare repository) in response to push operations. Unlike client-side hooks, they cannot be bypassed by individual developers with --no-verify. They are the definitive enforcement layer for policy, and they power deployment pipelines and notification systems in self-hosted Git environments.
Where server hooks live
On a bare Git repository the hooks directory is hooks/ (not .git/hooks/):
# Bare repo structure /srv/git/myproject.git/ ├── HEAD ├── config ├── hooks/ ← hooks live here │ ├── pre-receive │ ├── update │ └── post-receive ├── objects/ └── refs/
# On the server, activate a hook chmod +x /srv/git/myproject.git/hooks/pre-receive
The three server-side hooks
Hook | Fires | Receives on stdin | Can reject? |
|---|---|---|---|
| Once, before any refs are updated | All refs being pushed (one per line) | Yes — exit non-zero rejects the entire push |
| Once per ref being updated | Three arguments: | Yes — exit non-zero rejects only that ref |
| Once, after all refs are updated | Same as pre-receive (all updated refs) | No — push has already landed |
pre-receive
pre-receive is the most powerful server hook. It runs once per push and can inspect all the proposed ref updates together. If it exits non-zero, the entire push is rejected and nothing is changed on the server.
It reads from stdin — each line has the format: <old-sha> <new-sha> <ref-name>
stdin for a push that updates two refs
0000000000000000000000000000000000000000 a1b2c3d4... refs/heads/feature/login b3c4d5e6... f7a8b9c0... refs/heads/main
A zero SHA for the old value means the ref is being created (new branch/tag). A zero SHA for the new value means the ref is being deleted.
hooks/pre-receive — reject force-pushes to protected branches
#!/usr/bin/env bash
set -euo pipefail
ZERO_SHA="0000000000000000000000000000000000000000"
PROTECTED="main master release"
while read old_sha new_sha ref_name; do
branch="${ref_name#refs/heads/}"
for protected in $PROTECTED; do
if [ "$branch" = "$protected" ]; then
# Detect force-push: old commit is not an ancestor of new commit
if [ "$old_sha" != "$ZERO_SHA" ]; then
if ! git merge-base --is-ancestor "$old_sha" "$new_sha" 2>/dev/null; then
echo "✗ Force-push to '$branch' is not allowed."
echo " Branch '$branch' is protected."
exit 1
fi
fi
fi
done
done
exit 0update
update fires once per ref being pushed (as opposed to pre-receive which fires once for the entire push). It receives three positional arguments: the ref name, the old SHA, and the new SHA. It can selectively reject individual refs while allowing others through.
hooks/update — enforce branch naming conventions
#!/usr/bin/env bash
set -euo pipefail
REF_NAME="$1"
OLD_SHA="$2"
NEW_SHA="$3"
# Only validate branch refs
if [[ "$REF_NAME" != refs/heads/* ]]; then
exit 0
fi
BRANCH="${REF_NAME#refs/heads/}"
ZERO_SHA="0000000000000000000000000000000000000000"
# Allow deletions
if [ "$NEW_SHA" = "$ZERO_SHA" ]; then
exit 0
fi
# Enforce naming: main, develop, feature/*, fix/*, hotfix/*, release/*, chore/*
VALID_PATTERN='^(main|master|develop|feature/.+|fix/.+|hotfix/.+|release/.+|chore/.+|docs/.+)$'
if ! echo "$BRANCH" | grep -qE "$VALID_PATTERN"; then
echo "✗ Branch name '$BRANCH' does not follow the naming convention."
echo ""
echo " Allowed patterns:"
echo " main, develop"
echo " feature/<description>"
echo " fix/<description>"
echo " hotfix/<description>"
echo " release/<version>"
echo " chore/<description>"
echo " docs/<description>"
echo ""
echo " Example: git branch -m $BRANCH feature/$(echo $BRANCH | tr ' ' '-')"
exit 1
fi
# Enforce max branch name length
if [ ${#BRANCH} -gt 100 ]; then
echo "✗ Branch name is too long (${#BRANCH} chars). Max: 100."
exit 1
fi
exit 0post-receive
post-receive runs after the push completes successfully. It cannot reject the push — the data is already stored. It is used for notifications, triggering CI/CD, updating issue trackers, and deployments. It receives the same stdin format as pre-receive.
hooks/post-receive — send a Slack notification
#!/usr/bin/env bash
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../B.../..."
while read old_sha new_sha ref_name; do
branch="${ref_name#refs/heads/}"
pusher=$(git log -1 --pretty=format:'%an' "$new_sha")
message=$(git log -1 --pretty=format:'%s' "$new_sha")
short_sha=$(echo "$new_sha" | cut -c1-8)
payload=$(cat <<JSON
{
"text": "*[$branch]* $pusher pushed `$short_sha`: $message"
}
JSON
)
curl -s -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL"
donehooks/post-receive — trigger a CI build via webhook
#!/usr/bin/env bash
CI_WEBHOOK="https://ci.example.com/webhook/build"
REPO_NAME=$(basename "$(pwd)" .git)
while read old_sha new_sha ref_name; do
branch="${ref_name#refs/heads/}"
# Only trigger CI for main and feature branches
if [[ "$branch" != "main" && "$branch" != feature/* ]]; then
continue
fi
curl -s -X POST "$CI_WEBHOOK" -H "Content-Type: application/json" -d "{"repo":"$REPO_NAME","branch":"$branch","sha":"$new_sha"}"
echo "CI build triggered for $branch ($new_sha)"
donehooks/post-receive — deploy on push to main
#!/usr/bin/env bash
while read old_sha new_sha ref_name; do
branch="${ref_name#refs/heads/}"
if [ "$branch" = "main" ]; then
echo "Deploying to production..."
# Run your deployment script
/srv/deploy/deploy.sh "$new_sha" >> /var/log/deploy.log 2>&1 &
echo "Deployment started in background. Check /var/log/deploy.log"
fi
doneValidate commit messages server-side
hooks/update — reject commits with invalid messages
#!/usr/bin/env bash
set -euo pipefail
REF_NAME="$1"
OLD_SHA="$2"
NEW_SHA="$3"
ZERO_SHA="0000000000000000000000000000000000000000"
# Only check branch updates (not tags or deletions)
if [[ "$REF_NAME" != refs/heads/* ]] || [ "$NEW_SHA" = "$ZERO_SHA" ]; then
exit 0
fi
# Determine the range of new commits
if [ "$OLD_SHA" = "$ZERO_SHA" ]; then
# New branch — check all commits reachable from NEW_SHA but not from other branches
RANGE="$NEW_SHA"
COMMITS=$(git rev-list --not --branches "$RANGE" 2>/dev/null || git rev-list "$RANGE" | head -50)
else
COMMITS=$(git rev-list "$OLD_SHA..$NEW_SHA")
fi
PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)((.+))?: .{1,72}'
while IFS= read -r sha; do
[ -z "$sha" ] && continue
MSG=$(git log -1 --pretty=%s "$sha")
# Skip merge commits
if echo "$MSG" | grep -qE '^Merge '; then
continue
fi
if ! echo "$MSG" | grep -qE "$PATTERN"; then
echo "✗ Commit $sha has an invalid message:"
echo " '$MSG'"
echo " Expected: <type>(scope): <description>"
exit 1
fi
done <<< "$COMMITS"
exit 0How GitHub/GitLab webhooks relate to post-receive
Hosted Git platforms (GitHub, GitLab, Bitbucket) do not give you direct access to the server's hooks/ directory. Instead, they expose webhooks — HTTP callbacks sent to a URL you configure whenever a push (or PR, or issue, etc.) occurs. Webhooks are functionally equivalent to post-receive but run on the platform's infrastructure.
Aspect | Server hooks (self-hosted) | Platform webhooks (GitHub/GitLab) |
|---|---|---|
Access | Full shell access on the server | HTTP endpoint you control |
Can reject push | Yes ( | No — push already landed when webhook fires |
Payload | Raw Git refs on stdin | JSON body with rich metadata |
Setup | Write a script in | Configure a URL in repo settings |
Language | Any (shell, Python, Node, …) | Any HTTP server |
Availability | Only for bare repos you control | Available on all hosted repos |
Setting up hooks on a bare Git server
SSH to the server:
ssh git@serverInitialise a bare repo:
git init --bare /srv/git/myproject.gitWrite the hook script:
vim /srv/git/myproject.git/hooks/pre-receiveMake it executable:
chmod +x /srv/git/myproject.git/hooks/pre-receiveTest by pushing from a client:
git push git@server:/srv/git/myproject.git main
Minimal test on the server
# On the server git init --bare /srv/git/testrepo.git cat > /srv/git/testrepo.git/hooks/pre-receive << 'EOF' #!/usr/bin/env bash echo "pre-receive: push received" while read old new ref; do echo " Updating $ref: $old -> $new" done exit 0 EOF chmod +x /srv/git/testrepo.git/hooks/pre-receive # From a client git remote add server git@server:/srv/git/testrepo.git git push server main
Common server-side hook use cases
Reject force-pushes to protected branches (pre-receive).
Enforce branch naming conventions (update).
Require GPG-signed commits (update — check
git log --show-signature).Validate commit messages against Conventional Commits (update).
Trigger CI builds on push (post-receive).
Send Slack/Teams/email notifications on new pushes or tags (post-receive).
Auto-deploy a staging environment when a specific branch is pushed (post-receive).
Update a ticket tracker (e.g., close Jira issues referenced in commit messages) (post-receive).
Reject large files to prevent accidental binary uploads (pre-receive).