Git does not need a web platform to be shared: any server reachable over SSH can host repositories that developers push to and clone from. In this tutorial you will turn an Ubuntu 24.04 server into a private Git server with a dedicated git user that can only run Git commands through git-shell, grant access with SSH keys, create bare repositories, push an existing project to it, and schedule nightly backups with git bundle. This setup has no web interface or per-repository permissions; it suits small teams that already trust each other with every repository.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS. This guide calls its address your_server_ip.
  • A non-root user with sudo privileges and SSH access to the server.
  • On each developer machine, Git and an SSH key pair. If a developer has no key yet, they can create one with ssh-keygen -t ed25519.

Step 1 - Installing Git and allowing SSH

Git is usually preinstalled on Ubuntu 24.04. Install or update it:

sudo apt update
sudo apt install git
git --version
git version 2.43.0

All Git traffic travels over SSH on port 22. If UFW is enabled, make sure SSH is allowed:

sudo ufw allow OpenSSH
sudo ufw status

Step 2 - Creating a restricted git user

Every developer will connect as the same git user, and Git decides who they are by their SSH key. To make sure that user can only push and fetch, set its login shell to git-shell, which accepts git-receive-pack, git-upload-pack and git-upload-archive and refuses interactive logins.

Create a system user with its home directory in /srv/git, where the repositories will live:

sudo adduser --system --group --shell /usr/bin/git-shell --home /srv/git git

Register git-shell as a valid login shell so tools that check /etc/shells accept it:

which git-shell | sudo tee -a /etc/shells

Check the new account:

getent passwd git
git:x:110:111::/srv/git:/usr/bin/git-shell

The numeric IDs will differ; the home directory and shell must match.

Step 3 - Authorizing developers' SSH keys

Create the SSH directory for the git user with the strict permissions that OpenSSH requires:

sudo install -d -m 700 -o git -g git /srv/git/.ssh
sudo install -m 600 -o git -g git /dev/null /srv/git/.ssh/authorized_keys

Each developer sends you the contents of their public key, usually ~/.ssh/id_ed25519.pub. Open the file:

sudo nano /srv/git/.ssh/authorized_keys

Add one line per developer, prefixed with the restrict option. restrict disables port forwarding, agent forwarding, X11 forwarding and terminal allocation for that key, none of which Git needs:

restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyOfAlice alice@laptop
restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyOfBobXX bob@workstation

To revoke someone's access later, delete their line.

From a developer machine whose key you added, test the connection:

ssh git@your_server_ip
fatal: Interactive git shell is not enabled.
hint: ~/git-shell-commands should exist and have read and execute access.
Connection to your_server_ip closed.

This message is the expected result: authentication succeeded, and git-shell refused to open an interactive session. If you get Permission denied (publickey) instead, see the Troubleshooting section.

Step 4 - Hardening SSH for the git user

Key authentication is already required for git in practice, but it is worth making it explicit in the SSH server configuration. Ubuntu 24.04 reads extra settings from /etc/ssh/sshd_config.d/. Create a file for the git user:

sudo nano /etc/ssh/sshd_config.d/git.conf
Match User git
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    AllowTcpForwarding no
    AllowAgentForwarding no
    X11Forwarding no
    PermitTTY no

Check the syntax before applying it, so a mistake does not lock you out:

sudo sshd -t

The command prints nothing when the configuration is valid. Reload the SSH service:

sudo systemctl reload ssh

Step 5 - Creating a bare repository

A server repository is created as a bare repository: it has the Git history but no working tree, so nobody edits files there and pushes never conflict with checked-out files. Create one as the git user, with main as its default branch:

sudo -u git git -C /srv/git init --bare --initial-branch=main project.git
Initialized empty Git repository in /srv/git/project.git/

The .git suffix is a convention for bare repositories. Repeat this command for each new repository; only an administrator with sudo on the server can create them, which keeps the list of repositories under control.

Because the home directory of git is /srv/git, clients can use paths relative to it. These two remote URLs point to the same repository:

git@your_server_ip:project.git
git@your_server_ip:/srv/git/project.git

Step 6 - Pushing and cloning

On a developer machine, go to an existing project, add the server as a remote and push the main branch:

cd ~/project
git remote add origin git@your_server_ip:project.git
git push -u origin main
Enumerating objects: 24, done.
Counting objects: 100% (24/24), done.
Writing objects: 100% (24/24), 3.10 KiB | 3.10 MiB/s, done.
Total 24 (delta 4), reused 0 (delta 0), pack-reused 0
To your_server_ip:project.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

If you are starting from nothing instead, clone the empty repository and commit into it. Git warns that you cloned an empty repository, which is expected.

Another developer can now clone it:

git clone git@your_server_ip:project.git

To confirm the push reached the server, list the branches on the server side:

sudo -u git git -C /srv/git/project.git branch
* main

Step 7 - Backing up the repositories

git bundle packs a repository's complete history into one file that can be verified and cloned from, which makes it a better backup format than copying the directory while someone might be pushing. The following script bundles every repository under /srv/git into a dated directory and deletes backups older than 14 days.

Create the backup directory, owned by git:

sudo install -d -m 750 -o git -g git /var/backups/git

Create the script:

sudo nano /usr/local/bin/git-backup
#!/usr/bin/env bash
set -euo pipefail

src="/srv/git"
dest="/var/backups/git"
keep_days=14
target="${dest}/$(date +%F)"

mkdir -p "$target"

for repo in "$src"/*.git; do
    [ -d "$repo" ] || continue
    # Skip repositories that have no commits yet
    if [ -z "$(git -C "$repo" for-each-ref --count=1)" ]; then
        continue
    fi
    name="$(basename "$repo" .git)"
    git -C "$repo" bundle create "${target}/${name}.bundle" --all
done

find "$dest" -mindepth 1 -maxdepth 1 -type d -mtime +"$keep_days" -exec rm -rf {} +

Make it executable and run it once as the git user. It must run as git, because Git refuses to operate on repositories owned by another user:

sudo chmod 755 /usr/local/bin/git-backup
sudo -u git /usr/local/bin/git-backup

Verify the bundle it created:

sudo -u git git -C /srv/git/project.git bundle verify /var/backups/git/$(date +%F)/project.bundle
The bundle contains this ref:
...
The bundle records a complete history.
/var/backups/git/2026-09-25/project.bundle is okay

Schedule it every night at 02:30 with a cron file:

echo '30 2 * * * git /usr/local/bin/git-backup' | sudo tee /etc/cron.d/git-backup

To restore a repository, clone the bundle into a new bare repository and fix its ownership:

sudo git clone --bare /var/backups/git/2026-09-25/project.bundle /srv/git/project.git
sudo chown -R git:git /srv/git/project.git

Keeping the bundles only on the same server does not protect you from losing the server. Copy /var/backups/git to another machine or to object storage as well.

Troubleshooting

Permission denied (publickey). The server did not accept the key. On the server, check the ownership and permissions, which OpenSSH enforces strictly:

sudo ls -ld /srv/git /srv/git/.ssh
sudo ls -l /srv/git/.ssh/authorized_keys

/srv/git must not be writable by other users, .ssh must be drwx------ and authorized_keys must be -rw-------, all owned by git. Also check that the key line is complete and on a single line. The server log shows the exact reason:

sudo journalctl -u ssh -n 20

fatal: '...' does not appear to be a git repository. The path in the remote URL does not match a directory on the server. List the repositories with ls /srv/git and compare the name, including the .git suffix.

detected dubious ownership in repository. A repository or some of its files are owned by a user other than git, usually because they were created with plain sudo. Fix it with sudo chown -R git:git /srv/git/project.git.

error: remote unpack failed: unable to create temporary object directory. The same ownership problem prevents the git user from writing new objects. Apply the chown fix above.

Conclusion

You set up a private Git server on Ubuntu 24.04 with a git user restricted to git-shell, key-based access controlled through authorized_keys, bare repositories under /srv/git, and nightly git bundle backups.

As next steps you can:

  • Add a post-receive hook to a repository to deploy code or notify a chat channel after each push.
  • Copy the nightly bundles off the server with rsync or an object storage client.
  • Move to Gitea or Forgejo if you need a web interface, pull requests or per-repository permissions.