Tutorial

Git for Server Admins: Version Control, Deployment, and Automation

March 31, 2026

Back to Blog

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

CommandWhat It Does
git initInitialize a new repository in the current directory
git clone <url>Copy an existing repository (remote or local)
git statusShow which files have changed, staged, or are untracked
git add <file>Stage a file (prepare it for commit)
git add -AStage all changes (new, modified, deleted)
git commit -m "message"Save staged changes as a new commit
git logShow commit history
git log --onelineCompact one-line history
git diffShow unstaged changes
git diff --stagedShow staged changes (what will be committed)
git push origin mainUpload commits to remote
git pullDownload and merge remote commits
git fetchDownload 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 feature
  • fix/ — Fixing a broken config
  • hotfix/ — Emergency production fix
  • test/ — 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, .pem private 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.

HookWhen It RunsUse For
pre-commitBefore a commit is createdLint checks, secret detection, tests
commit-msgAfter commit message is writtenEnforce message format
pre-pushBefore pushing to remoteRun tests, block pushes to main
post-receiveAfter receiving a push (server-side)Deploy, notify, restart services
post-mergeAfter a successful mergeInstall 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 --hard on a shared branch destroys other people's work context. Use git revert instead. Reserve reset --hard for 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

CommandPurpose
git initInitialize new repository
git clone <url>Clone existing repository
git statusShow current state
git add -AStage all changes
git commit -m "msg"Commit staged changes
git pushUpload to remote
git pullDownload from remote
git log --onelineCompact history
git diffShow unstaged changes
git blame <file>Who changed each line
git checkout -- <file>Restore file from last commit
git revert HEADUndo last commit (safe)
git stashSave uncommitted changes
git tag -a v1.0 -m "msg"Create annotated tag
git init --bareCreate server-side bare repo

Deployment Methods Comparison

MethodSetup ComplexityBest For
Manual git pullMinimalSolo admins, simple setups
Bare repo + post-receive hookLowSmall teams, custom control
GitHub/GitLab webhooksMediumTeams, CI/CD pipelines
Full CI/CD (GitHub Actions, GitLab CI)HighLarge teams, multi-environment

Rollback Decision Guide

SituationCommandNotes
Undo last commit, keep it safegit revert HEADSafe for shared repos
Restore one file to last commitgit checkout -- fileDiscards uncommitted changes
Go back to a taggit checkout v1.0.0Detached HEAD — create branch after
Nuke last commit (unshared branch only)git reset --hard HEAD~1Destructive — use carefully
Save work-in-progress temporarilygit stashNon-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.

Share: