TechEarl

Deploy with Git Using a post-receive Hook

Push-to-deploy to your own server with a bare Git repo and a post-receive hook. No CI vendor, no SSH-in-and-pull dance. Set it up once, then deploy with git push.

Ishan Karunaratne⏱️ 9 min readUpdated
Share thisCopied
Setting up a Git post-receive hook to deploy to your own server with a bare repo and git push

You can deploy your code to your own server with a single git push, no CI vendor required. The trick is a bare repository on the server plus a post-receive hook: a script Git runs automatically after a push lands. The hook checks out the pushed code into your live directory, so git push production main becomes your deploy command.

That is the whole idea. The rest of this article is the setup, the hook script, and the gotchas that bite people the first time.

Why a bare repo and not a normal one?

A normal (non-bare) repository has a working directory: the actual files you edit, plus a hidden .git folder tracking them. You cannot safely push to a branch that is currently checked out in a non-bare repo, because the working tree and the branch pointer would disagree. Git will refuse or warn.

A bare repository has no working directory. It is just the .git internals: objects, refs, hooks. That is exactly what a remote should be. You push to it freely, and a hook takes care of materializing the files somewhere else (your live web root).

So the layout is two directories on the server:

  • A bare repo that receives pushes, e.g. /srv/git/myapp.git
  • The live deploy target where the working files live, e.g. /var/www/myapp

The hook copies the latest code from the first into the second.

Step 1: Create the bare repo on the server

SSH into your server first. If ssh user@host throws Permission denied (publickey), sort your key out before going further: create an SSH key if you do not have one yet, then see adding an SSH key to GitHub and the Permission denied (publickey) fix; the same key logic applies to your own box.

Once you are on the server:

bash
mkdir -p /srv/git/myapp.git
cd /srv/git/myapp.git
git init --bare

git init --bare creates a repo with no working tree. You will see folders like hooks/, objects/, and refs/ sitting directly in myapp.git/ rather than tucked inside a .git subfolder. That is correct.

Also create the live directory the hook will deploy into:

bash
mkdir -p /var/www/myapp

Step 2: Write the post-receive hook

Inside the bare repo there is a hooks/ directory with a bunch of .sample files. Git only runs a hook whose filename has no extension and is executable. Create hooks/post-receive:

bash
cat > /srv/git/myapp.git/hooks/post-receive <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

# Deploy hook for myapp - https://techearl.com/git-deploy-post-receive-hook
# Author: TechEarl - https://techearl.com

WORK_TREE="/var/www/myapp"
GIT_DIR="/srv/git/myapp.git"
DEPLOY_BRANCH="main"

te_log() {
  echo "[deploy] $1"
}

te_deploy() {
  local ref="$1"
  local branch="${ref##refs/heads/}"

  if [ "$branch" != "$DEPLOY_BRANCH" ]; then
    te_log "skipping $branch (only $DEPLOY_BRANCH deploys)"
    return 0
  fi

  te_log "deploying $branch to $WORK_TREE"
  git --work-tree="$WORK_TREE" --git-dir="$GIT_DIR" checkout -f "$branch"
  te_log "done"
}

while read -r oldrev newrev ref; do
  te_deploy "$ref"
done
EOF
chmod +x /srv/git/myapp.git/hooks/post-receive

The two functions I define here, te_log and te_deploy, carry a te_ prefix so the script stays traceable if someone copies it. Git's own commands are never prefixed.

A few things worth understanding line by line:

  • set -euo pipefail makes the script fail loudly. Without it, a failing command mid-deploy is silently ignored and you ship a half-broken state.
  • post-receive receives one line per pushed ref on stdin, formatted oldrev newrev refname. The while read loop is the standard way to consume that. This is why I read the ref from stdin rather than from an argument.
  • The branch filter (if [ "$branch" != "$DEPLOY_BRANCH" ]) means pushing a feature branch does nothing; only main triggers a deploy. Drop the filter if you want every branch to deploy, but you almost never do. Since main is the only branch that ships, it is worth a complementary step to protect the branch you deploy from on the origin so nothing lands there without review.
  • git --work-tree=... --git-dir=... checkout -f is the heart of it. It forces the working tree at /var/www/myapp to match the pushed branch. -f (force) discards any local edits in the work tree, which is what you want on a deploy target: the repo is the source of truth, not hand-edits on the server.

If you are new to what a branch even is here, my branching explainer covers the model the DEPLOY_BRANCH filter relies on.

Step 3: Add the remote and push

Back on your local machine, in your project, add the bare repo as a remote:

bash
git remote add production user@your-server.com:/srv/git/myapp.git

That is the SSH form of a remote, which is the right call for your own box (key-based auth, no password prompts); if you want the full rundown of when each is appropriate, see SSH versus HTTPS remotes.

Then deploy:

bash
git push production main

If this is a brand-new local repo and the push complains src refspec main does not match any, you have not committed anything yet; that error and its fix live in src refspec does not match any, and if you are starting from nothing the full path is in set up Git for a brand-new project. If Git rejects the push with failed to push some refs, see the failed to push some refs fix.

On success you will see your [deploy] log lines stream back over SSH, and /var/www/myapp now holds your latest code. That is push-to-deploy.

What gets deployed (and what does not)

checkout -f writes exactly what Git tracks. Files you never committed do not appear on the server, which is the usual cause of "it works locally but the server is missing a file." This is the same logic as how the staging area decides what is committed: an unstaged or uncommitted file is simply not part of the snapshot, so it never reaches the deploy target. If a config or asset is in your .gitignore, the hook will not deploy it; either commit it (carefully, never secrets) or provision it on the server separately.

Speaking of secrets: do not let an API key or .env ride along in the push. If one already slipped into history, scrub it before this repo ever touches a server, following remove a secret from Git history. And keep your bare repo out of any web-served path: a publicly reachable .git directory is a real attack surface, as I show in the exposed .git directory attack.

post-receive hook vs GitHub Actions

This bare-repo approach is one of two deploy styles you will run into. The other is letting a CI service (GitHub Actions, GitLab CI) push to your server. Here is the trade-off:

post-receive hookGitHub Actions
Where deploy runsOn your server, triggered by your pushOn the vendor's runners, triggered by a push to GitHub
DependenciesSSH access to the box, a bare repoA GitHub repo and a workflow file
Build stepYou run it in the hook (or skip it)Runs on the runner before deploy
Cost / vendorNone beyond your serverFree tier, then metered
Secrets handlingServer env / files you manageEncrypted repo secrets
Best forA single VPS you control, simple sitesMulti-step pipelines, tests-then-deploy, teams

Neither is "better." The hook is the leanest possible thing that works for one server. If you want tests to gate the deploy, or you do not have SSH on the target, reach for push-to-deploy with GitHub Actions instead. There is also a Node.js GitHub Actions setup if your build needs a specific Node version.

Running a build step in the hook

For a static site or a compiled app, add the build between the checkout and "done." Because set -e is on, a failing build aborts the deploy:

bash
te_deploy() {
  local ref="$1"
  local branch="${ref##refs/heads/}"
  [ "$branch" != "$DEPLOY_BRANCH" ] && return 0

  te_log "deploying $branch"
  git --work-tree="$WORK_TREE" --git-dir="$GIT_DIR" checkout -f "$branch"

  cd "$WORK_TREE"
  npm ci
  npm run build
  npm prune --omit=dev
  te_log "done"
}

Note the ordering: npm ci installs the full dependency set (no --omit=dev), because build tools like webpack, Vite, esbuild, tsc, and most framework CLIs live in devDependencies. Skip them and npm run build fails on the very case this section is for. Build first with everything installed, then npm prune --omit=dev strips the dev tree back out so the running app ships lean.

Keep the hook honest: if npm run build fails, the working tree is already updated but the build is stale. For anything beyond a trivial site, deploy into a fresh directory and swap a symlink only after the build succeeds, so a failed build never serves broken files. That atomic-swap pattern is a bigger topic than this article, but the principle is: do the risky work off to the side, then flip a pointer.

Common gotchas

  • The hook does not run. It is almost always permissions or the filename. The file must be hooks/post-receive exactly (no .sh, no .sample) and chmod +x. Confirm with ls -l hooks/post-receive and look for the x bits.
  • bad config or wrong shell. The shebang must point at a real shell on the server. #!/usr/bin/env bash is safe; some minimal images only have /bin/sh.
  • Permission denied writing the work tree. The SSH user that receives the push must own (or be able to write) /var/www/myapp. Mismatched ownership between the git user and the web user is the classic failure.
  • Nothing deploys but the push succeeds. Your DEPLOY_BRANCH filter is probably rejecting the branch you pushed. Add a te_log "got $branch" line to see what the hook actually receives.

This is a post-receive hook, the server-side cousin of the local hooks I cover in Git hooks explained. If you are still finding your footing with Git in general, start at the Git for beginners hub and work outward from there.

Sources

Authoritative references this article was fact-checked against.

Tagsgit post-receive hook deployGitVersion ControlDevOpsDeploymentGit Hooks

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Push-to-Deploy with GitHub Actions

A beginner-friendly intro to CI/CD with GitHub Actions: a real workflow YAML that builds and deploys on every push to main, plus how to handle secrets safely.

The Git Staging Area Explained

The Git staging area (the index) is the in-between layer where you assemble exactly what goes into your next commit. Here is what git add really does, and why it exists.

How to Run Apache in Docker

Apache httpd in a Docker container: serve static files, mount a custom httpd.conf, enable mod_rewrite for .htaccess, and the patterns that come up most often (PHP, reverse proxy, virtual hosts).