Genel bakış
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

archynarchyn
2 Ekim 2025
15 dk okuma

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

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/

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/
# Bare repo: just the .git contents
git init --bare my-project.git
ls my-project.git/

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

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

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

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

Clone, Commit, Push

On your local machine:

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

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

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

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

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

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/"

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

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

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