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.
git push deploy
/opt/repos/myapp.git
checkout + build
/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.
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:
# /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
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:
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
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.
git push origin
to your server
pull + build
PHP Webhook Receiver
<?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
# /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:
# 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
| Branch | Environment | URL | Purpose |
|---|---|---|---|
main | Production | app.example.com | Live users |
develop | Staging | staging.example.com | QA testing |
feature/* | Preview | pr-123.example.com | Feature review |
Rollback Strategy
Every deployment system needs a fast rollback path. With Git-based deployment, rollbacks are trivial:
Quick Rollback to Previous Commit
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:
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:
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
.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:
| Feature | Git Push-to-Deploy | CI/CD Pipeline |
|---|---|---|
| Complexity | Minimal | Moderate to High |
| Setup time | 30 minutes | Hours to days |
| External dependencies | None (Git only) | CI service required |
| Automated testing | Manual addition | Built-in |
| Multi-environment | Branch-based | Full pipeline stages |
| Rollback | Git checkout | Pipeline re-run |
| Docker/container support | Not built-in | Native |
| Audit trail | Git log + deploy log | Full pipeline history |
| Best for | Small teams, simple apps | Large teams, complex apps |
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
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:
# 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.