Tutorial

Git Deployment: Push to Deploy with Webhooks and Bare Repos

May 27, 2026

Back to Blog
Managing servers the hard way? Panelica gives you isolated hosting, built-in Docker and AI-assisted management.
Start free

Why Push-to-Deploy Changes Everything

The traditional deployment workflow looks like this: you finish a feature, SSH into your server, navigate to the project directory, run git pull, install dependencies, build assets, restart services, and pray nothing breaks. Every step is manual, error-prone, and undocumented. Miss a dependency install and the site crashes. Forget to restart the queue worker and background jobs pile up. Run the deploy on the wrong branch and you push untested code to production.

Push-to-deploy eliminates all of this. You push code to a Git branch, and the server automatically pulls, builds, and deploys. Every deployment is consistent, documented (it is a Git commit), and reversible. You can deploy from your IDE, from your phone, or from a CI/CD pipeline — the server does not care where the push came from, only that it happened.

This guide covers two approaches: Git bare repositories with post-receive hooks (the simplest and most self-contained method) and GitHub/GitLab webhook-based deployment (more flexible and better for team environments). Both achieve the same result, and you can set up either one in under 30 minutes.

Approach 1: Git Bare Repos with Post-Receive Hooks

This approach uses Git's built-in hook system. You create a bare repository on your server, configure a post-receive hook that runs after each push, and the hook checks out the code to your web root. No external services, no webhooks, no dependencies beyond Git itself.

Developer
git push deploy
Bare Repo
/opt/repos/myapp.git
Post-Receive Hook
checkout + build
Web Root
/var/www/myapp

Step 1: Create the Bare Repository

A bare repository is a Git repository without a working directory — it stores the Git data directly without checking out files. This is the standard format for server-side repositories that receive pushes.

# Create the bare repository directory
mkdir -p /opt/repos/myapp.git
cd /opt/repos/myapp.git
git init --bare

Initialized empty Git repository in /opt/repos/myapp.git/

# Create the web root (deployment target)
mkdir -p /var/www/myapp

Step 2: Write the Post-Receive Hook

The post-receive hook runs on the server after a push is received. This is where you put your deployment logic:

#!/bin/bash
# /opt/repos/myapp.git/hooks/post-receive

TARGET="/var/www/myapp"
GIT_DIR="/opt/repos/myapp.git"
BRANCH="main"

while read oldrev newrev ref; do
# Only deploy when main branch is pushed
if [ "$ref" = "refs/heads/$BRANCH" ]; then
echo "Deploying $BRANCH to $TARGET..."

# Checkout the code
git --work-tree="$TARGET" --git-dir="$GIT_DIR" checkout -f "$BRANCH"

# Run build steps
cd "$TARGET"

# Install PHP dependencies
if [ -f "composer.json" ]; then
composer install --no-dev --optimize-autoloader 2>&1
fi

# Install Node.js dependencies and build
if [ -f "package.json" ]; then
npm ci --production 2>&1
npm run build 2>&1
fi

# Clear cache (Laravel example)
if [ -f "artisan" ]; then
php artisan config:cache
php artisan route:cache
php artisan view:cache
fi

echo "Deployment complete: $(date)"
else
echo "Ref $ref is not $BRANCH, skipping deploy."
fi
done
# Make the hook executable
chmod +x /opt/repos/myapp.git/hooks/post-receive

Step 3: Add the Server as a Git Remote

On your local development machine, add the server as a Git remote:

# Add the deployment remote
git remote add deploy ssh://[email protected]/opt/repos/myapp.git

# Push to deploy
git push deploy main

Counting objects: 15, done.
Compressing objects: 100% (12/12), done.
Writing objects: 100% (15/15), 4.23 KiB, done.
remote: Deploying main to /var/www/myapp...
remote: Installing dependencies...
remote: Building assets...
remote: Deployment complete: Mon Mar 17 12:00:00 UTC 2026
Workflow tip: Keep your origin remote pointed at GitHub/GitLab for collaboration, and use the deploy remote purely for deployment: git push origin main && git push deploy main. Some teams create a Git alias: git config alias.ship "!git push origin main && git push deploy main"

Approach 2: GitHub/GitLab Webhook Deployment

The webhook approach uses GitHub or GitLab to notify your server when code is pushed. Your server runs a small webhook receiver that triggers the deployment. This is better for team environments because it integrates with your existing Git hosting and supports branch-based deployment.

Developer
git push origin
GitHub/GitLab
Webhook POST
to your server
Deploy Script
pull + build

PHP Webhook Receiver

// deploy-webhook.php
<?php
$secret = getenv('DEPLOY_SECRET');
$deployScript = '/opt/scripts/deploy.sh';
$logFile = '/var/log/deploy/webhook.log';
$targetBranch = 'main';

// Verify request method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}

// Read and verify payload
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$computed = 'sha256=' . hash_hmac('sha256', $payload, $secret);

if (!hash_equals($computed, $signature)) {
http_response_code(401);
file_put_contents($logFile, date('c') . " REJECTED: Invalid signature\n", FILE_APPEND);
exit;
}

// Parse event data
$data = json_decode($payload, true);
$ref = $data['ref'] ?? '';
$branch = str_replace('refs/heads/', '', $ref);

// Only deploy target branch
if ($branch !== $targetBranch) {
http_response_code(200);
echo "Skipping: push to $branch";
exit;
}

// Trigger deployment asynchronously
$cmd = sprintf('nohup %s >> %s 2>&1 &', $deployScript, $logFile);
exec($cmd);

http_response_code(200);
echo "Deployment triggered for $branch";

The Deployment Script

#!/bin/bash
# /opt/scripts/deploy.sh
set -e

APP_DIR="/var/www/myapp"
BRANCH="main"
LOG="/var/log/deploy/deploy.log"

echo "=== Deployment started: $(date) ===" >> "$LOG"

# Navigate to application directory
cd "$APP_DIR"

# Enable maintenance mode (if applicable)
if [ -f "artisan" ]; then
php artisan down --retry=30
fi

# Pull latest changes
git fetch origin "$BRANCH" >> "$LOG" 2>&1
git reset --hard "origin/$BRANCH" >> "$LOG" 2>&1

# Install dependencies
if [ -f "composer.json" ]; then
composer install --no-dev --no-interaction --optimize-autoloader >> "$LOG" 2>&1
fi

if [ -f "package.json" ]; then
npm ci >> "$LOG" 2>&1
npm run build >> "$LOG" 2>&1
fi

# Run migrations
if [ -f "artisan" ]; then
php artisan migrate --force >> "$LOG" 2>&1
php artisan config:cache >> "$LOG" 2>&1
php artisan route:cache >> "$LOG" 2>&1
php artisan view:cache >> "$LOG" 2>&1
php artisan up
fi

# Restart services if needed
# systemctl restart php-fpm
# systemctl reload nginx

echo "=== Deployment complete: $(date) ===" >> "$LOG"

Branch-Based Deployment

A powerful pattern is deploying different branches to different environments. Pushing to main deploys to production, pushing to develop deploys to staging:

#!/bin/bash
# post-receive hook with branch routing

while read oldrev newrev ref; do
BRANCH=$(echo "$ref" | sed 's/refs\/heads\///')

case "$BRANCH" in
main)
echo "Deploying to PRODUCTION..."
TARGET="/var/www/myapp-production"
;;
develop)
echo "Deploying to STAGING..."
TARGET="/var/www/myapp-staging"
;;
*)
echo "No deployment configured for branch: $BRANCH"
exit 0
;;
esac

git --work-tree="$TARGET" --git-dir="$(pwd)" checkout -f "$BRANCH"
cd "$TARGET" && bash deploy.sh
echo "Deployed $BRANCH to $TARGET at $(date)"
done
BranchEnvironmentURLPurpose
mainProductionapp.example.comLive users
developStagingstaging.example.comQA testing
feature/*Previewpr-123.example.comFeature review

Rollback Strategy

Every deployment system needs a fast rollback path. With Git-based deployment, rollbacks are trivial:

Quick Rollback to Previous Commit

# On the server — immediate rollback
cd /var/www/myapp
git log --oneline -5 # find the commit to revert to
git checkout abc1234 # checkout the known-good commit

# Or revert the last push
git reset --hard HEAD~1

Tag-Based Rollback

A more robust approach is tagging each deployment. This gives you named rollback points:

# In your post-receive hook, tag each deployment
DEPLOY_TAG="deploy-$(date +%Y%m%d-%H%M%S)"
git tag "$DEPLOY_TAG" "$newrev"
echo "Tagged deployment: $DEPLOY_TAG"

# To rollback: find and checkout the previous tag
git tag -l 'deploy-*' | sort -r | head -5
deploy-20260317-120000
deploy-20260316-150000
deploy-20260315-093000

git checkout deploy-20260316-150000

Deployment Notifications

Your team should know when deployments happen, whether they succeeded or failed. Add notifications to your deploy script:

# Slack notification function
notify_slack() {
local status="$1"
local message="$2"
local color="good"
[ "$status" = "failed" ] && color="danger"

curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d "{\"attachments\":[{\"color\":\"$color\",\"text\":\"$message\"}]}"
}

# Use in deploy script
if git pull origin main; then
npm run build && notify_slack "success" "Deployed to production: $(git log -1 --format='%s')"
else
notify_slack "failed" "Deployment FAILED at $(date)"
fi

Security Considerations

Webhook Security

  • Always verify HMAC signatures
  • Use HTTPS for webhook endpoints
  • Store secrets in environment variables
  • IP whitelist GitHub/GitLab webhook IPs

Deploy Script Security

  • Run deploy as limited user, not root
  • Restrict deploy script permissions (750)
  • Validate branch names against injection
  • Never execute user-supplied code in hooks

Repository Security

  • Use SSH keys for server authentication
  • Never store secrets in Git repos
  • Use .env files excluded from Git
  • Review deploy diffs before merging to main

Access Control

  • Protect production branches with rules
  • Require PR reviews before merging
  • Limit who can push to deploy remote
  • Audit deploy logs regularly
Never store database passwords, API keys, or secrets in your Git repository. Use .env files that are excluded via .gitignore. Your deploy script should never overwrite the production .env file. If you need to add new environment variables, document them in .env.example and add them manually on the server.

Git Deployment vs. CI/CD Pipelines

Git-based deployment is simple and effective, but it has limits. Here is how it compares to full CI/CD solutions:

FeatureGit Push-to-DeployCI/CD Pipeline
ComplexityMinimalModerate to High
Setup time30 minutesHours to days
External dependenciesNone (Git only)CI service required
Automated testingManual additionBuilt-in
Multi-environmentBranch-basedFull pipeline stages
RollbackGit checkoutPipeline re-run
Docker/container supportNot built-inNative
Audit trailGit log + deploy logFull pipeline history
Best forSmall teams, simple appsLarge teams, complex apps
Practical advice: Start with Git push-to-deploy. It works perfectly for single-server applications. When you outgrow it — when you need automated testing, multi-stage deployments, or container orchestration — migrate to a CI/CD pipeline. The deploy script you write today can often be reused as a CI/CD step.

Troubleshooting Common Issues

Permission Denied on Checkout

Cause: The Git user does not own the web root directory.

Fix: chown -R deployuser:www-data /var/www/myapp

Hook Not Executing

Cause: Hook file is not executable.

Fix: chmod +x hooks/post-receive

npm/composer Not Found in Hook

Cause: PATH is minimal in Git hook context.

Fix: Use full paths: /usr/local/bin/npm

Changes Not Showing

Cause: PHP opcache or CDN cache serving old files.

Fix: Add opcache_reset() to deploy or restart PHP-FPM.

Push-to-Deploy with Panelica

Panelica's webhook system can trigger custom scripts on push events. Combined with the panel's domain management and file system, you can build a complete push-to-deploy pipeline. Set up a webhook endpoint in Panelica, point GitHub or GitLab at it, and let the panel handle deployment script execution, file permissions, and service restarts — all within the panel's security model.

Complete Production-Ready Hook

Here is a complete post-receive hook that covers all the bases: branch filtering, error handling, deployment tagging, notifications, and logging:

#!/bin/bash
# Production post-receive hook
set -e

TARGET="/var/www/myapp"
LOG="/var/log/deploy/deploy.log"
DEPLOY_BRANCH="main"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG"; }

while read oldrev newrev ref; do
BRANCH="${ref#refs/heads/}"
[ "$BRANCH" != "$DEPLOY_BRANCH" ] && continue

log "START: Deploying $BRANCH ($newrev)"

# Checkout
git --work-tree="$TARGET" checkout -f "$BRANCH" >> "$LOG" 2>&1
log "Checkout complete"

# Build
cd "$TARGET"
[ -f composer.json ] && /usr/local/bin/composer install --no-dev -q >> "$LOG" 2>&1
[ -f package.json ] && /usr/local/bin/npm ci --silent >> "$LOG" 2>&1
[ -f package.json ] && /usr/local/bin/npm run build >> "$LOG" 2>&1

# Tag
git tag "deploy-$(date +%Y%m%d-%H%M%S)" "$newrev"

log "END: Deployment successful"
done

Summary

Push-to-deploy eliminates the error-prone manual deployment process and replaces it with a consistent, reproducible, Git-based workflow. The bare repository approach is ideal for simple setups — it requires nothing beyond Git and a shell script. The webhook approach integrates with GitHub and GitLab for team environments with branch protection and PR reviews.

Regardless of which approach you choose, the core principles are the same: deploy only from specific branches, make deployments atomic and reversible, log everything, notify your team, and never store secrets in your repository. Start simple with a basic post-receive hook, and evolve your deployment pipeline as your application and team grow. The deploy script you write today is the foundation of your deployment infrastructure tomorrow.

Security-first hosting panel

Run your servers on a modern panel.

Panelica is a modern, security-first hosting panel — isolated services, built-in Docker and AI-assisted management, with one-click migration from any panel.

Zero-downtime migration Fully isolated services Cancel anytime
Share:
No add-on tax.