🔧 Git for Sysadmins and Operations

Config repos, etckeeper, blame and bisect, deploying with hooks, rollbacks, secrets and server settings

Video: Git for Sysadmins (DevOps video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
Git for Sysadmins and Operations: config repos, audits, deploys and rollbacks
git -C /etc log --oneline

The Git sheet on this site covers the everyday developer loop. This guide is the OPERATIONS side: keeping server configuration under version control, answering "what changed, and who changed it?", bisecting a regression, deploying a website or config with a hook, rolling back, keeping secrets out of history, and the settings and errors that matter when Git runs on servers.
Every example ran for real in a throw-away repository (fixed author and dates, so even the commit hashes you see are reproducible), on Git 2.43. Commands that touch real servers are shown without output.

Setup used by the examples

In your own shell the one-time identity setup is: git config --global user.name "Your Name" and git config --global user.email "you@example.com". Nothing else is needed to follow along.

Put configuration under version control

Why: every change gets a date, an author and a reason; you can compare "what is deployed" with "what we intended"; a bad change is one command to undo; and a rebuilt server gets the same configuration back.
Start with a small repository holding the configuration you care about:
$ mkdir -p mini-etc/nginx mini-etc/ssh && cd mini-etc
$ printf 'worker_processes 2;\nworker_connections 512;\nserver_tokens on;\n' > nginx/nginx.conf
$ printf 'PermitRootLogin yes\nPasswordAuthentication yes\n' > ssh/sshd_config
$ printf '#!/bin/sh\necho backup\n' > backup.sh; chmod 755 backup.sh
$ git init -q && git add -A && git commit -q -m "Import current nginx and ssh settings" && git log --oneline
6a8acc3 Import current nginx and ssh settings
Make a change on purpose, review it, commit it with a message that says WHY (a ticket or a reason is the part nobody can reconstruct later):
$ sed -i 's/worker_connections 512/worker_connections 2048/' nginx/nginx.conf
$ sed -i 's/PermitRootLogin yes/PermitRootLogin no/; s/PasswordAuthentication yes/PasswordAuthentication no/' ssh/sshd_config
$ git status -s
 M nginx/nginx.conf
 M ssh/sshd_config
$ git diff
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 29ad82f..5b7ae64 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -1,3 +1,3 @@
 worker_processes 2;
-worker_connections 512;
+worker_connections 2048;
 server_tokens on;
diff --git a/ssh/sshd_config b/ssh/sshd_config
index 6b38776..d3b3049 100644
--- a/ssh/sshd_config
+++ b/ssh/sshd_config
@@ -1,2 +1,2 @@
-PermitRootLogin yes
-PasswordAuthentication yes
+PermitRootLogin no
+PasswordAuthentication no
$ git commit -q -am "Harden sshd (keys only, no root login); raise worker_connections for traffic spike (OPS-142)" && git log --oneline
5f79d38 Harden sshd (keys only, no root login); raise worker_connections for traffic spike (OPS-142)
6a8acc3 Import current nginx and ssh settings
Drift: someone edits a live file by hand. The repository tells you, and lets you put it back:
$ echo 'server_tokens off;' >> nginx/nginx.conf
$ git status -s
 M nginx/nginx.conf
$ git diff --stat
 nginx/nginx.conf | 1 +
 1 file changed, 1 insertion(+)
$ git restore nginx/nginx.conf && git status -s && echo "(clean again)"
(clean again)
Git tracks the executable bit but not ownership or other permission bits:
$ git ls-files -s
100755 326f366d36b5b15de8271ffc1ad1851c8a03ac22 0	backup.sh
100644 5b7ae6449140db744933fade5b87f1c430994396 0	nginx/nginx.conf
100644 d3b3049e07823382c1d16a5dacf178d2246ca20d 0	ssh/sshd_config
(100755 is executable, 100644 is a normal file.) So Git alone is not a full backup of /etc: ownership and modes are lost on restore. That is the gap etckeeper fills.
$ sudo apt install etckeeper                         # Debian, Ubuntu (dnf install etckeeper on Fedora/RHEL with EPEL)
$ sudo etckeeper init && sudo etckeeper commit "Initial commit of /etc"      # it records ownership and permissions in /etc/.etckeeper and commits automatically before and after every apt or dnf run
$ sudo git -C /etc log --oneline | head;  sudo git -C /etc diff                # what changed since the last commit (a live edit shows up here)
$ sudo git -C /etc log -p -- ssh/sshd_config                                   # the history of one file, with every change
$ sudo etckeeper commit "Reason for the change (ticket)"                       # commit a manual edit with a reason
Keep the repository private: /etc contains secrets (keys, password hashes in shadow). Never push /etc to a public or shared remote without removing them, and keep /etc/.git readable by root only.
Good habits for a configuration repository: one repository per system or role; one logical change per commit; messages that say why; tags for released states (git tag -a prod-2026-03-08); branches and review for changes to shared infrastructure.

What changed, who changed it, and when?

$ git log --oneline --stat
6d7ed0b Rename site.conf to web.conf (OPS-140)
 site.conf => web.conf | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
89713e0 Enable TLS (OPS-130)
 site.conf | 1 +
 1 file changed, 1 insertion(+)
fa4d803 Allow 50 MB uploads for the reports feature (OPS-117)
 site.conf | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
10813eb Rename site to www (OPS-101)
 site.conf | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
9302588 Add site config
 site.conf | 3 +++
 1 file changed, 3 insertions(+)
Who last touched each line (blame), including the commit and the reason:
$ git blame -L 1,4 web.conf
^9302588 site.conf (Demo Admin 2026-03-08 10:00:00 +0000 1) listen 80;
10813ebf site.conf (Demo Admin 2026-03-08 10:00:00 +0000 2) server_name www.example.com;
fa4d8036 site.conf (Ana Ops    2026-03-08 10:00:00 +0000 3) client_max_body_size 50m;
89713e0b site.conf (Demo Admin 2026-03-08 10:00:00 +0000 4) ssl_protocols TLSv1.2 TLSv1.3;
$ git log --follow --oneline -- web.conf
6d7ed0b Rename site.conf to web.conf (OPS-140)
89713e0 Enable TLS (OPS-130)
fa4d803 Allow 50 MB uploads for the reports feature (OPS-117)
10813eb Rename site to www (OPS-101)
9302588 Add site config
Find the commit that introduced or removed a specific string (the pickaxe), or matched a pattern:
$ git log -S'client_max_body_size 50m' --oneline
fa4d803 Allow 50 MB uploads for the reports feature (OPS-117)
$ git log -G'ssl_protocols' --oneline
89713e0 Enable TLS (OPS-130)
$ git log --all --oneline --grep='OPS-1[0-9][0-9]'
6d7ed0b Rename site.conf to web.conf (OPS-140)
89713e0 Enable TLS (OPS-130)
fa4d803 Allow 50 MB uploads for the reports feature (OPS-117)
10813eb Rename site to www (OPS-101)
$ git log --author='Ana' --format='%h %an %s'
fa4d803 Ana Ops Allow 50 MB uploads for the reports feature (OPS-117)
$ git shortlog -sn
$ git log --diff-filter=D --summary --oneline
$ git show --stat --oneline HEAD~1
89713e0 Enable TLS (OPS-130)
 site.conf | 1 +
 1 file changed, 1 insertion(+)
$ git diff HEAD~3 HEAD --stat
 site.conf | 3 ---
 web.conf  | 4 ++++
 2 files changed, 4 insertions(+), 3 deletions(-)
Useful log formats and filters: --since="2 weeks ago", --until=, --author=, --grep= (commit message), -S (code added or removed), -G (code matching a regex), --follow (through renames), --stat (files), -p (patch), --graph --decorate --all (shape of history), --format='%h %ad %an %s' --date=short.
The answer to "why is this set to that?" is almost always: git blame the line, git show the commit, read the message and the ticket.

git bisect: find the commit that broke it

Something worked last month and is broken now, and there are 200 commits in between. bisect does a binary search: you say which commit is good and which is bad, it checks out the middle, you (or a script) say good or bad, and in about eight steps it names the commit.
$ git log --oneline | wc -l
9
The test must exit 0 for GOOD and non-zero for BAD. A one-line check is enough: here "no worker count of zero".
$ git bisect start HEAD HEAD~8 >/dev/null
$ git bisect run sh -c '! grep -q "workers=0" app.conf' | grep -A5 'is the first bad commit'
da5188a772cf9b0009fb5c356abbf59d311052d4 is the first bad commit
commit da5188a772cf9b0009fb5c356abbf59d311052d4
Author: Demo Admin <admin@example.com>
Date:   Sun Mar 8 10:00:00 2026 +0000

    Reduce workers while testing
$ git bisect reset >/dev/null 2>&1; git status -sb | head -1
## main
Write the test as a script that exits 0 or 1 (exit 125 means "cannot test this commit, skip it"): the same technique finds a performance regression (time a command), a broken build or a failing check. git bisect start BAD GOOD, then git bisect run ./test.sh, then git bisect reset to return.

Deploy with Git

The simplest reliable deployment: a bare repository on the server with a post-receive hook that checks the pushed branch out into the live folder. Pushing IS deploying, and the history shows what was deployed when. Here is the whole thing running locally:
$ git init -q --bare site.git && mkdir -p www
$ cat > site.git/hooks/post-receive <<'EOF'
> #!/bin/sh
> # deploy the main branch into the live folder
> TARGET=/home/demo/www
> while read oldrev newrev ref; do
>   if [ "$ref" = "refs/heads/main" ]; then
>     git --work-tree="$TARGET" checkout -f main
>     echo "deployed $(git rev-parse --short "$newrev") to $TARGET"
>   fi
> done
> EOF
Developer side: clone, change, push.
$ git clone -q site.git work 2>&1 | head -1; cd work && echo '<h1>Hello v1</h1>' > index.html && git add . && git commit -q -m "First version" && git push -q origin main 2>&1 | sed 's/[0-9a-f]\{7\}/<hash>/'
warning: You appear to have cloned an empty repository.
remote: Already on 'main'        
remote: deployed <hash> to /home/demo/www        
$ cat ../www/index.html
<h1>Hello v1</h1>
$ echo '<h1>Hello v2</h1>' > index.html && git commit -q -am "Second version" && git push -q origin main 2>&1 | sed 's/[0-9a-f]\{7\}/<hash>/'
remote: Already on 'main'        
remote: deployed <hash> to /home/demo/www        
$ cat ../www/index.html
<h1>Hello v2</h1>
Rollback is a push as well: git revert HEAD (a new commit that undoes the last one) then git push. Protect the history from being rewritten on the server:
$ git -C ../site.git config receive.denyNonFastForwards true && git -C ../site.git config receive.denyNonFastForwards
true
Make releases you can name and reproduce
$ git tag -a v1.0 -m "Release 1.0" HEAD~1; git tag -a v1.1 -m "Release 1.1"; git tag -n
v1.0            Release 1.0
v1.1            Release 1.1
$ git describe --tags --always
v1.1
$ echo 'x' > extra.txt && git add . && git commit -q -m "Work after the release" && git describe --tags --always | sed 's/-g[0-9a-f]*$/-g<hash>/'
v1.1-1-g<hash>
$ git archive --format=tar --prefix=site-1.1/ v1.1 | tar -t
site-1.1/
site-1.1/index.html
$ git diff --stat v1.0 v1.1
 index.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
git describe gives a build a version string that says exactly which commit it came from (v1.1-1-g...: one commit after v1.1). git archive makes a clean tarball of a tag WITHOUT the .git folder: a reproducible release artefact. On servers that only need to RUN a version, avoid cloning the whole history:
$ git clone --depth 1 --branch v1.1 https://git.example.com/site.git /srv/site       # shallow: only that commit
$ git -C /srv/site fetch --tags --depth 1 origin tag v1.2 && git -C /srv/site checkout v1.2    # move a server to a new tag
$ git -C /srv/site pull --ff-only                      # update a branch checkout, and refuse to merge anything unexpected
Prefer pull --ff-only on servers: if the server's copy has local edits that diverge, it fails loudly instead of creating a surprise merge commit.

Rollback and recovery

$ git log --oneline
511de30 v3: add retries
6040b18 v2: shorter timeout (mistake)
e4145d4 v1
Undo ONE earlier commit by adding a new commit that reverses it (safe, keeps history, works on shared branches):
$ git revert --no-edit HEAD~1 | head -1; cat app.conf; git log --oneline
[main 8c542e5] Revert "v2: shorter timeout (mistake)"
timeout=30
8c542e5 Revert "v2: shorter timeout (mistake)"
511de30 v3: add retries
6040b18 v2: shorter timeout (mistake)
e4145d4 v1
Get one file back as it was at another commit (here: the version from one commit ago, which still had the bad timeout; the second restore puts the current one back):
$ git restore --source=HEAD~1 app.conf; cat app.conf; git restore app.conf; cat app.conf
timeout=1
timeout=30
Throw commits away (rewrites history: for LOCAL work only, never on a branch others use):
$ git reset --hard HEAD~1 && git log --oneline
HEAD is now at 511de30 v3: add retries
511de30 v3: add retries
6040b18 v2: shorter timeout (mistake)
e4145d4 v1
Oops, that was the wrong one: the reflog remembers every place HEAD has been (for about 90 days), so nothing committed is really lost:
$ git reflog | head -4
511de30 HEAD@{0}: reset: moving to HEAD~1
8c542e5 HEAD@{1}: revert: Revert "v2: shorter timeout (mistake)"
511de30 HEAD@{2}: commit: v3: add retries
6040b18 HEAD@{3}: commit: v2: shorter timeout (mistake)
$ git reset --hard 'HEAD@{1}' && git log --oneline
HEAD is now at 8c542e5 Revert "v2: shorter timeout (mistake)"
8c542e5 Revert "v2: shorter timeout (mistake)"
511de30 v3: add retries
6040b18 v2: shorter timeout (mistake)
e4145d4 v1
$ git stash list | wc -l
0
Choosing the undo: revert = new commit that cancels (shared history); restore = bring back a file (no history change); reset --hard = move the branch and discard working changes (local only); reflog = the way back from a mistake. Uncommitted changes that you discard with reset --hard or restore are NOT in the reflog: commit or stash first.
$ git stash push -m "wip on tls"; git stash list; git stash pop                    # put unfinished work aside, then back
$ git checkout -b hotfix/nginx-timeout v1.1 && git cherry-pick 4f3c2a1               # a hotfix branch from a release tag, with one commit taken from elsewhere

Keep secrets out of the repository

Look for secrets already tracked:
$ git add -A && git commit -q -m "Add configs"
$ git grep -n -i -E 'password|secret|token|api[_-]?key'
app.env:2:db_password=hunter2
$ git grep -n -E 'BEGIN (RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY'
deploy.pem:1:-----BEGIN OPENSSH PRIVATE KEY-----
$ git ls-files | grep -E '\.(pem|key|p12|pfx)$|\.env$|id_rsa'
app.env
deploy.pem
$ git log --all --full-history --oneline -- '*.pem' '*.env'
aeed6af Add configs
Stop new ones: .gitignore for files, a pre-commit hook for content. A hook that refuses obvious private keys and passwords:
$ cat > .git/hooks/pre-commit <<'EOF'
> #!/bin/sh
> # refuse to commit private keys or password assignments
> if git diff --cached -U0 | grep -E '^\+.*(BEGIN [A-Z ]*PRIVATE KEY|password[[:space:]]*=[[:space:]]*[^[:space:]$])' >/dev/null; then
>   echo "pre-commit: refusing, the change contains what looks like a secret" >&2
>   exit 1
> fi
> EOF
$ printf 'api_password=letmein\n' > new.conf; git add new.conf; git commit -m "Add new config"
pre-commit: refusing, the change contains what looks like a secret
# (exit status 1)
$ git status -s
A  new.conf
Hooks live in .git/hooks and are not copied by clone: share them with a tracked folder and git config core.hooksPath .githooks, or use a tool such as pre-commit, gitleaks or git-secrets.
IF A SECRET WAS ALREADY COMMITTED: deleting the file in a new commit does NOT remove it, the old commit still has it, and every clone and fork keeps a copy.
  1. ROTATE the secret first (new password, new key, revoke the token): treat it as public from now on.
  2. Then, if you want it out of the history too, rewrite it: git filter-repo --path app.env --invert-paths (a separate tool; everyone must re-clone afterwards).
  3. Make sure it cannot come back: .gitignore, the hook above, a secret scanner in CI.
$ echo '*.env' >> .gitignore; echo '*.pem' >> .gitignore; git rm --cached app.env deploy.pem       # stop tracking (the files stay on disk)
$ git filter-repo --path app.env --path deploy.pem --invert-paths                                 # remove them from ALL history (after rotating!)
Read secrets from the environment, a secrets manager or systemd credentials (LoadCredential=), and keep only templates in Git (app.env.example without the values).

Line endings, modes and other quiet troubles

A script edited on Windows with CRLF line endings fails on Linux ("bad interpreter: /bin/bash^M"). Decide line endings in the repository with .gitattributes:
$ git init -q repo && cd repo
$ printf '#!/bin/sh\r\necho hello\r\n' > run.sh
$ git add run.sh && git ls-files --eol run.sh
i/crlf  w/crlf  attr/                 	run.sh
$ printf '* text=auto\n*.sh text eol=lf\n*.ps1 text eol=crlf\n' > .gitattributes
$ git add --renormalize . && git ls-files --eol
i/lf    w/crlf  attr/text eol=lf      	run.sh
"i/crlf" is what is stored in the repository, "w/crlf" what is in your working folder, "attr/..." the rule that applies. After the rule, shell scripts are stored with LF (i/lf) whatever the editor does. Without .gitattributes, set core.autocrlf=input on Linux/macOS and true on Windows (a per-user setting: easy to forget).
File mode changes show up as noise (old mode 100644, new mode 100755) when a repository moves between file systems that do not store the executable bit:
$ git config core.fileMode false             # ignore executable-bit changes in this clone (use with care)
Show the effective configuration and where each value comes from (the first thing to run when "Git behaves differently on that server"):
$ git config --global --list --show-origin
file:/home/demo/.gitconfig	user.name=Demo Admin
file:/home/demo/.gitconfig	user.email=admin@example.com
file:/home/demo/.gitconfig	init.defaultbranch=main
$ git config --list --show-origin | sort | head -30
Settings worth putting in a server or admin account:
  git config --global init.defaultBranch main           git config --global pull.ff only            (never create surprise merges on pull)
  git config --global fetch.prune true                  git config --global rerere.enabled true     (remember conflict resolutions)
  git config --global diff.colorMoved default           git config --global core.pager 'less -FRX'
  git config --global alias.lg "log --oneline --graph --decorate --all"
  git config --global safe.directory /srv/app          (see "dubious ownership" below)

Authentication on servers

$ ssh -T git@github.com                                                 # test the SSH key (shows who you are)
$ GIT_SSH_COMMAND='ssh -i ~/.ssh/deploy_ed25519 -o IdentitiesOnly=yes' git clone git@github.com:org/infra.git     # use one specific key for one command
$ git remote set-url origin git@github.com:org/infra.git                 # switch a clone from https to ssh
$ git config --global credential.helper 'cache --timeout=3600'           # remember an https token for an hour in MEMORY (store writes it in plain text: avoid on servers)
Use a DEPLOY KEY (a key pair added to ONE repository as read-only) for servers that only pull. A personal access token or your own SSH key on a server means anyone who gets onto that server can act as you. Rotate tokens, give them the least scope, and prefer short-lived credentials.
Signed commits and tags prove who published a release; a server can check before it deploys:
$ git tag -s v1.2 -m "Release 1.2"                  # needs a GPG or SSH signing key configured
$ git verify-tag v1.2; git verify-commit HEAD        # prints the signer, fails otherwise
$ git config --global gpg.format ssh; git config --global user.signingkey ~/.ssh/id_ed25519.pub     # sign with an SSH key instead of GPG

Operations one-liners

$ git rev-list --count HEAD
4
$ git branch --merged main
  feature/tls
* main
  old/experiment
$ git branch --merged main | grep -vE '^\*|main' | xargs -r git branch -d
Deleted branch feature/tls (was 188da73).
Deleted branch old/experiment (was c8dee55).
$ git diff --name-status v1.0 v1.1
A	b.txt
A	big.bin
$ git log --oneline --graph --decorate --all
* f2b6907 (HEAD -> main, tag: v1.1) add a big file
*   c8dee55 Merge tls
|\  
| * 188da73 tls work
|/  
* ce92e50 (tag: v1.0) one
$ git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' | awk '$1 == "blob" {print $2, $3}' | sort -rn | head -2
300000 big.bin
2 b.txt
$ git ls-tree -r -l HEAD | awk '{print $4, $5}' | sort -rn | head -3
300000 big.bin
2 b.txt
2 a.txt
$ git log -1 --format='%H %s' | cut -c1-12
f2b6907f1745
$ git show v1.0:a.txt
a
$ git commit --amend --no-edit                    # add forgotten changes to the LAST commit (only if not pushed)
$ git reset --soft HEAD~1                         # undo the last commit but keep the changes staged
$ git clean -nd; git clean -fd                    # list, then delete, untracked files (careful: it is permanent)
$ git remote -v; git branch -vv                   # where does this clone pull from, and which branch tracks what
$ git config --get remote.origin.url
Big files in history make clones slow: find them (the rev-list line above) and keep large binaries out of Git (artifact storage, git lfs, or a package registry).

When Git complains

Message                                              Cause and fix
fatal: detected dubious ownership in repository     the folder is owned by a different user than the one running git (common with sudo, root, shared deploy folders): git config --global --add safe.directory /srv/app  (as the user that runs git). Do NOT set safe.directory '*' on a shared server.
fatal: not a git repository                           you are in the wrong folder, or the .git folder is missing: git rev-parse --show-toplevel; cd to the repository, or git -C /path ...
error: Your local changes would be overwritten       a pull or checkout would clobber edits (typical when someone edited a live file): look with git diff, then git stash or git restore (after saving a copy), then pull again
refusing to merge unrelated histories                 two repositories with no common ancestor: usually the wrong remote; check git remote -v before forcing it
Permission denied (publickey)                         wrong key, key not added to the repository or account, or ssh-agent not holding it: ssh -vT git@host, GIT_SSH_COMMAND with the right -i
Updates were rejected because the remote contains work   someone pushed first: git pull --rebase (or fetch and merge), then push; never push --force on a shared branch (--force-with-lease is the polite version)
HEAD detached at ...                                  you checked out a tag or commit, not a branch: fine to look around, but commits there can be lost: git switch -c rescue-branch to keep them
CONFLICT (content): merge conflict in nginx.conf      open the file, choose between the <<<<<<< ======= >>>>>>> sections, git add the file, git commit (git merge --abort goes back to before)
Everything shows as modified after copying the repo  line endings or file modes changed: git diff --stat, git ls-files --eol, .gitattributes, core.fileMode
"warning: LF will be replaced by CRLF"                a CRLF setting on Windows: harmless, but decide with .gitattributes

Tips

Version-control /etc (etckeeper) and anything you would hate to retype. History is the cheapest audit log there is.
Write commit messages for the person who investigates at 3 a.m.: what AND why, with the ticket number.
Deploy from tags or from a branch that only moves forward, with pull --ff-only, and keep a one-command rollback (git revert, then deploy).
Rotate any secret that touches a repository. Deleting the file does not delete the secret.
git blame, git log -S and git bisect answer "who, when, and which commit". Use them before guessing.
The reflog saves you from most mistakes for about three months: git reflog first, panic second.
Servers should pull with read-only deploy keys, never with a person's credentials.
Never git push --force to a shared branch. If you must, --force-with-lease and tell the team.