Protect a Git Server with git-receive-pack Hooks

Every push you make lands on the server side through git-receive-pack, and that is where a rejected policy check quietly saves you. This walkthrough builds a disposable bare server, accepts a normal push, rejects a policy violation before any ref changes, and keeps non-fast-forward updates under control. The examples use git-receive-pack from Git 2.43.0, supplied by Ubuntu's git-man package. Allow about fifteen minutes and a writable directory under /tmp.

git-receive-pack is normally started by git push on the receiving side, you rarely invoke it by hand. The useful work here is preparing the bare repository, setting its receive policy, and installing hooks in its hooks directory.

1. Create an isolated bare repository

Use a temporary path so this test cannot touch a real hosted repository. These are ordinary user commands, no sudo needed:

$ rm -rf /tmp/receive-pack-demo
$ git init --bare /tmp/receive-pack-demo.git
$ git init /tmp/receive-pack-client
$ git -C /tmp/receive-pack-client config user.name 'Demo User'
$ git -C /tmp/receive-pack-client config user.email '[email protected]'
$ printf '%s\n' 'first commit' > /tmp/receive-pack-client/README
$ git -C /tmp/receive-pack-client add README
$ git -C /tmp/receive-pack-client commit -m 'Initial commit'
$ git -C /tmp/receive-pack-client branch -M main
$ git -C /tmp/receive-pack-client remote add origin /tmp/receive-pack-demo.git
$ git -C /tmp/receive-pack-client push -u origin HEAD

The final command should create the remote branch and report a new branch named after the client's current one. Check the receiving repository without changing it:

$ git --git-dir=/tmp/receive-pack-demo.git show-ref
<commit-id> refs/heads/<branch-name>

Checkpoint: if show-ref prints nothing, the push did not create a ref. Check the preceding push output before going further.

2. Reject non-fast-forward updates

A receive repository can refuse a push that would move a ref backwards or sideways. Set the documented receive.denyNonFastForwards option in the bare repository:

$ git --git-dir=/tmp/receive-pack-demo.git config receive.denyNonFastForwards true
$ git --git-dir=/tmp/receive-pack-demo.git config --get receive.denyNonFastForwards
true

Security boundary: this is a server-side setting, not a suggestion. It does not block ordinary fast-forward pushes. A force push can still be an intentional operational action elsewhere, but do not weaken this setting just to turn a failed deployment green.

To test the boundary safely, make a second commit on the client, push it, then create a separate history and try to replace the branch. The second push should be rejected:

$ printf '%s\n' 'second commit' >> /tmp/receive-pack-client/README
$ git -C /tmp/receive-pack-client add README
$ git -C /tmp/receive-pack-client commit -m 'Second commit'
$ git -C /tmp/receive-pack-client push

$ git -C /tmp/receive-pack-client checkout --orphan replacement
$ git -C /tmp/receive-pack-client rm -rf .
$ printf '%s\n' 'unrelated history' > /tmp/receive-pack-client/README
$ git -C /tmp/receive-pack-client add README
$ git -C /tmp/receive-pack-client commit -m 'Unrelated history'
$ git -C /tmp/receive-pack-client push --force origin HEAD:main

The last command should report the update was rejected as a non-fast-forward. This example deliberately names the branch main, keep the ref name consistent in the push and hook if you adapt it. To restore this disposable test, remove both paths and repeat step 1, do not force-push to a production repository as an undo strategy.

3. Add a pre-receive policy hook

The pre-receive hook runs once before any proposed ref is updated. It receives one line per proposed ref on standard input: old object ID, new object ID, ref name. A non-zero exit status rejects the whole update, and the update, post-receive and post-update hooks that would normally follow do not run.

Install this deliberately small policy in the disposable repository. It rejects updates to the protected refs/heads/main ref, adjust the name if your test branch differs:

$ cat > /tmp/receive-pack-demo.git/hooks/pre-receive <<'EOF'
#!/bin/sh
while read old new ref
do
    if test "$ref" = refs/heads/main
    then
        echo 'main is protected by the pre-receive hook' >&2
        exit 1
    fi
done
exit 0
EOF
$ chmod +x /tmp/receive-pack-demo.git/hooks/pre-receive
$ test -x /tmp/receive-pack-demo.git/hooks/pre-receive && echo 'hook is executable'
hook is executable

Push a change to the protected ref and expect the hook's message followed by a rejected push. This hook changes server behaviour, review it like production code and keep a copy before editing. Recovery is simple: rename or remove the hook only once you have decided the policy should no longer apply.

4. Choose the right hook for side effects

Warning: do not send mail or trigger an external deployment from update. A later check can still reject the ref, leaving that notification false. Put side effects in post-receive instead, and make them retryable if delivery matters.

5. Understand quarantine before writing hooks

Incoming objects first land in a temporary quarantine directory under $GIT_DIR/objects, and move into the main object store only after pre-receive succeeds. If the pack, connectivity checks, or the hook fails the push, the quarantined data is removed.

That stops repeated failed pushes from accumulating unreferenced data, but it creates a strict hook rule: a pre-receive hook must not update a ref to an object still in quarantine. Other processes cannot reliably see that object yet, so Git rejects ref updates from inside the hook for exactly this reason. Inspect incoming objects from the hook if you need to, but leave ref changes to receive-pack's normal update phase.

6. Diagnose a rejected push without guessing

First identify which side rejected the operation and keep the exact client output. Then check the receiving repository's policy and hook permissions:

$ git --git-dir=/tmp/receive-pack-demo.git config --get-regexp '^receive\.'
receive.denynonfastforwards true
$ ls -l /tmp/receive-pack-demo.git/hooks/pre-receive
$ git --git-dir=/tmp/receive-pack-demo.git show-ref

Do not run sudo git-receive-pack as a first diagnostic, it can make ownership and hook-environment problems harder to see. The service account that owns the repository should normally run the receive process; reach for elevated privileges only to inspect or repair permissions under an approved server-maintenance procedure.

Done means