What This Post Covers
This is a walkthrough for setting up a private Git server using nothing but SSH and bare repositories. No web UI, no extra dependencies, no platform accounts. Just Git on a Linux box.
By the end you’ll have:
- A private Git server on a cheap VPS
- Multi-machine access (laptop, desktop, whatever)
- Multi-user support with access control
- Git hooks for notifications, auto-deploy, and push rules
- Automated backups
- Some quality-of-life scripts to keep things smooth
Everything here costs about $4–5/month for a VPS, and the only software you install is Git itself.
The Basic Idea
If you SSH into GitHub, something interesting happens:
$ ssh git@github.comPTY allocation request failed on channel 0Hi username! You've successfully authenticated, but GitHub does not provide shell access.Connection to github.com closed.GitHub is an SSH server. When you push or pull over SSH, Git is opening a connection, navigating to a path, and transferring objects. That’s it. And any GitHub SSH URL confirms this:
git@github.com:username/project.gitgit is the SSH user, github.com is the host, username/project.git is a directory path. There’s no proprietary protocol underneath — it’s SSH all the way down.
So if you have any server with SSH access, you already have the foundation for a Git server. The rest of this post is about setting it up properly.
Background: How Git Works Over SSH
Worth covering briefly so everything that follows makes sense.
SSH URL Formats
Git understands two SSH URL styles:
# Full syntaxssh://user@server/path/to/repo.git
# SCP-like shorthand (more common)user@server:path/to/repo.gitThey’re equivalent. The shorthand is what you’ll see most often.
Bare Repositories
When you run git init, Git creates a .git directory that holds everything — commits, branches, history, hooks. The files in your working directory are just the current checkout. Git doesn’t need them to function.
A bare repo is a repository without the working directory. It’s the contents of .git standing on its own:
$ ls my-project.git/HEAD branches config description hooks info objects refsServers don’t need working files. Nobody’s opening index.html on the server to edit it by hand. The server just stores Git data and handles push/pull requests. A bare repo is built for exactly that.
# Regular repo: working files + .git directory$ git init my-project$ ls my-project/.git/
# Bare repo: just the .git contents$ git init --bare my-project.git$ ls my-project.git/HEAD branches config description hooks info objects refsThe .git suffix on bare repos is a convention — it signals “this is a bare repo, don’t try to work in here.” Every remote Git host (GitHub, GitLab, Bitbucket) stores your repos as bare repos on their end.
Part 1: Server Setup
Getting a VPS
Any cheap Linux VPS works. A $4–5/month instance from Hetzner, DigitalOcean, Vultr, or Linode is more than enough — Git repos are small and operations are lightweight. Ubuntu or Debian is assumed here, but any distro will be similar.
Once you’re in, do some minimal hardening:
# Update packages$ sudo apt update && sudo apt upgrade -y
# Basic firewall$ sudo ufw allow OpenSSH$ sudo ufw enable
# Disable root SSH login (make sure your sudo user works first)$ sudo sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config$ sudo systemctl restart sshdInstall Git if it’s not already there:
$ sudo apt install git -y$ git --versiongit version 2.43.0That’s the only software you need for this entire setup.
Creating the Git User
A dedicated git user keeps things clean and secure:
$ sudo adduser --system --shell /usr/bin/git-shell --group --disabled-password --home /home/git gitBreaking down the flags:
--system: system user, lower UID range--shell /usr/bin/git-shell: restricted shell (more on this below)--group: creates a matchinggitgroup--disabled-password: SSH keys only, no password login--home /home/git: explicit home directory
The important part is git-shell. This is a restricted shell bundled with Git that only allows Git commands (git-receive-pack, git-upload-pack, git-upload-archive) and blocks everything else.
If someone tries to SSH in interactively:
$ ssh git@your-serverfatal: Interactive git shell is not enabled.hint: ~/git-shell-commands should exist and have read and execute access.Connection to your-server closed.This is the same behavior as GitHub — you can authenticate and run Git operations, but you can’t get a shell. No extra software needed.
Set up the SSH directory:
$ sudo mkdir -p /home/git/.ssh$ sudo touch /home/git/.ssh/authorized_keys$ sudo chmod 700 /home/git/.ssh$ sudo chmod 600 /home/git/.ssh/authorized_keys$ sudo chown -R git:git /home/git/.sshSSH Key Authentication
If you already have SSH keys (likely, if you’ve used GitHub), skip the generation step. Otherwise:
$ ssh-keygen -t ed25519 -C "your-email@example.com"ed25519 over rsa — shorter keys, faster operations, and broadly considered the modern default.
Copy your public key to the server’s git user directly:
$ cat ~/.ssh/id_ed25519.pub | ssh your-sudo-user@your-server "sudo tee -a /home/git/.ssh/authorized_keys"Or manually — copy the key content and paste it on the server:
# Copy your public key content$ cat ~/.ssh/id_ed25519.pub
# On the server$ echo "your-public-key-here" | sudo tee -a /home/git/.ssh/authorized_keysTest it:
$ ssh git@your-serverfatal: Interactive git shell is not enabled.Connection to your-server closed.That rejection means it’s working. You authenticated, git-shell blocked the interactive session.
SSH Config Alias
Add this to your local ~/.ssh/config so you don’t have to type the full address every time:
Host gitbox HostName your-server-ip-or-domain User git IdentityFile ~/.ssh/id_ed25519Now instead of:
$ git clone git@203.0.113.42:/srv/git/my-project.gitYou get:
$ git clone gitbox:/srv/git/my-project.gitPart 2: Working with Repos
Creating Your First Bare Repo
Set up the directory:
$ sudo mkdir -p /srv/git$ sudo chown git:git /srv/gitI use /srv/git since the Filesystem Hierarchy Standard recommends /srv for site-specific served data. /home/git, /var/lib/git, or /opt/git all work too — just be consistent.
Create a repo:
$ sudo -u git git init --bare /srv/git/my-project.gitInitialized empty Git repository in /srv/git/my-project.git/Clone, Commit, Push
On your local machine:
$ git clone gitbox:/srv/git/my-project.gitCloning into 'my-project'...warning: You appear to have cloned an empty repository.The warning is expected — nothing’s been pushed yet.
$ cd my-project$ echo "# My Project" > README.md$ git add README.md$ git commit -m "Initial commit"$ git push origin mainVerify from the server side:
$ sudo -u git git -C /srv/git/my-project.git log --onelinea1b2c3d Initial commitThe day-to-day workflow is identical to using GitHub. You still git add, git commit, git push, git pull. The only difference is where the data lives.
Multi-Machine Access
On a second machine, clone the same repo:
$ git clone gitbox:/srv/git/my-project.git$ cd my-project$ cat README.md# My ProjectMake changes and push:
$ echo "Some work from my desktop" >> README.md$ git add README.md$ git commit -m "Add work from desktop"$ git push origin mainPull from the first machine:
$ git pull origin main$ cat README.md# My ProjectSome work from my desktopAny number of machines can connect — they just need the SSH key authorized and the clone URL.
Organizing Multiple Repos
Keep it flat:
/srv/git/├── personal-site.git├── dotfiles.git├── cool-project.git├── client-work.git├── scripts.git└── notes.gitNo need for subdirectories until you genuinely have enough repos to warrant it.
One-command repo creation — save yourself from SSH-ing in every time:
#!/bin/bash# Create a new bare repo on your Git server
REPO_NAME="$1"GIT_SERVER="gitbox"GIT_PATH="/srv/git"
if [ -z "$REPO_NAME" ]; then echo "Usage: git-new-repo <repo-name>" exit 1fi
if [[ "$REPO_NAME" != *.git ]]; then REPO_NAME="${REPO_NAME}.git"fi
ssh your-sudo-user@your-server \ "sudo -u git git init --bare ${GIT_PATH}/${REPO_NAME}"
echo ""echo "Repository created. Clone it with:"echo " git clone ${GIT_SERVER}:${GIT_PATH}/${REPO_NAME}"$ chmod +x ~/bin/git-new-repo$ git-new-repo cool-projectInitialized empty Git repository in /srv/git/cool-project.git/
Repository created. Clone it with: git clone gitbox:/srv/git/cool-project.gitOnce you set up the gitbox-admin SSH alias in Part 6, you can replace your-sudo-user@your-server with gitbox-admin in this script and the commands below.
Listing repos:
$ ssh your-sudo-user@your-server "ls /srv/git/"cool-project.gitdotfiles.gitmy-project.gitpersonal-site.gitOr as a shell alias:
alias git-repos="ssh your-sudo-user@your-server 'ls /srv/git/'"Part 3: Multi-User Access
Adding Collaborators
A teammate generates their key and sends you the public key (.pub file). You add it:
$ echo "ssh-ed25519 AAAAC3Nz... teammate@their-machine" | \ ssh your-sudo-user@your-server \ "sudo tee -a /home/git/.ssh/authorized_keys"They set up their SSH config and clone:
Host gitbox HostName your-server-ip-or-domain User git IdentityFile ~/.ssh/id_ed25519$ git clone gitbox:/srv/git/cool-project.gitHow Git Tracks Identity
Everyone connects as the git SSH user, so how does Git know who committed what? It doesn’t rely on the SSH user. Authorship comes from local .gitconfig:
$ git config --global user.name "Your Name"$ git config --global user.email "you@example.com"Each person’s machine attaches their identity to commits. The SSH user is just transport. This is how GitHub works too — everyone pushes as git, but commits carry individual author metadata.
Access Control
By default, every authorized key gets full read-write access to every repo. For a solo developer or small trusted team, that’s usually fine. When you need more control, there are a few options.
SSH key restrictions — you can prepend options to individual keys in authorized_keys:
# Full accessssh-ed25519 AAAAC3Nz... you@your-machine
# Restricted connection options for a contractorno-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3Nz... contractor@their-machineThis disables port forwarding, X11 forwarding, agent forwarding, and interactive terminal sessions. It doesn’t make access read-only, but combined with git-shell it significantly limits what the connection can do beyond Git operations.
Per-repo permissions with filesystem groups — for actual per-repo control, use Unix groups:
$ sudo groupadd project-alpha$ sudo usermod -aG project-alpha git
# Create system users for team members$ sudo adduser --disabled-password dev-alice$ sudo adduser --disabled-password dev-bob$ sudo usermod -aG project-alpha dev-alice# dev-bob intentionally left out
# Set repo permissions$ sudo chgrp -R project-alpha /srv/git/project-alpha.git$ sudo chmod -R g+rwX /srv/git/project-alpha.git$ sudo chmod -R o-rwx /srv/git/project-alpha.gitNow dev-alice can access project-alpha.git but dev-bob cannot. This does require separate system users per person instead of everyone sharing the git user.
When you outgrow this — if you need per-repo, per-branch, or per-user permissions at scale, Gitolite sits on top of SSH and provides fine-grained access control without requiring a web interface. It uses the same SSH + bare repo foundation, so everything here still applies.
Part 4: Git Hooks
Hooks are where this setup starts to get genuinely useful. A hook is a script that runs automatically at certain points in the Git workflow, stored in the hooks/ directory of a repo.
On a bare repo (server side), the key hooks are:
pre-receive— runs before accepting a push. Non-zero exit rejects the push.post-receive— runs after a push is accepted. Good for notifications and deployments.
Push Notifications to Discord
#!/bin/bash# Send a Discord notification on push
DISCORD_WEBHOOK="https://discord.com/api/webhooks/your/webhook-url"REPO_NAME=$(basename $(pwd) .git)
while read oldrev newrev refname; do BRANCH=$(echo "$refname" | sed 's|refs/heads/||') COMMIT_COUNT=$(git rev-list --count "$oldrev".."$newrev" 2>/dev/null || echo "new") LATEST_MSG=$(git log -1 --format="%s" "$newrev") AUTHOR=$(git log -1 --format="%an" "$newrev")
PAYLOAD=$(cat <<EOF{ "content": "📦 **${REPO_NAME}** — ${AUTHOR} pushed ${COMMIT_COUNT} commit(s) to \`${BRANCH}\`\n> ${LATEST_MSG}"}EOF)
curl -s -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK"done$ sudo chmod +x /srv/git/my-project.git/hooks/post-receiveAdaptable to Telegram, Slack, or email — it’s just a curl or sendmail call.
Auto-Deploy on Push
Push to main, website updates:
#!/bin/bash# Auto-deploy on push to main
DEPLOY_DIR="/var/www/my-site"BRANCH="main"
while read oldrev newrev refname; do if [ "$refname" = "refs/heads/$BRANCH" ]; then echo "Deploying $BRANCH to $DEPLOY_DIR..." git --work-tree="$DEPLOY_DIR" checkout -f "$BRANCH"
# Build steps if needed: # cd "$DEPLOY_DIR" && npm install && npm run build
echo "Deploy complete." fidonePush to any other branch, nothing happens. This is a minimal version of what platforms like Vercel or Netlify do under the hood.
Reject Force Pushes to Main
#!/bin/bash# Pre-receive hook: reject force pushes to main + reject low-effort commit messages
PROTECTED_BRANCH="refs/heads/main"
while read oldrev newrev refname; do # --- Reject force pushes to main --- if [ "$refname" = "$PROTECTED_BRANCH" ]; then if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then MERGE_BASE=$(git merge-base "$oldrev" "$newrev" 2>/dev/null) if [ "$MERGE_BASE" != "$oldrev" ]; then echo "ERROR: Force push to main is not allowed." echo "Use a feature branch and merge instead." exit 1 fi fi fi
# --- Reject low-effort commit messages --- if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then COMMITS=$(git rev-list "$newrev") else COMMITS=$(git rev-list "$oldrev".."$newrev") fi
for commit in $COMMITS; do MSG=$(git log -1 --format="%s" "$commit") WORD_COUNT=$(echo "$MSG" | wc -w)
if [ "$WORD_COUNT" -lt 2 ]; then echo "ERROR: Commit $commit has a low-effort message: '$MSG'" echo "Please write meaningful commit messages (at least 2 words)." exit 1 fi donedoneA repo can only have one pre-receive hook, so both checks go in the same script. The same applies if you add more rules later — combine them into a single file.
Shared Hooks Across Repos
Instead of copying hooks into every repo, use a shared directory:
$ sudo mkdir -p /srv/git/.shared-hooks$ sudo chown git:git /srv/git/.shared-hooks
# Point a specific repo at shared hooks$ sudo -u git git -C /srv/git/my-project.git config core.hooksPath /srv/git/.shared-hooks
# Or apply globally for all repos$ sudo -u git git config --global core.hooksPath /srv/git/.shared-hooksIndividual repos can override the global setting by configuring their own core.hooksPath — but note that a local hooks/ directory alone won’t take effect if core.hooksPath is set globally. You’d need to explicitly point it back:
$ sudo -u git git -C /srv/git/special-repo.git config core.hooksPath /srv/git/special-repo.git/hooksPart 5: Backups
Your server is now the central source of truth. Worth protecting.
Bare repos are straightforward to back up — they’re just directories. No database, no running process. You can copy, tar, or rsync them without worrying about lock files.
Nightly Archive with Cron
#!/bin/bash# Archive all repos nightly
BACKUP_DIR="/var/backups/git"SRC_DIR="/srv/git"DATE=$(date +%Y-%m-%d)ARCHIVE="${BACKUP_DIR}/git-backup-${DATE}.tar.gz"
mkdir -p "$BACKUP_DIR"
tar -czf "$ARCHIVE" -C "$SRC_DIR" .
# Keep last 30 daysfind "$BACKUP_DIR" -name "git-backup-*.tar.gz" -mtime +30 -delete
echo "Backup complete: $ARCHIVE"$ sudo chmod +x /home/git/backup-repos.sh$ sudo crontab -u git -e
# Run at 2 AM daily0 2 * * * /home/git/backup-repos.sh >> /var/log/git-backup.log 2>&1Mirror to a Second Server
For real redundancy, push everything to a backup server using the same SSH + bare repo approach:
#!/bin/bash# Mirror all repos to a backup server
SRC_DIR="/srv/git"MIRROR_GIT="git@backup-server"MIRROR_ADMIN="your-sudo-user@backup-server"MIRROR_PATH="/srv/git-mirror"
for repo in "$SRC_DIR"/*.git; do REPO_NAME=$(basename "$repo") echo "Mirroring $REPO_NAME..." git -C "$repo" push --mirror "$MIRROR_GIT:$MIRROR_PATH/$REPO_NAME" 2>/dev/null
if [ $? -ne 0 ]; then echo " Mirror repo doesn't exist yet. Creating..." # Use the admin user to create the bare repo — git-shell can't run arbitrary commands ssh "$MIRROR_ADMIN" "sudo -u git git init --bare $MIRROR_PATH/$REPO_NAME" git -C "$repo" push --mirror "$MIRROR_GIT:$MIRROR_PATH/$REPO_NAME" fidone
echo "Mirror complete."This gets you multiple layers of redundancy: code on your local machines, on the primary server, and on the mirror. If the primary VPS goes down, nothing is lost.
Also worth remembering: every git clone is a full copy of the entire repository history. If three machines have cloned a repo, that’s already three backups. Git’s distributed nature is a natural safety net — the backup scripts above are extra insurance.
Part 6: Quality of Life
Git Aliases
Add these to ~/.gitconfig:
[alias] s = status --short l = log --oneline --graph --decorate -20 la = log --oneline --graph --decorate --all d = diff co = checkout br = branch cm = commit -m amend = commit --amend --no-edit undo = reset --soft HEAD~1 pushf = push --force-with-leaseNot specific to self-hosting, but they make everything faster.
Polished SSH Config
Host gitbox HostName your-server-ip-or-domain User git IdentityFile ~/.ssh/id_ed25519 ServerAliveInterval 60 ServerAliveCountMax 3
Host gitbox-admin HostName your-server-ip-or-domain User your-sudo-user IdentityFile ~/.ssh/id_ed25519gitbox for Git operations, gitbox-admin for server maintenance.
Create-and-Clone in One Step
#!/bin/bash# Create a repo on the server and clone it locally in one go
REPO_NAME="$1"GIT_SERVER="gitbox"GIT_PATH="/srv/git"ADMIN_HOST="gitbox-admin"
if [ -z "$REPO_NAME" ]; then echo "Usage: git-start <project-name>" exit 1fi
BARE_NAME="${REPO_NAME}.git"
echo "Creating ${BARE_NAME} on server..."ssh "$ADMIN_HOST" "sudo -u git git init --bare ${GIT_PATH}/${BARE_NAME}"
echo "Cloning locally..."git clone "${GIT_SERVER}:${GIT_PATH}/${BARE_NAME}" "$REPO_NAME"
cd "$REPO_NAME"echo "# ${REPO_NAME}" > README.mdgit add README.mdgit commit -m "Initial commit"git push origin main
echo ""echo "Done. Project ready at ./${REPO_NAME}"$ git-start my-new-projectCreating my-new-project.git on server...Initialized empty Git repository in /srv/git/my-new-project.git/Cloning locally...Done. Project ready at ./my-new-projectMOTD Banner
If you SSH into the admin account, a quick summary is nice to have:
#!/bin/bashREPO_COUNT=$(find /srv/git -maxdepth 1 -name "*.git" -type d | wc -l)TOTAL_SIZE=$(du -sh /srv/git 2>/dev/null | cut -f1)
echo ""echo " 📦 Git Server Status"echo " ─────────────────────"echo " Repositories: ${REPO_COUNT}"echo " Total size: ${TOTAL_SIZE}"echo ""What This Setup Doesn’t Cover
To be clear about the trade-offs:
- No web interface — you can’t browse code in a browser, you need to clone
- No pull requests — code review happens over chat, email, or not at all
- No CI/CD — beyond hooks, there’s no automated test pipeline
- No issue tracking — no tickets, boards, or milestones
- No fork model — contributors need SSH access, there’s no public “fork and PR” workflow
For solo work or a small trusted team, these usually aren’t problems. If they become problems, the natural next steps are:
- Gitea — lightweight, self-hosted, GitHub-like interface. Written in Go, runs on minimal resources. Probably the most popular option.
- Cgit — read-only web interface for browsing code. Extremely minimal.
- Soft Serve — Git server with a terminal UI, from the Charm team.
- Forgejo — community fork of Gitea, same idea, different governance.
A common approach is to self-host private repos (personal projects, client work, dotfiles) and keep GitHub for public/open-source work where discoverability matters.
Wrapping Up
Here’s what we set up:
- A private Git server with zero dependencies beyond Git
- SSH authentication locked down with
git-shell - Multi-machine, multi-user workflow
- Access control via SSH keys and Unix permissions
- Hooks for notifications, deployment, and push rules
- Automated backups with mirroring
- Helper scripts for common tasks
The practical takeaway: Git speaks SSH natively, and SSH is everywhere. Any Linux server is already a potential Git server. Once you see that, platforms like GitHub become a convenience you opt into rather than something you depend on.
Your code, your hardware, your rules.
References
- Tsoding — Microsoft doesn’t want you to know this
- Git on the Server — The Protocols (Pro Git Book)
- Filesystem Hierarchy Standard 3.0 — /srv
- Gitolite — Fine-Grained Access Control for Git
- Gitea — Self-Hosted Git Service
- Soft Serve — A TUI Git Server
- Forgejo — Community Fork of Gitea
- Cgit — Hyperfast Web Frontend for Git