Why Server Admins Need Git
Git was built for developers. But if you manage servers, you probably deal with the exact same problems developers have: you change a config file, something breaks, and you have no idea what changed or when. You fix it by guessing. You overwrite the file you should have kept. You have three slightly different versions of the same nginx config across four servers and no way to know which one is "correct."
That is a version control problem. And Git solves it.
Server admins who use Git get:
- Full history — Every change to every config file, with author, timestamp, and a message explaining why
- Instant rollback — One command to go back to any previous state
- Diff visibility — See exactly what changed before you apply anything
- Collaboration — Multiple admins working on the same infrastructure without stepping on each other
- Automated deployment — Push code, watch it deploy — no manual SSH, no FTP, no "I forgot to upload the file"
This guide covers Git from the ground up — not as a developer tool, but as an infrastructure tool. We'll go from basic commands to production deployment pipelines, secrets management, and rollback strategies.
Git Basics for Server Admins
If you already know the basics, skip ahead. If not, here's what you need to know to get started.
The Core Concepts
Git tracks changes to files over time. Every set of changes is a commit — a snapshot of your files at a specific point. Commits chain together into a history. You can move forward, backward, or branch off in any direction.
The three areas to understand:
- Working directory — Your actual files on disk
- Staging area (index) — Files you have marked as "ready to commit"
- Repository (.git/) — The database of all commits and history
Essential Commands
| Command | What It Does |
|---|---|
git init | Initialize a new repository in the current directory |
git clone <url> | Copy an existing repository (remote or local) |
git status | Show which files have changed, staged, or are untracked |
git add <file> | Stage a file (prepare it for commit) |
git add -A | Stage all changes (new, modified, deleted) |
git commit -m "message" | Save staged changes as a new commit |
git log | Show commit history |
git log --oneline | Compact one-line history |
git diff | Show unstaged changes |
git diff --staged | Show staged changes (what will be committed) |
git push origin main | Upload commits to remote |
git pull | Download and merge remote commits |
git fetch | Download remote commits without merging |
Your First Repository
# Initialize a new repo
mkdir myproject && cd myproject
git init
# Create a file and make the first commit
echo "# My Project" > README.md
git add README.md
git commit -m "Initial commit"
# Connect to a remote (GitHub, GitLab, etc.)
git remote add origin https://github.com/yourusername/myproject.git
git push -u origin main
Branching Basics
Branches let you work on changes in isolation without touching the main (stable) version.
# Create and switch to a new branch
git checkout -b feature/new-nginx-config
# Make changes, commit them
git add nginx.conf
git commit -m "add HTTP/2 push support"
# Merge back to main when done
git checkout main
git merge feature/new-nginx-config
# Delete the branch
git branch -d feature/new-nginx-config
A good branch naming convention for server configs:
feature/— New configuration or featurefix/— Fixing a broken confighotfix/— Emergency production fixtest/— Experimental changes, may be discarded
Version Control for Server Configs
Tracking /etc/ with etckeeper
The most practical tool for server admins is etckeeper — it automatically puts /etc/ under Git version control and commits before and after every package installation.
# Install etckeeper
apt install etckeeper
# It auto-initializes /etc/ as a git repo
# Check what it is already tracking
cd /etc && git log --oneline
# See what changed recently
git diff HEAD~1
etckeeper commits automatically when you run apt install, apt upgrade, or dpkg. You get a full audit trail of every config file change on your system.
Tracking Specific Config Files
For service-specific configs (nginx, PHP, application configs), create a dedicated repository:
# Create a server-configs repository
mkdir -p ~/server-configs/nginx
mkdir -p ~/server-configs/php
mkdir -p ~/server-configs/mysql
# Copy configs
cp /etc/nginx/nginx.conf ~/server-configs/nginx/
cp /etc/nginx/sites-available/mysite ~/server-configs/nginx/
cd ~/server-configs
git init
git add .
git commit -m "initial config snapshot"
What NOT to Commit
This is critical. Some files must never enter a Git repository:
- Private SSL/TLS certificates (
.key,.pemprivate keys) - Environment files (
.env,.env.production) - Database passwords, API keys, tokens
- SSH private keys
- Files with embedded credentials (some CMS config files)
- Large binary files, database dumps
.gitignore for Server Files
Create a .gitignore file in your repository root to exclude sensitive and unnecessary files:
# SSL certificates and private keys
*.key
*.pem
*.p12
*.pfx
ssl/
certs/
# Environment and secrets
.env
.env.*
secrets/
credentials/
# Database files
*.sql
*.dump
*.sqlite
*.db
# Logs
*.log
logs/
# PHP application secrets
wp-config.php
config/database.php
# Composer/npm dependencies (large, reproducible)
vendor/
node_modules/
# Cache and temp
*.cache
tmp/
temp/
# OS files
.DS_Store
Thumbs.db
Rule of thumb: If it contains a password, a private key, or data you would not post publicly, it does not belong in Git. Even in a private repository — because repositories get forked, copied, and accidentally made public.
Git-Based Deployment
This is where Git goes from useful to indispensable. Instead of manually uploading files via FTP or SCP, you push code and it deploys automatically. Three main methods, from simplest to most sophisticated.
Method 1: Git Pull Deploy (Simplest)
The server has a clone of your repository. You SSH in and run git pull. Simple, manual, but already better than FTP.
# On the server — initial setup
cd /var/www/myapp
git clone https://github.com/yourusername/myapp.git .
# Deploy new version
cd /var/www/myapp
git pull origin main
systemctl reload php8.3-fpm
systemctl reload nginx
Method 2: Bare Repository with post-receive Hook
A bare repository is a Git repo without a working directory — it stores only the history. You push to the bare repo on your server, and a hook script automatically deploys. This is the "push to deploy" pattern, and it is clean and reliable.
Step 1: Create the bare repository on the server
mkdir -p /opt/repos/myapp.git
cd /opt/repos/myapp.git
git init --bare
Step 2: Create the post-receive hook
cat > /opt/repos/myapp.git/hooks/post-receive << 'EOF'
#!/bin/bash
TARGET="/var/www/myapp"
GIT_DIR="/opt/repos/myapp.git"
BRANCH="main"
while read oldrev newrev ref; do
BRANCH_NAME=$(git --git-dir="$GIT_DIR" rev-parse --abbrev-ref "$ref")
if [ "$BRANCH_NAME" = "$BRANCH" ]; then
echo "Deploying branch: $BRANCH"
git --work-tree="$TARGET" --git-dir="$GIT_DIR" checkout -f "$BRANCH"
cd "$TARGET"
if [ -f composer.json ]; then
/usr/local/bin/php /usr/local/bin/composer install --no-dev --optimize-autoloader
fi
if [ -f package.json ]; then
npm ci --production
fi
systemctl reload nginx
systemctl reload php8.3-fpm
echo "Deployment complete: $(date)"
fi
done
EOF
chmod +x /opt/repos/myapp.git/hooks/post-receive
Step 3: Add the server as a Git remote
# On your local machine
git remote add production root@yourserver:/opt/repos/myapp.git
# Deploy by pushing to main
git push production main
Every push to production automatically checks out the latest code and runs your deploy steps. No SSH session required.
Method 3: GitHub/GitLab Webhooks
For teams using GitHub or GitLab, webhooks are the professional approach. When you push to a branch, GitHub sends an HTTP POST to your server, which triggers a deploy script.
Simple webhook receiver in Python:
# /opt/scripts/webhook_deploy.py
import hmac, hashlib, subprocess, json
from http.server import HTTPServer, BaseHTTPRequestHandler
WEBHOOK_SECRET = b"your-secret-key-here"
DEPLOY_SCRIPT = "/opt/scripts/deploy.sh"
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers['Content-Length'])
body = self.rfile.read(length)
signature = self.headers.get('X-Hub-Signature-256', '')
expected = 'sha256=' + hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
self.send_response(403)
self.end_headers()
return
payload = json.loads(body)
if payload.get('ref') == 'refs/heads/main':
subprocess.Popen([DEPLOY_SCRIPT])
self.send_response(200)
self.end_headers()
def log_message(self, format, *args):
pass
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', 9000), WebhookHandler)
print("Webhook server running on port 9000")
server.serve_forever()
# /opt/scripts/deploy.sh
#!/bin/bash
set -e
APP_DIR="/var/www/myapp"
LOG_FILE="/var/log/deploy.log"
echo "$(date): Deploy started" >> "$LOG_FILE"
cd "$APP_DIR"
git pull origin main >> "$LOG_FILE" 2>&1
php composer install --no-dev --optimize-autoloader >> "$LOG_FILE" 2>&1
php artisan migrate --force >> "$LOG_FILE" 2>&1
php artisan config:cache >> "$LOG_FILE" 2>&1
systemctl reload php8.3-fpm
systemctl reload nginx
echo "$(date): Deploy completed" >> "$LOG_FILE"
In GitHub, go to Settings → Webhooks → Add webhook. Set Payload URL to http://yourserver:9000, Content type to application/json, and set a secret. That same secret goes in WEBHOOK_SECRET in your script.
Security note: Always verify the webhook signature. Without it, anyone who knows your webhook URL can trigger a deployment. Never skip this check.
Git Hooks for Automation
Git hooks are scripts that run automatically at specific points in the Git workflow. They live in .git/hooks/ and are just shell scripts (or any executable). They are powerful for automation and enforcement.
| Hook | When It Runs | Use For |
|---|---|---|
pre-commit | Before a commit is created | Lint checks, secret detection, tests |
commit-msg | After commit message is written | Enforce message format |
pre-push | Before pushing to remote | Run tests, block pushes to main |
post-receive | After receiving a push (server-side) | Deploy, notify, restart services |
post-merge | After a successful merge | Install dependencies, rebuild |
pre-commit: Prevent Accidental Secret Commits
#!/bin/bash
# .git/hooks/pre-commit
echo "Scanning for secrets..."
PATTERNS=(
"password\s*=\s*['\"][^'\"]{8,}"
"api_key\s*=\s*['\"][^'\"]+"
"secret\s*=\s*['\"][^'\"]+"
"private_key"
"BEGIN RSA PRIVATE KEY"
"BEGIN OPENSSH PRIVATE KEY"
"AWS_SECRET_ACCESS_KEY"
)
for pattern in "${PATTERNS[@]}"; do
if git diff --cached --name-only | xargs grep -lP "$pattern" 2>/dev/null; then
echo "ERROR: Possible secret found matching: $pattern"
echo "Remove the secret before committing."
exit 1
fi
done
echo "No secrets detected."
exit 0
chmod +x .git/hooks/pre-commit
commit-msg: Enforce Commit Message Format
#!/bin/bash
# .git/hooks/commit-msg
MSG=$(cat "$1")
PATTERN="^(feat|fix|docs|style|refactor|test|chore|security|config|deploy)(\(.+\))?: .{10,}"
if ! echo "$MSG" | grep -qP "$PATTERN"; then
echo "ERROR: Commit message format invalid."
echo "Required: type(scope): description (min 10 chars)"
echo "Example: fix(nginx): increase worker_connections for high traffic"
exit 1
fi
exit 0
pre-push: Run Tests Before Pushing
#!/bin/bash
# .git/hooks/pre-push
if [ -f "phpunit.xml" ]; then
php vendor/bin/phpunit --no-coverage
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
fi
if [ -f "package.json" ] && grep -q '"test"' package.json; then
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
fi
exit 0
Hooks are not shared by default when you clone a repository. Keep them in a hooks/ directory in the repo and add a setup script:
# setup.sh
cp hooks/* .git/hooks/
chmod +x .git/hooks/*
echo "Git hooks installed."
Managing Secrets
The most common Git mistake in server administration is committing secrets. A password committed once — even if you remove it in the next commit — is forever in your history unless you do a destructive rewrite. GitHub secret scanning will flag it. Attackers scan public repos for exposed credentials.
The Golden Rule
Never commit credentials. Use environment variables or encrypted secret stores instead.
# Bad: hardcoded in config
DB_PASSWORD="supersecret123"
# Good: environment variable
DB_PASSWORD="${DB_PASSWORD}"
# In production, set via systemd service file
[Service]
Environment=DB_PASSWORD=supersecret123
# Or via .env file that is in .gitignore
DB_PASSWORD=supersecret123
git-crypt: Transparent File Encryption
git-crypt encrypts specific files in the repository. When you push, they are encrypted. When you pull with the key, they are transparently decrypted.
# Install
apt install git-crypt
# Initialize in repo
cd myrepo
git-crypt init
# Export the key (store securely)
git-crypt export-key /secure/location/myrepo.key
# Mark files to encrypt with .gitattributes
echo "secrets.env filter=git-crypt diff=git-crypt" >> .gitattributes
echo "config/credentials.yml filter=git-crypt diff=git-crypt" >> .gitattributes
# Add team members via GPG key
git-crypt add-gpg-user GPG_KEY_ID
SOPS: Mozilla Secret Manager
SOPS (Secrets OPerationS) encrypts only the values in YAML/JSON/ENV files, leaving keys visible. It works with AWS KMS, GCP KMS, Azure Key Vault, or age/PGP keys.
# Install
wget https://github.com/mozilla/sops/releases/latest/download/sops-linux-amd64 -O /usr/local/bin/sops
chmod +x /usr/local/bin/sops
# Encrypt a .env file
sops --encrypt --age age1... .env > .env.encrypted
# Decrypt at deploy time
sops --decrypt .env.encrypted > .env
# Edit encrypted file in-place
sops .env.encrypted
What to Do If You Accidentally Commit a Secret
# If you just committed and have not pushed yet:
git reset HEAD~1 # Undo the commit, keep changes staged
# Remove the secret from the file
git add the-file
git commit -m "fix: remove accidental credential"
# If you have already pushed:
# 1. Rotate the credential IMMEDIATELY (assume it is compromised)
# 2. Use git filter-repo to purge from history
pip install git-filter-repo
git filter-repo --path secrets.env --invert-paths
# Force push with team awareness
git push --force-with-lease
Bare Repositories (Server-Side)
A bare repository contains only the Git data — no working directory, no checked-out files. It stores only the history. Think of it like GitHub itself: you push to it, but you do not work directly in it.
# Create a bare repo
git init --bare /opt/repos/myapp.git
# The structure looks like this
ls /opt/repos/myapp.git/
# branches/ config description HEAD hooks/ info/ objects/ packed-refs refs/
Why use bare repos instead of regular repos on the server?
- You can push to a bare repo even when someone is working in it — no conflict
- It is semantically correct — the server receives code, it does not edit it
- post-receive hooks work cleanly with bare repos
For multi-server setups, have post-receive distribute to multiple targets:
#!/bin/bash
# post-receive: deploy to multiple servers
GIT_DIR="/opt/repos/myapp.git"
SERVERS=("web1.example.com" "web2.example.com" "web3.example.com")
for server in "${SERVERS[@]}"; do
echo "Deploying to $server..."
ssh "$server" "cd /var/www/myapp && git pull" &
done
wait
echo "Multi-server deployment complete"
Git for Team Collaboration
Solo, Git is useful. With a team, it becomes essential. Here is how to set it up for server admin teams.
Protected Branches
On GitHub or GitLab, enable branch protection on main:
- Require pull request reviews before merging
- Require status checks to pass (CI/CD tests)
- Prevent force pushes to main
- Restrict who can push directly
No one pushes directly to main. Everyone works on branches and opens pull/merge requests. This creates a review trail — you know who changed what, why, and who approved it.
The Pull Request Workflow
# 1. Create a branch for your change
git checkout -b fix/nginx-memory-leak
# 2. Make changes, commit
git add nginx.conf
git commit -m "fix(nginx): reduce worker_processes to match CPU count"
# 3. Push the branch
git push origin fix/nginx-memory-leak
# 4. Open a Pull Request on GitHub/GitLab
# 5. Team reviews, approves, and merges
# 6. CI/CD automatically deploys
Git Workflow for Infrastructure
A simple and effective workflow for small infrastructure teams:
- main — Production-ready. Protected. Only deployable code lives here.
- staging — Pre-production. Changes go here first for testing.
- feature/ or fix/ branches — Created from main, merged via PR after review.
Rollback Strategies
Rollback is where Git earns its place in infrastructure work. Something breaks in production. You need to go back. Here are your options, from safest to most destructive.
git revert — Safe (Recommended for Production)
Creates a new commit that undoes the changes of a previous commit. History is preserved. This is what you want for shared repos and production.
# Revert the last commit
git revert HEAD
# Revert a specific commit
git log --oneline
# a3f1d2c feat: deploy new nginx config
# 9b2e1a4 fix: update PHP memory limit
# 7c3f0a1 initial setup
git revert a3f1d2c
# Creates a new commit that undoes a3f1d2c
git push origin main
git reset — Powerful but Destructive
git reset --hard moves your branch pointer backward. It rewrites history. Use with extreme caution in shared repos.
# Go back 1 commit (destroys the last commit completely)
git reset --hard HEAD~1
# Go back to a specific commit
git reset --hard 9b2e1a4
# Force push after reset
git push --force-with-lease
Warning:git reset --hardon a shared branch destroys other people's work context. Usegit revertinstead. Reservereset --hardfor your own unshared branches.
git stash — Temporary Save
Stash saves your uncommitted changes and gives you a clean working directory. Useful when you need to switch branches quickly or pull in an emergency.
# Save current changes
git stash
git stash push -m "WIP: half-finished nginx changes"
# List stashes
git stash list
# Apply the latest stash
git stash apply
# Apply and remove from list
git stash pop
# Discard a stash
git stash drop stash@{0}
Tags for Release Points
Tags mark specific commits as meaningful milestones — release versions, pre-change snapshots.
# Create a tag before a major change
git tag -a v1.4.0 -m "Pre-migration snapshot"
git push origin v1.4.0
# Deploy a specific version
git checkout v1.4.0
# List all tags
git tag -l
Best practice: tag every production deployment. When something breaks, you can say "go back to v1.4.0" without looking up commit hashes.
Practical Scenarios
Real situations you will encounter, and how to handle them with Git.
Scenario 1: "I broke the nginx config"
# See what changed
git diff /etc/nginx/nginx.conf
# View the previous version
git show HEAD~1:/etc/nginx/nginx.conf
# Restore the file to the last committed state
git checkout -- /etc/nginx/nginx.conf
# Test and reload
nginx -t && systemctl reload nginx
Scenario 2: "Who changed this config and when?"
# See who changed which lines
git blame nginx.conf
# Output:
# a3f1d2c (john.admin 2026-01-15 09:23:11 +0000 42) worker_processes 4;
# 9b2e1a4 (alice.ops 2025-12-03 14:11:05 +0000 43) worker_connections 1024;
# See full history for a file
git log --follow nginx.conf
# See all changes to a file
git log -p nginx.conf
Scenario 3: "Deploy a specific version to production"
# List tags
git tag -l
# v1.0.0 v1.1.0 v1.2.0 v1.3.0
# Deploy v1.2.0 (skip v1.3.0 that is causing issues)
git fetch --tags
git checkout v1.2.0
# Restart services
systemctl reload nginx php8.3-fpm
Scenario 4: "Sync config across 5 servers"
# Central config repo approach
# Each server clones the config repo
git clone git@gitserver:/opt/repos/configs.git /opt/configs
# Cron job on each server pulls and applies
# /etc/cron.d/config-sync
*/15 * * * * root cd /opt/configs && git pull && bash apply-configs.sh
Scenario 5: "Emergency rollback at 2 AM"
# Find what was deployed just before things broke
git log --oneline -10
# a3f1d2c (HEAD) feat: enable new caching layer <-- broke things
# 9b2e1a4 fix: update PHP config <-- was stable
# Revert the breaking commit
git revert a3f1d2c
git push origin main
# If post-receive hook is set up, this deploys automatically
Quick Reference
Essential Git Commands
| Command | Purpose |
|---|---|
git init | Initialize new repository |
git clone <url> | Clone existing repository |
git status | Show current state |
git add -A | Stage all changes |
git commit -m "msg" | Commit staged changes |
git push | Upload to remote |
git pull | Download from remote |
git log --oneline | Compact history |
git diff | Show unstaged changes |
git blame <file> | Who changed each line |
git checkout -- <file> | Restore file from last commit |
git revert HEAD | Undo last commit (safe) |
git stash | Save uncommitted changes |
git tag -a v1.0 -m "msg" | Create annotated tag |
git init --bare | Create server-side bare repo |
Deployment Methods Comparison
| Method | Setup Complexity | Best For |
|---|---|---|
| Manual git pull | Minimal | Solo admins, simple setups |
| Bare repo + post-receive hook | Low | Small teams, custom control |
| GitHub/GitLab webhooks | Medium | Teams, CI/CD pipelines |
| Full CI/CD (GitHub Actions, GitLab CI) | High | Large teams, multi-environment |
Rollback Decision Guide
| Situation | Command | Notes |
|---|---|---|
| Undo last commit, keep it safe | git revert HEAD | Safe for shared repos |
| Restore one file to last commit | git checkout -- file | Discards uncommitted changes |
| Go back to a tag | git checkout v1.0.0 | Detached HEAD — create branch after |
| Nuke last commit (unshared branch only) | git reset --hard HEAD~1 | Destructive — use carefully |
| Save work-in-progress temporarily | git stash | Non-destructive, temporary |
Next Steps
Git is one of those tools that scales with you — the basics get you most of the value immediately, and you can add more sophisticated workflows as your needs grow. Start by putting your most critical config files under version control today. Add the pre-commit secret scanner. Set up a bare repo for your primary application. Tag before every major change.
From there, the natural progression is a full CI/CD pipeline: code goes through automated testing before it ever reaches production, deployment is repeatable and auditable, and rollback is a single command instead of a crisis.
The goal is a server that behaves predictably, where every change is tracked, every deployment is reversible, and "what changed?" is always answerable. Git gets you there.