--- title: "How to Set Up a Private Git Server over SSH on Ubuntu 24.04" description: "Build a private Git server on Ubuntu 24.04 with a restricted git user, git-shell and SSH keys, host bare repositories, push and clone, and back them up nightly." category: "Application Deployment" language: en source: https://cubepath.com/docs/application-deployment/private-git-server-configuration updated: 2026-09-25 --- # How to Set Up a Private Git Server over SSH on Ubuntu 24.04 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: ```bash sudo apt update sudo apt install git git --version ``` ```output git version 2.43.0 ``` All Git traffic travels over SSH on port 22. If UFW is enabled, make sure SSH is allowed: ```bash 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: ```bash 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: ```bash which git-shell | sudo tee -a /etc/shells ``` Check the new account: ```bash getent passwd git ``` ```output 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: ```bash 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: ```bash 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: ```text 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: ```bash ssh git@your_server_ip ``` ```output 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: ```bash sudo nano /etc/ssh/sshd_config.d/git.conf ``` ```text 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: ```bash sudo sshd -t ``` The command prints nothing when the configuration is valid. Reload the SSH service: ```bash 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: ```bash sudo -u git git -C /srv/git init --bare --initial-branch=main project.git ``` ```output 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: ```text 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: ```bash cd ~/project git remote add origin git@your_server_ip:project.git git push -u origin main ``` ```output 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: ```bash git clone git@your_server_ip:project.git ``` To confirm the push reached the server, list the branches on the server side: ```bash sudo -u git git -C /srv/git/project.git branch ``` ```output * main ``` > Tip: If you move an existing project from another host, push every branch and tag at once with `git push --mirror git@your_server_ip:project.git` from a clone made with `git clone --mirror`. ## 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`: ```bash sudo install -d -m 750 -o git -g git /var/backups/git ``` Create the script: ```bash sudo nano /usr/local/bin/git-backup ``` ```bash #!/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: ```bash sudo chmod 755 /usr/local/bin/git-backup sudo -u git /usr/local/bin/git-backup ``` Verify the bundle it created: ```bash sudo -u git git -C /srv/git/project.git bundle verify /var/backups/git/$(date +%F)/project.bundle ``` ```output 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: ```bash 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: ```bash 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: ```bash 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: ```bash 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.