How to Deploy Code with Git: Bare Repos, Post-Receive Hooks, and Simple CI/CD for Your Homelab

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:

  1. A bare Git repository on your server (no working directory, just the Git data)
  2. A post-receive hook that fires after every push
  3. A checkout directory where your live code lives
  4. 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_DIR and TREE tell Git where the repo is and where to put the files
  • checkout -f main forcefully checks out the main branch 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 curl call 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

Your email address will not be published. Required fields are marked *