Appearance
Hosting Bare Git Repositories over SSH on Linux
Sometimes I want a private place to push Git repositories, but I do not need a web interface, issue tracker, pull requests, runners, or another database to maintain. A Linux server, a directory with reliable storage, and SSH are enough.
The result is deliberately simple:
text
developer workstation
|
| Git over SSH
v
dedicated git account + git-shell
|
v
/srv/git/project.gitThe server stores bare repositories. There is no checked-out working tree on the server; clients clone, fetch, and push over SSH. Git already includes the server-side commands this requires, and git-shell restricts the service account to those Git operations instead of providing a general-purpose shell.
This is a good fit for a homelab, NAS, or small trusted team. It is not a replacement for GitHub, GitLab, Forgejo, or Gitea when you need code review, fine-grained permissions, audit features, or a web UI.
What You Need
- a Linux server with persistent storage
- OpenSSH server
- Git
- an administrative account with
sudo - an SSH key on each client that needs access
The examples below use:
git.example.comas the serveradminas the administrative SSH accountgitas the restricted service account/srv/gitas the repository rootproject.gitas an example repository
Replace those values to match your environment.
1. Install Git and OpenSSH
On Debian or Ubuntu:
bash
sudo apt update
sudo apt install git openssh-serverEquivalent packages are available on other Linux distributions. Confirm that Git provides git-shell:
bash
command -v git-shellIt is commonly installed at /usr/bin/git-shell. Add that exact path to /etc/shells if it is not already listed:
bash
git_shell="$(command -v git-shell)"
grep -Fxq "$git_shell" /etc/shells || printf '%s\n' "$git_shell" | sudo tee -a /etc/shells2. Create a Restricted Git Account
Create one service account for Git traffic:
bash
sudo useradd --create-home --shell "$(command -v git-shell)" git
sudo passwd --lock gitIf the account already exists, set its shell explicitly:
bash
sudo usermod --shell "$(command -v git-shell)" gitUsing git-shell matters. Git clients can still invoke the server-side commands used by clone, fetch, and push, but an ordinary interactive SSH login is rejected.
3. Create the Repository Directory
Create a location for bare repositories and give the service account ownership:
bash
sudo install -d -o git -g git -m 0750 /srv/gitOnly the git account needs to write here in this simple model. Administrators should create and maintain repositories with sudo -u git; doing so avoids ownership drift.
If several local administrators need direct group access, use a dedicated group and a setgid directory instead. For example, mode 2770 causes new entries to inherit the directory's group. Do not make the repository tree world-writable.
4. Authorize Client SSH Keys
Prepare the service account's SSH directory:
bash
sudo install -d -o git -g git -m 0700 /home/git/.ssh
sudo touch /home/git/.ssh/authorized_keys
sudo chown git:git /home/git/.ssh/authorized_keys
sudo chmod 0600 /home/git/.ssh/authorized_keysOpen the file as an administrator:
bash
sudoedit /home/git/.ssh/authorized_keysAdd one public key per line. A modern Ed25519 entry looks like this:
text
restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... workstation-nameThe optional OpenSSH restrict key option disables forwarding, PTY allocation, and several other SSH features that a Git-only key does not need. git-shell remains the main control that limits commands to Git operations.
Never copy a private key to the server. Only the contents of the client's .pub file belong in authorized_keys.
5. Create a Bare Repository
The restricted git account cannot open a shell and should not be able to create arbitrary repositories remotely. Create each repository through the administrative account:
bash
ssh -t admin@git.example.com \
'sudo -u git git init --bare --initial-branch=main /srv/git/project.git'The --bare option makes the repository itself the Git database. This is the correct layout for a shared remote because nobody edits a working tree on the server.
On the server, the resulting directory should belong to the service account:
bash
sudo stat -c '%U:%G %a %n' /srv/git/project.git
sudo -u git git -C /srv/git/project.git rev-parse --is-bare-repositoryThe second command should print true.
6. Connect a Local Repository
For an existing local repository, add the SSH URL and push its current branch:
bash
git remote add origin \
ssh://git@git.example.com/srv/git/project.git
git push -u origin mainIf the local branch is named master, push master instead. It is better to choose the initial branch deliberately than to assume every repository uses the same convention.
To clone the repository elsewhere:
bash
git clone ssh://git@git.example.com/srv/git/project.gitThe shorter SCP-like syntax works too:
bash
git clone git@git.example.com:/srv/git/project.gitI prefer the explicit ssh:// form in documentation because the protocol and absolute path are unambiguous.
7. Verify the Setup
Check the configured remote:
bash
git remote -v
git ls-remote originYou can also test the account restriction directly:
bash
ssh -T git@git.example.comA message such as fatal: Interactive git shell is not enabled is expected. That failure is a successful security check: SSH authentication worked, but the account did not provide an interactive shell. Git fetches and pushes should still work.
Creating More Repositories
Repeat the administrative initialization command for each new repository:
bash
ssh -t admin@git.example.com \
'sudo -u git git init --bare --initial-branch=main /srv/git/another-project.git'Then add the new remote locally and push. Keeping repository creation behind the administrative account prevents the restricted service account from turning into a general remote shell.
Optional Push Protection
For repositories where rewriting shared history would be especially painful, reject non-fast-forward pushes and branch deletion on the server:
bash
sudo -u git git -C /srv/git/project.git config receive.denyNonFastForwards true
sudo -u git git -C /srv/git/project.git config receive.denyDeletes trueThese settings are policy choices, not requirements. They also prevent intentional force-pushes and branch deletion, so enable them only when that tradeoff is desirable.
Back It Up
A bare repository contains committed history, branches, tags, and other Git refs, but it is still only one copy of the data. Include /srv/git in filesystem snapshots or a regular backup job, and test restoration occasionally.
Also remember what Git does not contain: uncommitted work, ignored files, external issue trackers, deployment secrets, and files stored outside the repository all need their own protection.
Troubleshooting
Permission denied (publickey)
Check that the correct public key is present in /home/git/.ssh/authorized_keys. The .ssh directory should normally be mode 0700, the file mode 0600, and both should be owned by git.
Use verbose SSH output from the client to see which keys are offered:
bash
ssh -vT git@git.example.comfatal: Interactive git shell is not enabled
This is expected from a direct ssh git@... login. Use Git commands such as git clone, git fetch, git push, or git ls-remote instead.
does not appear to be a git repository
Confirm the remote path, the .git suffix, and that the repository was initialized with --bare:
bash
ssh admin@git.example.com \
'sudo -u git git -C /srv/git/project.git rev-parse --is-bare-repository'Pushes fail with a permissions error
Inspect ownership as an administrator:
bash
ssh admin@git.example.com \
'sudo find /srv/git/project.git -maxdepth 2 -not -user git -ls'An empty result is ideal. Create repositories and perform server-side maintenance as git so new files do not acquire the wrong owner.
Git reports “dubious ownership” during administration
This can happen when an administrator runs Git directly against a repository owned by the service account. Do not weaken Git's global ownership checks just to silence it. Run the command as the owner instead:
bash
sudo -u git git -C /srv/git/project.git fsckKnow the Limits
Every SSH key in this design maps to the same Unix account. By default, every authorized user has the same access to every repository that account can reach, and server logs identify the shared git account rather than a distinct Git user.
That is often acceptable for one person, a household, or a small trusted group. If you need per-user and per-repository permissions while keeping an SSH-first workflow, look at Gitolite. If you also want a browser, pull requests, issues, or account management, a forge such as Forgejo or Gitea is a better next step.
Closing
This setup is intentionally boring: SSH handles authentication, git-shell limits the service account, and bare repositories hold the Git data. There is little to run, patch, or troubleshoot, and every normal Git client already knows how to use it.
For a small private repository store, that simplicity is the feature.