Rules no developer can bypass locally
Client-side Git hooks are convenient, but every developer can bypass them with --no-verify, delete them, or simply never install them. Server-side hooks, by contrast, run on the central Git server, where nobody has direct access, making them the only real enforcement point for force-push protection, file size limits and branch naming conventions across the team.
Table of Contents
- 1. Why server-side hooks are the real enforcement point
- 2. The three hook types in the Git push flow
- 3. pre-receive: blocking a push before anything changes
- 4. update: enforcing rules per branch
- 5. post-receive: notifications and CI triggers
- 6. GitLab Push Rules as a managed alternative
- 7. GitHub Rulesets and Branch Protection
- 8. Practical examples: force-push, file size, branch names
- 9. Client-side vs. server-side compared directly
- 10. Summary
- 11. FAQ
1. Why server-side hooks are the real enforcement point
Client-side hooks like pre-commit and commit-msg live in the local .git/hooks directory or get set up via Husky through npm install. That is exactly where their structural weakness lies: any developer can bypass them with git commit --no-verify, delete the scripts, or clone a fresh repository where Husky was never installed in the first place. Client-side hooks are therefore a convenience and early-warning layer, not a security boundary. They catch typos and obvious mistakes before a push even happens, but they guarantee nothing.
Server-side hooks, by contrast, run on the central Git server, whether that is a self-hosted bare repository server, a GitLab instance, or a GitHub Enterprise server. No developer has direct access to that directory, cannot disable the scripts locally, and cannot manipulate the push without going through administrative access. That is exactly what makes server-side hooks the only place where rules like force-push bans, file size limits, or branch naming conventions actually apply to every client, regardless of whether they push from the command line, an IDE plugin, or a CI system.
2. The three hook types in the Git push flow
Git offers three relevant hook types on the server side, executed in a fixed order during a git push: pre-receive, update, and post-receive. All three live in the hooks/ directory of the remote repository and must be executable to take effect. The decisive difference lies in the timing of execution and in the ability to actually reject the push.
pre-receive runs exactly once for the entire push, before any reference is updated, and receives lines on stdin in the format <old-sha> <new-sha> <ref-name> for every affected branch or tag. update, by contrast, runs once per reference being updated and receives three positional arguments: ref name, old SHA, and new SHA. post-receive, finally, runs only after all references have already been updated. It is well suited for notifications and CI triggers, but it can no longer reject the push, because the change is already in the repository by that point.
3. pre-receive: blocking a push before anything changes
The pre-receive hook is the most powerful point for hard validation, because it is the only hook that sees the entire push in one go and can reject it completely with a non-zero exit code, without a single reference ever being updated. That makes it ideal for checks that need to consider all affected branches together, such as a global force-push ban on protected branches or a check for whether the push contains a merge commit at all.
Technically, the script reads the stdin lines one by one and checks, for each reference, whether the old SHA is an ancestor of the new SHA. If it is not, this is a force-push that rewrites history instead of extending it. git merge-base --is-ancestor <old-sha> <new-sha> is built exactly for this: the command returns exit code 0 if the old SHA is an ancestor, and non-zero if not. If the check runs on a protected branch like main or production and fails, the script prints an error message to stderr and exits with code 1, which makes Git reject the entire push.
#!/usr/bin/env bash
# pre-receive hook: reject force-pushes to protected branches
set -euo pipefail
readonly PROTECTED_BRANCHES="main|production|release/.*"
while read -r old_sha new_sha ref_name; do
branch="${ref_name#refs/heads/}"
# Skip branch deletions and non-matching branches
[[ "$new_sha" =~ ^0+$ ]] && continue
[[ "$branch" =~ ^($PROTECTED_BRANCHES)$ ]] || continue
# New branch creation on a protected name is fine
[[ "$old_sha" =~ ^0+$ ]] && continue
# Force-push means old_sha is NOT an ancestor of new_sha
if ! git merge-base --is-ancestor "$old_sha" "$new_sha" 2>/dev/null; then
echo "REJECTED: force-push to protected branch '$branch' is not allowed" >&2
echo "History rewrites on $branch must go through a reviewed process" >&2
exit 1
fi
done
exit 0
4. update: enforcing rules per branch
While pre-receive sees all references in a push together, update is called separately for each individual reference, with the ref name, old SHA, and new SHA as positional arguments $1, $2, and $3. That makes it the natural place for rules decided on a per-branch basis without needing to consider the entire push context, such as checking branch naming conventions when a new branch is created.
A typical example: a team enforces that new branches follow a fixed prefix scheme, such as feature/*, bugfix/*, or hotfix/*, so that CI pipelines and deployment scripts can automatically pick the right behavior based on the name. The update hook checks the ref name against a regular expression and rejects the reference with a non-zero exit code if it does not match. Unlike pre-receive, however, the rejection then only affects that one reference, not necessarily the entire push, if multiple references are being updated at the same time.
#!/usr/bin/env bash
# update hook: enforce branch naming convention
# Arguments: $1 = refname, $2 = old_sha, $3 = new_sha
set -euo pipefail
refname="$1"
old_sha="$2"
new_sha="$3"
# Only enforce naming on newly created branches
if [[ "$old_sha" =~ ^0+$ ]] && [[ "$refname" =~ ^refs/heads/ ]]; then
branch="${refname#refs/heads/}"
# Allow protected long-lived branches by exact name
case "$branch" in
main|develop|production) exit 0 ;;
esac
if ! [[ "$branch" =~ ^(feature|bugfix|hotfix|release)/[a-z0-9._-]+$ ]]; then
echo "REJECTED: branch '$branch' violates naming convention" >&2
echo "Use one of: feature/<name>, bugfix/<name>, hotfix/<name>, release/<name>" >&2
exit 1
fi
fi
exit 0
5. post-receive: notifications and CI triggers
post-receive runs after all references have already been successfully updated, and like pre-receive it receives the list of changes on stdin. The decisive difference: this hook can no longer reject the push, no matter what exit code it returns, since the commits are already a permanent part of the repository by then. That makes it unsuitable for validation, but ideal for anything that should happen after a successful change.
Typical use cases include triggering a CI pipeline via a webhook call, sending a Slack or email notification to the team, updating a deployment dashboard, or triggering an automatic deployment on pushes to main. Because post-receive runs after the actual push operation, a slow webhook call blocks the response to the client, but no longer endangers the integrity of the repository. When in doubt, the hook should kick off time-consuming work asynchronously in the background instead of leaving the client waiting minutes for a response.
6. GitLab Push Rules as a managed alternative
Anyone running a GitLab instance does not necessarily need to maintain pre-receive and update as raw shell scripts. GitLab offers Push Rules, a UI-driven configuration layer that is internally enforced server-side and therefore offers the same security guarantee as a hand-written pre-receive hook. Push Rules can be defined per project or globally at the instance level, and cover regular expressions for allowed branch names, commit message formats, maximum file size, and banning force-pushes on specific branches, among other things.
The advantage over a custom script lies in maintainability: changing the rules requires no server access and no deployment of a new hook script, but is instead managed through the project settings and is visible in a versioned audit log. For more complex logic that goes beyond regular expressions, such as a check against an external database or ticketing system, a classic pre-receive hook remains available on self-hosted GitLab instances and can be combined with Push Rules.
# GitLab Push Rules configuration (Project Settings > Repository > Push Rules)
# These map to fields in the GitLab API / gitlab.rb for self-managed instances
push_rules:
# Reject commits that don't match this branch naming pattern
branch_name_regex: "^(feature|bugfix|hotfix|release)/[a-z0-9._-]+$"
# Reject force-pushes on protected branches (also configurable via
# Settings > Repository > Protected Branches > Allowed to force push: No)
deny_delete_tag: true
member_check: true
# Reject files above this size in newly pushed commits (MB)
max_file_size: 20
# Reject commit messages that don't reference a ticket ID
commit_message_regex: "^(feat|fix|chore|docs)(\\(.+\\))?: .+ \\[[A-Z]+-[0-9]+\\]$"
# Reject pushes that add files matching these paths (e.g. secrets)
file_name_regex: "(^|/)\\.env$|\\.pem$|id_rsa$"
7. GitHub Rulesets and Branch Protection
Raw pre-receive hook scripts, as supported by GitLab and self-hosted Git servers, only exist on GitHub in GitHub Enterprise Server, the self-hosted variant. On GitHub.com, the SaaS offering, there is no access to the Git server itself and therefore no way to deploy custom hook scripts. Instead, GitHub.com provides Repository Rulesets and the older Branch Protection as a comparable, but declarative, alternative that is enforced server-side without any custom code.
Rulesets can be managed through the web interface, via gh api, or as infrastructure-as-code through Terraform, and cover mandatory status checks before a merge, force-push bans, blocking deletion of protected branches, and requiring signed commits, among other things. Unlike GitLab Push Rules, there is no way to plug in arbitrary shell logic here. Anyone who needs more complex server-side checks has to express them through mandatory status checks set by an external CI pipeline or GitHub Action before a merge is allowed.
# Create a GitHub repository ruleset via gh CLI (GitHub.com or Enterprise Cloud)
# Enforces required status checks and blocks force-pushes on main
gh api repos/mironsoft/shop-backend/rulesets \
--method POST \
-f name='protect-main' \
-f target='branch' \
-f enforcement='active' \
-f 'conditions[ref_name][include][]=refs/heads/main' \
-f 'rules[][type]=deletion' \
-f 'rules[][type]=non_fast_forward' \
-f 'rules[][type]=required_status_checks' \
-f 'rules[][parameters][required_status_checks][][context]=ci/tests' \
-f 'rules[][parameters][required_status_checks][][context]=ci/phpstan' \
-f 'rules[][parameters][strict_required_status_checks_policy]=true'
# non_fast_forward = reject force-pushes server-side, equivalent to
# checking old_sha is an ancestor of new_sha in a raw pre-receive hook
8. Practical examples: force-push, file size, branch names
Besides force-push protection and branch naming conventions, rejecting large files is one of the most common use cases for pre-receive. Binary files, accidentally committed .env files with credentials, or large media assets should never make it into the repository in the first place, rather than having to be painstakingly removed from history later with git filter-repo. The hook uses git diff-tree for this, to determine the objects newly introduced by the push, and git cat-file -s to check their size without needing to load the full blob content.
All three examples, force-push protection, branch names, and file size limits, share the same underlying principle: the check inspects only metadata and objects that Git makes available to the hook via stdin, arguments, or Git commands directly, without the client-side state being relevant. That is the decisive difference from a linter running in a pre-commit hook: server-side hooks only see what would actually end up in the repository, not what a developer happens to have lying around locally in their working directory.
#!/usr/bin/env bash
# pre-receive hook: reject files above a size threshold
set -euo pipefail
readonly MAX_SIZE_BYTES=$((10 * 1024 * 1024)) # 10 MB
while read -r old_sha new_sha ref_name; do
[[ "$new_sha" =~ ^0+$ ]] && continue
base_sha="$old_sha"
[[ "$base_sha" =~ ^0+$ ]] && base_sha="$(git rev-list --max-parents=0 "$new_sha" | tail -1)"
# List new blob objects introduced by this push
while read -r blob_sha; do
size=$(git cat-file -s "$blob_sha")
if (( size > MAX_SIZE_BYTES )); then
path=$(git rev-list --objects "$base_sha..$new_sha" | grep "$blob_sha" | cut -d' ' -f2-)
echo "REJECTED: '$path' is ${size} bytes, exceeds ${MAX_SIZE_BYTES} byte limit" >&2
exit 1
fi
done < <(git rev-list --objects "$base_sha..$new_sha" \
| git cat-file --batch-check='%(objectname) %(objecttype)' \
| awk '$2 == "blob" {print $1}')
done
exit 0
9. Client-side vs. server-side compared directly
Client-side hooks, hand-written server-side hooks, and managed solutions like GitLab Push Rules or GitHub Rulesets solve similar problems with very different guarantees. The following overview shows why choosing the level is not a matter of style, but a security decision.
| Dimension | Client-Side Hooks | Custom Server-Side Hook | GitLab/GitHub Managed Rules |
|---|---|---|---|
| Bypassable locally | Yes, --no-verify or delete | No | No |
| Applies to all clients | Only if installed (Husky + npm install) | Yes, always | Yes, always |
| Setup effort | Low, but needed per developer | High, custom script plus server access | Low, UI configuration |
| Free-form script logic | Fully free | Fully free | Limited to predefined rules |
| Early feedback | Immediate, before the commit | Only at push time | Only at push time |
| Maintenance on rule changes | Rollout to all developers needed | Deployment to the Git server needed | Immediate via project settings |
The table makes clear that client-side and server-side hooks are not competitors but complementary layers. Client-side hooks deliver fast feedback right at commit time and save unnecessary, otherwise-rejected pushes. Server-side hooks or managed rules ensure that these rules actually apply to everyone, regardless of whether the local configuration is correct or even present at all.
Mironsoft
Git workflows, server hooks and CI/CD hardening for development teams
Push validation that actually applies to everyone?
We set up server-side hooks and managed rules for your team, from force-push protection through branch conventions to file size limits, tailored to GitLab, GitHub, or your self-hosted Git server.
Hook development
pre-receive, update and post-receive scripts for custom rules
GitLab/GitHub setup
Cleanly configuring Push Rules, Rulesets and Branch Protection
CI/CD integration
Mandatory status checks and automated approval workflows
10. Summary
Server-side hooks solve a problem that client-side hooks cannot structurally solve: the reliable enforcement of rules regardless of how a developer pushes. pre-receive sees the entire push before any change is made and can reject it completely, such as on a force-push to main or when files are too large. update checks each reference individually, for example against a branch naming convention. post-receive runs after the change and is suited to notifications and CI triggers, but can no longer reject the push.
Anyone who does not want to maintain their own shell scripts will find a declarative, UI-driven alternative in GitLab Push Rules and GitHub Rulesets with the same server-side enforcement guarantee. The best strategy combines both layers: client-side hooks for fast feedback right at commit time, server-side hooks or managed rules as an unavoidable safeguard behind them. Only the combination ensures that rules are not just documented, but actually enforced.
Server-Side Hooks, the essentials at a glance
Real enforcement point
Server-side hooks run where no developer has direct access, unlike --no-verify with client-side hooks.
pre-receive vs. update vs. post-receive
pre-receive checks the whole push, update checks per reference, post-receive runs afterward and can no longer reject.
Force-push protection
git merge-base --is-ancestor reliably detects whether a push rewrites history.
Managed alternatives
GitLab Push Rules and GitHub Rulesets replace raw scripts with UI configuration under the same guarantee.