Overview
Blog
You Don't Need GitHub: Build a Full-Featured Git Server with Just SSH

You Don't Need GitHub: Build a Full-Featured Git Server with Just SSH

archyn archyn
October 2, 2025
16 min read

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:

Terminal window
$ ssh git@github.com
Terminal window
PTY allocation request failed on channel 0
Hi 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.git

git 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 syntax
ssh://user@server/path/to/repo.git
# SCP-like shorthand (more common)
user@server:path/to/repo.git

They’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:

Terminal window
$ ls my-project.git/
HEAD branches config description hooks info objects refs

Servers 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.

Terminal window
# 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 refs

The .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:

Terminal window
# 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 sshd

Install Git if it’s not already there:

Terminal window
$ sudo apt install git -y
$ git --version
git version 2.43.0

That’s the only software you need for this entire setup.

Creating the Git User

A dedicated git user keeps things clean and secure:

Terminal window
$ sudo adduser --system --shell /usr/bin/git-shell --group --disabled-password --home /home/git git

Breaking down the flags:

  • --system: system user, lower UID range
  • --shell /usr/bin/git-shell: restricted shell (more on this below)
  • --group: creates a matching git group
  • --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:

Terminal window
$ ssh git@your-server
fatal: 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:

Terminal window
$ 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/.ssh

SSH Key Authentication

If you already have SSH keys (likely, if you’ve used GitHub), skip the generation step. Otherwise:

Terminal window
$ 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:

Terminal window
$ 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:

Terminal window
# 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_keys

Test it:

Terminal window
$ ssh git@your-server
fatal: 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:

~/.ssh/config
Host gitbox
HostName your-server-ip-or-domain
User git
IdentityFile ~/.ssh/id_ed25519

Now instead of:

Terminal window
$ git clone git@203.0.113.42:/srv/git/my-project.git

You get:

Terminal window
$ git clone gitbox:/srv/git/my-project.git

Part 2: Working with Repos

Creating Your First Bare Repo

Set up the directory:

Terminal window
$ sudo mkdir -p /srv/git
$ sudo chown git:git /srv/git

I 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:

Terminal window
$ sudo -u git git init --bare /srv/git/my-project.git
Initialized empty Git repository in /srv/git/my-project.git/

Clone, Commit, Push

On your local machine:

Terminal window
$ git clone gitbox:/srv/git/my-project.git
Cloning into 'my-project'...
warning: You appear to have cloned an empty repository.

The warning is expected — nothing’s been pushed yet.

Terminal window
$ cd my-project
$ echo "# My Project" > README.md
$ git add README.md
$ git commit -m "Initial commit"
$ git push origin main

Verify from the server side:

Terminal window
$ sudo -u git git -C /srv/git/my-project.git log --oneline
a1b2c3d Initial commit

The 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:

Terminal window
$ git clone gitbox:/srv/git/my-project.git
$ cd my-project
$ cat README.md
# My Project

Make changes and push:

Terminal window
$ echo "Some work from my desktop" >> README.md
$ git add README.md
$ git commit -m "Add work from desktop"
$ git push origin main

Pull from the first machine:

Terminal window
$ git pull origin main
$ cat README.md
# My Project
Some work from my desktop

Any 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.git

No 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/git-new-repo
#!/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 1
fi
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}"
Terminal window
$ chmod +x ~/bin/git-new-repo
$ git-new-repo cool-project
Initialized empty Git repository in /srv/git/cool-project.git/
Repository created. Clone it with:
git clone gitbox:/srv/git/cool-project.git

Once 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:

Terminal window
$ ssh your-sudo-user@your-server "ls /srv/git/"
cool-project.git
dotfiles.git
my-project.git
personal-site.git

Or as a shell alias:

~/.bashrc or ~/.zshrc
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:

Terminal window
$ 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:

Teammate's ~/.ssh/config
Host gitbox
HostName your-server-ip-or-domain
User git
IdentityFile ~/.ssh/id_ed25519
Terminal window
$ git clone gitbox:/srv/git/cool-project.git

How 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:

Terminal window
$ 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:

/home/git/.ssh/authorized_keys
# Full access
ssh-ed25519 AAAAC3Nz... you@your-machine
# Restricted connection options for a contractor
no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3Nz... contractor@their-machine

This 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:

Terminal window
$ 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.git

Now 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

/srv/git/my-project.git/hooks/post-receive
#!/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
Terminal window
$ sudo chmod +x /srv/git/my-project.git/hooks/post-receive

Adaptable to Telegram, Slack, or email — it’s just a curl or sendmail call.

Auto-Deploy on Push

Push to main, website updates:

/srv/git/personal-site.git/hooks/post-receive
#!/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."
fi
done

Push 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

/srv/git/my-project.git/hooks/pre-receive
#!/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
done
done

A 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:

Terminal window
$ 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-hooks

Individual 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:

Terminal window
$ sudo -u git git -C /srv/git/special-repo.git config core.hooksPath /srv/git/special-repo.git/hooks

Part 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

/home/git/backup-repos.sh
#!/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 days
find "$BACKUP_DIR" -name "git-backup-*.tar.gz" -mtime +30 -delete
echo "Backup complete: $ARCHIVE"
Terminal window
$ sudo chmod +x /home/git/backup-repos.sh
$ sudo crontab -u git -e
# Run at 2 AM daily
0 2 * * * /home/git/backup-repos.sh >> /var/log/git-backup.log 2>&1

Mirror to a Second Server

For real redundancy, push everything to a backup server using the same SSH + bare repo approach:

/home/git/mirror-repos.sh
#!/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"
fi
done
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:

~/.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-lease

Not specific to self-hosting, but they make everything faster.

Polished SSH Config

~/.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_ed25519

gitbox for Git operations, gitbox-admin for server maintenance.

Create-and-Clone in One Step

~/bin/git-start
#!/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 1
fi
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.md
git add README.md
git commit -m "Initial commit"
git push origin main
echo ""
echo "Done. Project ready at ./${REPO_NAME}"
Terminal window
$ git-start my-new-project
Creating 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-project

MOTD Banner

If you SSH into the admin account, a quick summary is nice to have:

/etc/update-motd.d/99-git-stats
#!/bin/bash
REPO_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