SeriesPart 1 of 8 // Beneath the Porcelain
gitWriting
Aug 13, 2026
9 min read
ci-cd

The Bare Repo Pipeline

A bare repository and a nine-line post-receive hook turn `git push` into a full deploy, with no CI platform, no webhook, and no build server in the loop.

A terminal window showing a git push landing on a bare repository, with an arrow tracing from the push into a deployed directory on the same server.

The Bare Repo Pipeline

Part 1 of 8 in the Beneath the Porcelain series: what you can build, break, and repair using nothing but git plumbing.


Somewhere on most of my personal servers there is a small static site or a side-project API that does not deserve a CI platform. It has one contributor, deploys a few times a month, and would take longer to configure a pipeline for than to actually maintain. For years, the standard answer for that gap was a shell script triggered by a cron job polling a repository, or a webhook wired into a runner that spins up a container just to run rsync.

Git already has a mechanism for exactly this, and it has had one since before "CI/CD" was a phrase anyone used. A bare repository is a git repository with no working tree: just the object database, the refs, and a hooks/ directory sitting next to them. When you push into a bare repo, the receiving git-receive-pack process runs a script called post-receive after it finishes writing the new objects and moving the refs. That script runs on the server, with the pushed commits already sitting in the object database, before git push on your end even returns.

That is the entire mechanism. No agent, no listener, no separate process watching the filesystem. git push is the trigger, and the hook is the pipeline. This part builds the minimal version: a bare repo whose post-receive hook checks out the new commit and rsyncs it into a directory a webserver is already serving. Later parts add testing gates, blue-green rollouts, and release signing on top of exactly this mechanism, so it is worth understanding precisely, not just copying.


Setting Up the Bare Repo

The .git suffix on the directory name (site.git, not site) is convention rather than a technical requirement, but it is convention worth keeping: it is the same suffix GitHub, GitLab, and every other forge use for exactly the same reason — a bare repository is not a project directory you would ever cd into and start editing, and the suffix is a small, visible reminder of that every time it shows up in a path, a remote URL, or an ls listing next to directories that are meant to be worked in.

On the server, initializing a bare repo looks almost identical to any other git init, with one flag:

git init --bare /srv/site.git
Initialized empty Git repository in /srv/site.git/

The directory that comes out looks like the inside of a normal repo's .git/, because that is exactly what it is:

$ ls /srv/site.git
HEAD  config  description  hooks  info  objects  refs

There is no index, no working tree, nothing to git status. This repo cannot be built or run in place; it only exists to receive pushes and hand off to the hook. That constraint is the whole point: a bare repo has no working copy for a careless git checkout on the server to clobber, and nothing for a developer to accidentally commit into by SSHing in and poking around.


Free Protection Before You Write a Single Hook

Before reaching for a hook at all, a bare repo already has a couple of git config switches worth setting, because they cost nothing and cover two of the most common accidents on a shared deploy target. receive.denyDeletes refuses any push that would delete a branch or tag ref outright:

$ git -C /srv/site.git config receive.denyDeletes true
$ git push origin :refs/heads/main
remote: error: denying ref deletion for refs/heads/main
 ! [remote rejected] main (deletion prohibited)

Its sibling, receive.denyNonFastForwards, refuses any push that is not a fast-forward — the same shape of protection this series builds out with much finer control in Parts 3 and 7, but available as a single boolean with no hook at all. Neither of these needs a script, a language runtime, or anything to go wrong at 2am; they are policy the git server itself enforces, and they are worth setting on any bare repo before you write a single line of hook logic on top.


The post-receive Hook

Git ships every new repository with a hooks/ directory full of *.sample files, none of them active by default:

$ ls /srv/site.git/hooks
applypatch-msg.sample  pre-applypatch.sample    pre-receive.sample
commit-msg.sample      pre-commit.sample        prepare-commit-msg.sample
post-update.sample     pre-merge-commit.sample  push-to-checkout.sample
                       pre-push.sample          sendemail-validate.sample
fsmonitor-watchman.sample  pre-rebase.sample     update.sample

Notice what is missing: there is no post-receive.sample. Git ships a sample for pre-receive (used starting in Part 3) but not for the hook this part actually needs, which means writing post-receive from scratch, as a plain executable file with no suffix, is not skipping a step — there was never a template to start from. To activate any hook, drop the .sample suffix from the file that has one, or create the file directly for the ones that do not, and make it executable. post-receive reads lines from standard input, one per ref that was updated in the push, each formatted as <old-sha> <new-sha> <refname>:

#!/usr/bin/env bash
set -euo pipefail
 
TARGET="/srv/deploy"
WORKTREE="/tmp/site-deploy-checkout"
BRANCH="main"
 
mkdir -p "$WORKTREE"
 
while read -r oldrev newrev refname; do
  if [ "$refname" = "refs/heads/$BRANCH" ]; then
    echo "post-receive: deploying $refname ($oldrev -> $newrev)"
    git --work-tree="$WORKTREE" --git-dir="$GIT_DIR" checkout -f "$BRANCH"
    rsync -a --delete "$WORKTREE"/ "$TARGET"/
    echo "post-receive: deployed to $TARGET"
  fi
done

A few details matter here, and each one is a common source of a hook that silently does nothing:

  • GIT_DIR is set for you. When git invokes a hook, it exports GIT_DIR pointing at the repository the push landed in. You do not need to hardcode the path, and you should not: hardcoding it is the first thing that breaks when you clone the bare repo somewhere else.
  • --work-tree turns a bare repo into a checkout target for exactly one command. git --work-tree=<dir> --git-dir=<repo> checkout -f <ref> populates <dir> with the tree at <ref>, using the bare repo's object database, without ever giving the bare repo a permanent working tree of its own.
  • The loop filters by ref. post-receive fires once per push, not once per branch, and its stdin can contain multiple lines if the push touched multiple refs. Checking refname against refs/heads/main explicitly is what keeps a push to a scratch branch from redeploying production.
  • rsync -a --delete syncs the checkout into the real target directory and removes files that no longer exist in the new tree. Skip --delete and you accumulate stale files from every deleted page or asset, forever.

Make it executable and it is live:

chmod +x /srv/site.git/hooks/post-receive

Pushing to It

From a normal working clone, add the bare repo as a remote and push:

$ git remote add origin /srv/site.git
$ git push origin main
remote: post-receive: deploying refs/heads/main (0000000000000000000000000000000000000000 -> 6d2ccbb04d869fa51f897ea8de2068fb018eca4f)
remote: Already on 'main'
remote: post-receive: deployed to /srv/deploy
To /srv/site.git
 * [new branch]      main -> main

That Already on 'main' line is worth pausing on, because it looks wrong and is not. The old SHA is 0000...000, git's way of saying "this ref did not exist before this push" — the classic zero-id you will see again in later parts whenever a hook needs to distinguish a brand-new branch or tag from an update to an existing one. On this very first push, checkout -f main runs against the temporary work-tree for the first time, and since that work-tree's HEAD already defaults to main before any file has been written into it, git reports "already on" rather than "switched to." It is a checkout of a branch git considers itself already positioned on, into a directory that happens to be empty. The files still land correctly; the message is just about branch identity, not file state.

A second push behaves exactly as you would hope:

$ echo "<h1>hello v2</h1>" > index.html
$ git commit -am "update site"
$ git push origin main
remote: post-receive: deploying refs/heads/main (6d2ccbb..94dd26a)
remote: Already on 'main'
remote: post-receive: deployed to /srv/deploy
To /srv/site.git
   6d2ccbb..94dd26a  main -> main

Same "Already on 'main'" message every time, because the temporary work-tree persists between pushes and stays on main throughout its life — there is nothing here that ever calls checkout on a different branch from within that work-tree, so git never has cause to print "Switched to." If your own version of this hook ever needs to distinguish "first deploy" from "update," do it by checking oldrev against the zero SHA, not by parsing checkout output.

Pushing a different branch confirms the filter works:

$ git checkout -b scratch
$ git push origin scratch
To /srv/site.git
 * [new branch]      scratch -> scratch

No post-receive: lines at all, and /srv/deploy is untouched. The hook ran — post-receive always runs on any push that updates any ref — it just found no line where refname matched refs/heads/main, so the loop body never executed.


Why the Bare Repo, Specifically

You could get something visually similar by making /srv/deploy itself a git repo and running git pull there on a cron timer. The bare-repo-plus-hook approach is better for reasons that will matter more as this series adds gates on top of it:

The trigger and the deploy are the same event. There is no polling interval, no window where a push has landed but nothing has happened yet. The hook runs synchronously inside git push, which means the deploy result — success, failure, hook rejection — can be reported back to the person pushing, over the same connection, as part of the push output you already saw above. Part 3 leans on exactly this to reject bad pushes before they are ever accepted.

There is no working tree to corrupt. A non-bare repo used as a deploy target has an .git/index and a checked-out tree that a stray git reset --hard, a manual edit, or a second concurrent process can desynchronize from the ref it is supposed to represent. A bare repo has none of that; the only mutable state is the object database and refs, both of which git itself guards with its normal atomicity guarantees during a push.

Ref updates are transactional across the whole push, not per-branch. When git-receive-pack accepts a push touching several refs at once, it applies all of the resulting ref updates as a single reference transaction: if the process is interrupted partway, refs land in either their old state or their new state, never a mix where three branches updated and a fourth is left half-written. The post-receive hook only ever sees a push whose ref updates have already fully committed — the hook does not need to worry about the object database being mid-update while it reads from it, which is a guarantee a cron-driven git pull script running against a live working directory does not get for free.

The mechanism generalizes. A post-receive hook is just a script that receives ref transitions on standard input. Nothing about it says "rsync a static site." Part 4 replaces the single checkout with two, managed as git worktrees, and flips which one is live. Part 6 gates the whole thing on a signed tag instead of a branch. Part 8 combines all of it into one pipeline. None of those parts change the fundamental shape introduced here: push updates a ref, a hook reacts to the ref update, the hook does the deploy. Everything this series adds from here on is a refinement of what the hook does once it fires, not a replacement for the fact that it fires at all.


Takeaways

A post-receive hook is a real event trigger, not a polling workaround. It runs synchronously as part of git push, with the new objects already committed to the object database, and its output streams back to the pusher over the same connection. That is a tighter coupling between "push" and "deploy" than most webhook-driven CI setups achieve, for a fraction of the moving parts.

Bare repositories exist specifically to be pushed into, not worked in. No working tree means no local state for a hook, a careless SSH session, or a second concurrent push to desynchronize. Every write to a bare repo goes through git's own ref-update machinery, and a couple of git config switches (receive.denyDeletes, receive.denyNonFastForwards) give you baseline protection against the most common accidents before you write any hook logic at all.

--work-tree combined with --git-dir is the general-purpose tool for turning a bare repo's history into files on disk on demand. It is how this hook deploys, how Part 4's blue-green worktrees stay independent, and how Part 6's release gate checks out a promoted build. Learning this one flag pair early pays for itself across the rest of this series.


Next: Part 2: What Actually Happens on git push. The wire protocol underneath the command you just ran nine times, and the failure modes it explains.

Beneath the Porcelain is an eight-part series by Ajitem Sahasrabuddhe on the git internals that quietly hold together small production systems. Every command shown was run against git 2.50.1 on macOS before being written up; output may differ slightly on other platforms or versions.

Series contents

01
The Bare Repo Pipeline
Current
02
What Actually Happens on `git push`
Coming soon // Aug 20, 2026
03
Hooks as a Poor Man's CI
Coming soon // Aug 27, 2026
04
Worktrees in Production: Blue-Green Deploys from One Clone
Coming soon // Sep 3, 2026
05
Custom Merge and Filter Drivers for Generated Files
Coming soon // Sep 10, 2026
06
Signed Tags as a Release Gate
Coming soon // Sep 17, 2026
07
Rewriting History Safely: `filter-repo` and Protected Refs
Coming soon // Sep 24, 2026
08
A Deployment Pipeline Made Entirely of Git
Coming soon // Oct 1, 2026