Why Git Can Do More Than You Think
You probably use Git every day to track your code, push to GitHub, and maybe open the occasional pull request. But Git has a superpower that most developers never explore: it can deploy your code for you.
No Docker Compose files. No CI/CD platforms. No webhooks from third-party services. Just a bare repository on your server and a tiny shell script that runs every time you push. It’s elegant, it’s fast, and once it’s set up, it just works.
In this tutorial, we’ll build a complete Git-based deployment pipeline from scratch. By the end, you’ll be able to git push production main from your laptop and watch your server automatically pull down the new code, install dependencies, run tests, and restart your service.
What We’re Building
Here’s the architecture:
- A bare Git repository on your server (no working directory, just the Git data)
- A post-receive hook that fires after every push
- A checkout directory where your live code lives
- Optional: automated tests before deployment, with rollback on failure
This pattern works for any language or framework — Python, Node.js, Go, static sites, whatever. The hook is just a shell script, so you have full control.
Prerequisites
- A Linux server (VPS, Raspberry Pi, old laptop — anything works)
- SSH access to that server
- Git installed on both your local machine and the server
- A project you want to deploy (we’ll use a simple example)
- Basic comfort with the command line
Step 1: Create the Bare Repository on Your Server
SSH into your server and create a directory for your Git repositories:
ssh user@your-server
mkdir -p ~/git
cd ~/git
git init --bare myproject.git
The --bare flag is key here. A bare repo has no working directory — it only contains the Git data (objects, refs, hooks). You can’t edit files directly in it, but you can push to it. This makes it perfect as a central receiving point.
Your bare repo lives at ~/git/myproject.git/ and contains the usual Git internals:
ls ~/git/myproject.git/
# branches config description HEAD hooks info objects refs
Step 2: Create the Live Checkout Directory
This is where your actual deployed code will live — the directory your web server or process manager points to:
mkdir -p ~/sites/myproject
That’s it for now. The hook will populate this directory on the first push.
Step 3: Write the Post-Receive Hook
This is where the magic happens. Create the hook file:
cat > ~/git/myproject.git/hooks/post-receive < 'EOF'
#!/bin/bash
# Configuration
GIT_DIR="$HOME/git/myproject.git"
WORK_TREE="$HOME/sites/myproject"
LOG_FILE="$HOME/logs/myproject-deploy.log"
# Ensure log directory exists
mkdir -p "$HOME/logs"
echo "===== $(date) =====" >> "$LOG_FILE"
echo "Received push, deploying..." >> "$LOG_FILE"
# Checkout the latest code into the working tree
git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" checkout -f main 2>&1 >> "$LOG_FILE"
echo "Code checked out to $WORK_TREE" >> "$LOG_FILE"
# --- Customize everything below for your project ---
# Example: Install Python dependencies
# cd "$WORK_TREE" && pip install -r requirements.txt 2>&1 >> "$LOG_FILE"
# Example: Install Node.js dependencies
# cd "$WORK_TREE" && npm install --production 2>&1 >> "$LOG_FILE"
# Example: Run database migrations
# cd "$WORK_TREE" && python manage.py migrate 2>&1 >> "$LOG_FILE"
# Example: Restart a systemd service
# systemctl --user restart myproject 2>&1 >> "$LOG_FILE"
# Example: Restart with PM2
# cd "$WORK_TREE" && pm2 restart myproject 2>&1 >> "$LOG_FILE"
echo "Deployment complete!" >> "$LOG_FILE"
echo "" >> "$LOG_FILE"
EOF
chmod +x ~/git/myproject.git/hooks/post-receive
Let’s break down what this hook does:
GIT_DIRandTREEtell Git where the repo is and where to put the filescheckout -f mainforcefully checks out themainbranch into the working tree- Everything after the checkout is your custom deployment logic
- All output is logged so you can debug later
Step 4: Add the Remote and Push from Your Local Machine
On your local machine, in your project directory:
# Add the server as a remote
git remote add production user@your-server:~/git/myproject.git
# Push your code
git push production main
On the first push, you’ll see the hook fire in real time. If everything works, your code now lives at ~/sites/myproject/ on the server.
From now on, every git push production main triggers a fresh deployment. It’s that simple.
Step 5: Add a Safety Net — Test Before Deploy
Blindly deploying every push is fine for personal projects, but let’s add a basic test gate. If tests fail, we skip the deployment and leave the old code running:
cat > ~/git/myproject.git/hooks/post-receive < 'EOF'
#!/bin/bash
GIT_DIR="$HOME/git/myproject.git"
WORK_TREE="$HOME/sites/myproject"
LOG_FILE="$HOME/logs/myproject-deploy.log"
BACKUP_DIR="$HOME/sites/myproject-backup"
mkdir -p "$HOME/logs"
echo "===== $(date) =====" >> "$LOG_FILE"
# Read the pushed ref
while read oldrev newrev refname; do
BRANCH=$(echo "$refname" | sed 's|refs/heads/||')
if [ "$BRANCH" != "main" ]; then
echo "Ignoring push to $BRANCH (only deploys main)" >> "$LOG_FILE"
continue
fi
echo "Deploying branch: $BRANCH (commit: $newrev)" >> "$LOG_FILE"
# Stash any local changes and checkout new code
mkdir -p "$BACKUP_DIR"
cp -r "$WORK_TREE" "$BACKUP_DIR/$(date +%Y%m%d-%H%M%S)" 2>/dev/null
git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" checkout -f "$BRANCH" 2>&1 >> "$LOG_FILE"
# Run tests (customize for your project)
cd "$WORK_TREE"
# Example: Python project with pytest
# if ! python -m pytest tests/ -q 2>&1 >> "$LOG_FILE"; then
# echo "TESTS FAILED! Rolling back..." >> "$LOG_FILE"
# LATEST_BACKUP=$(ls -t "$BACKUP_DIR" | head -1)
# rm -rf "$WORK_TREE"
# cp -r "$BACKUP_DIR/$LATEST_BACKUP" "$WORK_TREE"
# echo "Rolled back to $LATEST_BACKUP" >> "$LOG_FILE"
# exit 1
# fi
# Example: Node.js project
# if ! npm test 2>&1 >> "$LOG_FILE"; then
# echo "TESTS FAILED! Rolling back..." >> "$LOG_FILE"
# exit 1
# fi
echo "Deployment successful!" >> "$LOG_FILE"
done
echo "" >> "$LOG_FILE"
EOF
chmod +x ~/git/myproject.git/hooks/post-receive
Now if tests fail, the hook exits with an error and your old code stays in place. The client sees the push “succeeds” (Git doesn’t care about hook exit codes by default), but your server keeps running the last known-good version.
Step 6: Deploy Multiple Branches (Staging + Production)
You can extend this pattern to deploy different branches to different directories:
cat > ~/git/myproject.git/hooks/post-receive < 'EOF'
#!/bin/bash
GIT_DIR="$HOME/git/myproject.git"
LOG_FILE="$HOME/logs/myproject-deploy.log"
mkdir -p "$HOME/logs"
while read oldrev newrev refname; do
BRANCH=$(echo "$refname" | sed 's|refs/heads/||')
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
case "$BRANCH" in
main)
WORK_TREE="$HOME/sites/myproject-production"
echo "[$TIMESTAMP] Deploying PRODUCTION from $BRANCH" >> "$LOG_FILE"
;;
staging)
WORK_TREE="$HOME/sites/myproject-staging"
echo "[$TIMESTAMP] Deploying STAGING from $BRANCH" >> "$LOG_FILE"
;;
*)
echo "[$TIMESTAMP] Ignoring push to $BRANCH" >> "$LOG_FILE"
continue
;;
esac
mkdir -p "$WORK_TREE"
git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" checkout -f "$BRANCH" 2>&1 >> "$LOG_FILE"
# Run branch-specific deployment steps
cd "$WORK_TREE"
if [ "$BRANCH" = "main" ]; then
# Production-specific steps
systemctl --user restart myproject 2>&1 >> "$LOG_FILE"
fi
echo "[$TIMESTAMP] Done: $BRANCH -> $WORK_TREE" >> "$LOG_FILE"
done
EOF
chmod +x ~/git/myproject.git/hooks/post-receive
Now git push production staging deploys to your staging environment, and git push production main goes to production. Same repo, same hook, different behavior based on branch name.
Common Pitfalls
Permission Denied on Hook Execution
Make sure the hook is executable (chmod +x) and owned by the user that Git runs as. If you’re pushing over SSH as deploy, the hook runs as deploy.
Files Not Updating After Push
Check that GIT_DIR and WORK_TREE paths are absolute. Relative paths in hooks are unreliable because the working directory isn’t what you expect.
Untracked Files Persisting
checkout -f updates tracked files but doesn’t remove files that were deleted in the new commit. Add this before the checkout if you need a clean slate:
git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" clean -fd
Secrets in the Working Tree
Never commit .env files or secrets to your repo. Instead, keep them in the working tree separately and have your hook symlink them in:
ln -sf "$HOME/secrets/myproject.env" "$WORK_TREE/.env"
Taking It Further
This pattern scales surprisingly far. Here are some ideas:
- Slack/Discord notifications: Add a
curlcall at the end of your hook to post deployment status to a webhook - Health checks: After restarting your service, hit an HTTP endpoint to verify it’s responding before declaring success
- Blue-green deployment: Maintain two directories and swap a symlink for zero-downtime deploys
- Multiple servers: Push to a central bare repo, then use the hook to trigger deploys on multiple machines via SSH
Why This Beats (Simple) CI/CD Platforms
For personal projects and homelabs, this approach has real advantages:
- No external dependencies: Everything runs on your server, no third-party service needed
- Full control: It’s just a shell script. Add, remove, or change anything
- Fast: No queue, no container build, no waiting. Push and it’s live in seconds
- Private: Your code never leaves your infrastructure
- Free: No per-minute charges, no build minutes, no limits
Is it as feature-rich as GitHub Actions or GitLab CI? No. But for deploying a personal project, a homelab service, or a small team’s internal tool, it’s often exactly what you need — nothing more, nothing less.
Sometimes the best deployment pipeline is the one you can understand entirely, debug in five minutes, and never has to worry about going down because someone else’s cloud had an outage.
Give it a try. Your future self will thank you the next time you need to push a fix at 2 AM and just want it to work.
Leave a Reply