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:
mkdir -p /srv/git/myapp.git
cd /srv/git/myapp.git
git init --baregit 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:
mkdir -p /var/www/myappStep 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:
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-receiveThe 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 pipefailmakes the script fail loudly. Without it, a failing command mid-deploy is silently ignored and you ship a half-broken state.post-receivereceives one line per pushed ref on stdin, formattedoldrev newrev refname. Thewhile readloop 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; onlymaintriggers a deploy. Drop the filter if you want every branch to deploy, but you almost never do. Sincemainis 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 -fis the heart of it. It forces the working tree at/var/www/myappto 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:
git remote add production user@your-server.com:/srv/git/myapp.gitThat 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:
git push production mainIf 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 hook | GitHub Actions | |
|---|---|---|
| Where deploy runs | On your server, triggered by your push | On the vendor's runners, triggered by a push to GitHub |
| Dependencies | SSH access to the box, a bare repo | A GitHub repo and a workflow file |
| Build step | You run it in the hook (or skip it) | Runs on the runner before deploy |
| Cost / vendor | None beyond your server | Free tier, then metered |
| Secrets handling | Server env / files you manage | Encrypted repo secrets |
| Best for | A single VPS you control, simple sites | Multi-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:
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-receiveexactly (no.sh, no.sample) andchmod +x. Confirm withls -l hooks/post-receiveand look for thexbits. bad configor wrong shell. The shebang must point at a real shell on the server.#!/usr/bin/env bashis 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_BRANCHfilter is probably rejecting the branch you pushed. Add ate_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.
- Pro Git: Setting Up the Server (bare repos over SSH)git-scm.com
- git githooks documentation (post-receive)git-scm.com
- git init documentation (--bare)git-scm.com





