all topics

Server Setup

Private Git Server

SSH-based git hosting with per-repository access control — no extra software, no monthly fee.

TLDR;

You can run a full private git hosting service on an Ubuntu server using only tools that ship with git itself — no Gitea, no GitLab, no Gogs. The approach is simple: one dedicated system user (git), SSH public-key authentication, and a shell wrapper that checks a plain-text access file before executing any git command. The result is a server that handles multiple users and multiple repositories with read/write or read-only permission per combination.

Architecture

Every client connects via SSH as the git user. The git user’s login shell is git-shell, which blocks interactive logins and restricts the session to git operations only. Each public key in authorized_keys has a command= option that runs the authorization wrapper before executing any git command. The wrapper reads a per-user access file and either executes the git command or returns an “Access denied” error.

Flow diagram: Developer sends a git push via SSH to sshd, which runs git-auth.sh via the command= directive in authorized_keys. git-auth.sh checks the per-user access file and either executes the git command against the bare repository on disk, or returns Access Denied to the client.
No passwords at any point. Authentication is entirely by SSH public key. Authorization is per-repository, controlled by plain-text access files on disk.
Clone URL format:  git@mikegit.com:owner/reponame.git

Directory layout:
  /srv/git/
    repos/            ← all bare repositories
      alice/
        project1.git/
      bob/
        webapp.git/
    access/           ← per-user access control files
      alice           ← lists repos alice can access
      bob

  /home/git/.ssh/
    authorized_keys   ← all user public keys with command= wrappers

  /opt/gitserver/
    bin/              ← admin scripts (git-auth.sh, gs-*.sh)
    hooks/            ← post-receive hook template

Prerequisites

# Verify before starting
apache2 -v
git --version
openssl s_client -connect yourdomain.com:443 -brief </dev/null

You need an Ubuntu 22.04 server with Apache + SSL already configured. The git server layers on top of an existing Apache vhost — it does not replace your existing site.

Phase 1 — Package installation

Phase 1
apt update
apt install -y git gitweb php libapache2-mod-php fail2ban

# Enable required Apache modules
a2enmod cgi php8.3
systemctl restart apache2

# Verify gitweb installed
ls /usr/share/gitweb/gitweb.cgi

Phase 2 — System user and directories

Phase 2
# Create the git system user; git-shell is the login shell
useradd \
  --system \
  --shell /usr/bin/git-shell \
  --home-dir /home/git \
  --create-home \
  --comment "Git server" \
  git

# Confirm the path (may differ on your system)
which git-shell

# Create directory structure
mkdir -p /srv/git/repos /srv/git/access /srv/git/pending-keys /srv/git/archive
mkdir -p /opt/gitserver/bin /opt/gitserver/hooks
mkdir -p /var/www/gitserver/public /var/www/gitserver/addkey
mkdir -p /var/log/gitserver

# SSH directory for the git user
mkdir -p /home/git/.ssh
touch /home/git/.ssh/authorized_keys

# Set ownership and permissions
chown -R git:git /srv/git
chown -R git:git /home/git
chmod 755 /srv/git /srv/git/repos
chmod 750 /srv/git/access
chown git:www-data /srv/git/pending-keys
chmod 1770 /srv/git/pending-keys   # sticky bit prevents cross-deletion
chown git:git /var/log/gitserver
chmod 750 /var/log/gitserver
chmod 700 /home/git/.ssh
chmod 600 /home/git/.ssh/authorized_keys
chown -R www-data:www-data /var/www/gitserver
chown -R root:root /opt/gitserver

# Add www-data to the git group (gitweb needs to read repos)
usermod -aG git www-data

# Add admin scripts to root's PATH
echo 'export PATH="/opt/gitserver/bin:$PATH"' >> /root/.bashrc
source /root/.bashrc

Phase 3 — SSH configuration

Phase 3

Edit /etc/ssh/sshd_config to lock down the git user to key-only access while leaving root SSH intact:

# Append to /etc/ssh/sshd_config
Match User git
    PasswordAuthentication no
    PubkeyAuthentication yes
    AllowTcpForwarding no
    X11Forwarding no
    PermitTTY no
    ForceCommand /usr/bin/git-shell
The ForceCommand here is belt-and-suspenders alongside the command= in authorized_keys. The command= wrapper runs first and handles per-repo authorization. ForceCommand catches any edge case where a key entry lacks command=.
# Test before reloading (no output = config is valid)
sshd -t
systemctl reload sshd

# Verify root SSH still works from a second terminal before closing this session

Phase 4 — Authorization wrapper

Phase 4

This script is the core of per-repository access control. SSH runs it instead of the git command the client requested. It reads SSH_ORIGINAL_COMMAND, validates the repository path, checks the user’s access file, and either executes the git command or exits with an error.

●●● /opt/gitserver/bin/git-auth.sh
#!/bin/bash
USERNAME="$1"
ACCESS_FILE="/srv/git/access/${USERNAME}"
REPOS_BASE="/srv/git/repos"

if [ -z "$USERNAME" ]; then
    echo "ERROR: Auth wrapper called without username." >&2
    exit 1
fi

if [ -z "$SSH_ORIGINAL_COMMAND" ]; then
    echo "mikegit.com: Hello ${USERNAME}. Interactive shell access is not permitted."
    echo "Usage: git clone git@mikegit.com:<owner>/<repo>.git"
    exit 0
fi

GIT_CMD=$(echo "$SSH_ORIGINAL_COMMAND" | awk '{print $1}')
REPO_PATH=$(echo "$SSH_ORIGINAL_COMMAND" | sed "s/^[^ ]* '//;s/'$//;s/^[^ ]* //")

case "$GIT_CMD" in
    git-upload-pack|git-receive-pack|git-upload-archive) ;;
    *)
        echo "ERROR: Unsupported command: ${GIT_CMD}" >&2
        exit 1
        ;;
esac

REPO_PATH="${REPO_PATH#/}"
REPO_PATH="${REPO_PATH#srv/git/repos/}"

if [[ ! "$REPO_PATH" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.+-]+\.git$ ]]; then
    echo "ERROR: Invalid repository path: ${REPO_PATH}" >&2
    exit 1
fi

REPO_KEY="${REPO_PATH%.git}"
FULL_PATH="${REPOS_BASE}/${REPO_PATH}"

if [ ! -d "$FULL_PATH" ]; then
    echo "ERROR: Repository not found: ${REPO_PATH}" >&2
    exit 1
fi

if [ "$GIT_CMD" = "git-receive-pack" ]; then
    REQUIRED="rw"
else
    REQUIRED="ro"
fi

if [ ! -f "$ACCESS_FILE" ]; then
    echo "ERROR: Access denied (no access file for ${USERNAME})." >&2
    exit 1
fi

GRANTED=""
while IFS=" " read -r entry_repo entry_perm rest; do
    [[ "$entry_repo" =~ ^#.*$ ]] && continue
    [[ -z "$entry_repo" ]] && continue
    if [ "$entry_repo" = "$REPO_KEY" ]; then
        if [ "$REQUIRED" = "ro" ] && [[ "$entry_perm" =~ ^(ro|rw)$ ]]; then
            GRANTED="yes"
        elif [ "$REQUIRED" = "rw" ] && [ "$entry_perm" = "rw" ]; then
            GRANTED="yes"
        fi
        break
    fi
done < "$ACCESS_FILE"

if [ "$GRANTED" != "yes" ]; then
    echo "ERROR: Access denied to ${REPO_KEY} for user ${USERNAME}." >&2
    exit 1
fi

exec "$GIT_CMD" "$FULL_PATH"
chmod 755 /opt/gitserver/bin/git-auth.sh
chown root:root /opt/gitserver/bin/git-auth.sh

When an admin adds a user, their key is stored in authorized_keys with this format:

# /home/git/.ssh/authorized_keys — one line per user:
command="/opt/gitserver/bin/git-auth.sh alice",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...keydata... gs-user=alice

The command= means SSH runs git-auth.sh alice instead of what the client requested. The client’s original command arrives in $SSH_ORIGINAL_COMMAND. The restriction flags prevent interactive sessions, port forwarding, and agent forwarding.

Phase 5 — Admin scripts

Phase 5

All scripts live in /opt/gitserver/bin/, are owned by root, and must be run as root. Scripts are named gs-* (git server).

●●● gs-adduser.sh
#!/bin/bash
set -euo pipefail
[ "$EUID" -ne 0 ] && { echo "Must run as root."; exit 1; }
[ $# -ne 2 ] && { echo "Usage: gs-adduser.sh <username> <pubkey-file>"; exit 1; }

USERNAME="$1"
PUBKEY_FILE="$2"
AUTH_WRAPPER="/opt/gitserver/bin/git-auth.sh"
AUTH_KEYS="/home/git/.ssh/authorized_keys"
ACCESS_FILE="/srv/git/access/${USERNAME}"

[[ "$USERNAME" =~ ^[a-zA-Z0-9_-]{2,32}$ ]] || {
    echo "Invalid username. 2-32 characters: letters, numbers, underscore, hyphen."; exit 1; }
[ -f "$PUBKEY_FILE" ] || { echo "Key file not found: $PUBKEY_FILE"; exit 1; }

PUBKEY=$(tr -d '\n\r' < "$PUBKEY_FILE")
[[ "$PUBKEY" =~ ^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|sk-ssh-ed25519|sk-ecdsa-sha2-nistp256)[[:space:]]+[A-Za-z0-9+/]+=* ]] || {
    echo "Invalid public key format."; exit 1; }

grep -q "gs-user=${USERNAME}$" "$AUTH_KEYS" 2>/dev/null && {
    echo "User '${USERNAME}' already exists."; exit 1; }

mkdir -p "/srv/git/repos/${USERNAME}"
chown git:git "/srv/git/repos/${USERNAME}"
chmod 750 "/srv/git/repos/${USERNAME}"

touch "$ACCESS_FILE"
chown git:git "$ACCESS_FILE"
chmod 640 "$ACCESS_FILE"

KEYLINE="command=\"${AUTH_WRAPPER} ${USERNAME}\",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${PUBKEY} gs-user=${USERNAME}"
echo "$KEYLINE" >> "$AUTH_KEYS"

echo "User '${USERNAME}' added."
echo "Next: gs-createrepo.sh ${USERNAME} <reponame>"
●●● gs-createrepo.sh
#!/bin/bash
set -euo pipefail
[ "$EUID" -ne 0 ] && { echo "Must run as root."; exit 1; }
[ $# -lt 2 ] && { echo "Usage: gs-createrepo.sh <username> <reponame> [--description \"text\"]"; exit 1; }

USERNAME="$1"
REPONAME="$2"
DESCRIPTION=""
[ "${3:-}" = "--description" ] && DESCRIPTION="${4:-}"

[[ "$USERNAME" =~ ^[a-zA-Z0-9_-]{2,32}$ ]] || { echo "Invalid username."; exit 1; }
[[ "$REPONAME" =~ ^[a-zA-Z0-9_.-]{1,64}$ ]] || {
    echo "Invalid repo name. Letters, numbers, underscore, hyphen, dot."; exit 1; }

REPO_DIR="/srv/git/repos/${USERNAME}/${REPONAME}.git"
HOOK_TEMPLATE="/opt/gitserver/hooks/post-receive"
ACCESS_FILE="/srv/git/access/${USERNAME}"

[ -d "/srv/git/repos/${USERNAME}" ] || {
    echo "User directory not found. Run gs-adduser.sh first."; exit 1; }
[ -d "$REPO_DIR" ] && {
    echo "Repository '${USERNAME}/${REPONAME}' already exists."; exit 1; }

git init --bare "$REPO_DIR"
chown -R git:git "$REPO_DIR"
chmod -R u=rwX,g=rX,o= "$REPO_DIR"

[ -n "$DESCRIPTION" ] && echo "$DESCRIPTION" > "${REPO_DIR}/description"
echo "$USERNAME" > "${REPO_DIR}/owner"

if [ -f "$HOOK_TEMPLATE" ]; then
    cp "$HOOK_TEMPLATE" "${REPO_DIR}/hooks/post-receive"
    chown git:git "${REPO_DIR}/hooks/post-receive"
    chmod 755 "${REPO_DIR}/hooks/post-receive"
fi

touch "$ACCESS_FILE"
grep -q "^${USERNAME}/${REPONAME} " "$ACCESS_FILE" 2>/dev/null || \
    echo "${USERNAME}/${REPONAME} rw" >> "$ACCESS_FILE"

echo "Repository created: ${USERNAME}/${REPONAME}.git"
echo "  Clone URL: git@mikegit.com:${USERNAME}/${REPONAME}.git"
echo "  Owner '${USERNAME}' granted rw access automatically."
●●● gs-grantaccess.sh
#!/bin/bash
set -euo pipefail
[ "$EUID" -ne 0 ] && { echo "Must run as root."; exit 1; }
[ $# -lt 2 ] && { echo "Usage: gs-grantaccess.sh <username> <owner/reponame> [rw|ro]"; exit 1; }

USERNAME="$1"
REPO_SPEC="${2%.git}"
PERMISSION="${3:-rw}"

[[ "$PERMISSION" =~ ^(rw|ro)$ ]] || { echo "Permission must be rw or ro."; exit 1; }

grep -q "gs-user=${USERNAME}$" /home/git/.ssh/authorized_keys 2>/dev/null || {
    echo "User '${USERNAME}' not found."; exit 1; }
[ -d "/srv/git/repos/${REPO_SPEC}.git" ] || {
    echo "Repository '${REPO_SPEC}' not found."; exit 1; }

ACCESS_FILE="/srv/git/access/${USERNAME}"
touch "$ACCESS_FILE"
chown git:git "$ACCESS_FILE"
chmod 640 "$ACCESS_FILE"

sed -i "/^${REPO_SPEC} /d" "$ACCESS_FILE"
echo "${REPO_SPEC} ${PERMISSION}" >> "$ACCESS_FILE"

echo "Granted ${USERNAME} [${PERMISSION}] access to ${REPO_SPEC}"
●●● gs-revokeaccess.sh
#!/bin/bash
set -euo pipefail
[ "$EUID" -ne 0 ] && { echo "Must run as root."; exit 1; }
[ $# -ne 2 ] && { echo "Usage: gs-revokeaccess.sh <username> <owner/reponame>"; exit 1; }

USERNAME="$1"
REPO_SPEC="${2%.git}"
ACCESS_FILE="/srv/git/access/${USERNAME}"

[ -f "$ACCESS_FILE" ] || { echo "No access file for '${USERNAME}'."; exit 1; }

if grep -q "^${REPO_SPEC} " "$ACCESS_FILE"; then
    sed -i "/^${REPO_SPEC} /d" "$ACCESS_FILE"
    echo "Revoked ${USERNAME}'s access to ${REPO_SPEC}"
else
    echo "No access entry found for ${USERNAME} on ${REPO_SPEC}"
fi
●●● gs-showaccess.sh
#!/bin/bash
if [ "$1" = "--repo" ] && [ -n "${2:-}" ]; then
    REPO_SPEC="${2%.git}"
    echo "Users with access to ${REPO_SPEC}:"
    for f in /srv/git/access/*; do
        [ -f "$f" ] || continue
        user=$(basename "$f")
        if grep -q "^${REPO_SPEC} " "$f" 2>/dev/null; then
            perm=$(grep "^${REPO_SPEC} " "$f" | awk '{print $2}')
            echo "  ${user}  [${perm}]"
        fi
    done
elif [ -n "${1:-}" ]; then
    ACCESS_FILE="/srv/git/access/$1"
    [ -f "$ACCESS_FILE" ] && [ -s "$ACCESS_FILE" ] || {
        echo "No access entries for '$1'."; exit 0; }
    echo "Access for $1:"
    grep -v '^#' "$ACCESS_FILE" | grep -v '^$' | while IFS=" " read -r repo perm; do
        echo "  ${repo}  [${perm}]"
    done
else
    echo "Usage: gs-showaccess.sh <username>"
    echo "       gs-showaccess.sh --repo <owner/reponame>"
fi
●●● gs-removeuser.sh
#!/bin/bash
set -euo pipefail
[ "$EUID" -ne 0 ] && { echo "Must run as root."; exit 1; }
[ $# -lt 1 ] && { echo "Usage: gs-removeuser.sh <username> [--archive]"; exit 1; }

USERNAME="$1"
ARCHIVE=false
[ "${2:-}" = "--archive" ] && ARCHIVE=true

AUTH_KEYS="/home/git/.ssh/authorized_keys"
REPOS_DIR="/srv/git/repos/${USERNAME}"
ACCESS_FILE="/srv/git/access/${USERNAME}"

grep -q "gs-user=${USERNAME}$" "$AUTH_KEYS" 2>/dev/null || {
    echo "User '${USERNAME}' not found."; exit 1; }

sed -i "/gs-user=${USERNAME}$/d" "$AUTH_KEYS"
[ -f "$ACCESS_FILE" ] && rm "$ACCESS_FILE"

for f in /srv/git/access/*; do
    [ -f "$f" ] && sed -i "/^${USERNAME}\//d" "$f"
done

if [ -d "$REPOS_DIR" ]; then
    if $ARCHIVE; then
        DEST="/srv/git/archive/${USERNAME}_$(date +%Y%m%d_%H%M%S)"
        mv "$REPOS_DIR" "$DEST"
        echo "Repos archived to: $DEST"
    else
        echo "WARNING: Permanently delete all repos for '${USERNAME}'?"
        read -rp "Type DELETE to confirm: " CONFIRM
        [ "$CONFIRM" = "DELETE" ] && rm -rf "$REPOS_DIR" && echo "Repos deleted." || echo "Cancelled."
    fi
fi
echo "User '${USERNAME}' removed."
●●● gs-updatekey.sh
#!/bin/bash
set -euo pipefail
[ "$EUID" -ne 0 ] && { echo "Must run as root."; exit 1; }
[ $# -ne 2 ] && { echo "Usage: gs-updatekey.sh <username> <new-pubkey-file>"; exit 1; }

USERNAME="$1"
PUBKEY_FILE="$2"
AUTH_WRAPPER="/opt/gitserver/bin/git-auth.sh"
AUTH_KEYS="/home/git/.ssh/authorized_keys"

grep -q "gs-user=${USERNAME}$" "$AUTH_KEYS" 2>/dev/null || {
    echo "User '${USERNAME}' not found."; exit 1; }
[ -f "$PUBKEY_FILE" ] || { echo "Key file not found: $PUBKEY_FILE"; exit 1; }

PUBKEY=$(tr -d '\n\r' < "$PUBKEY_FILE")
[[ "$PUBKEY" =~ ^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|sk-ssh-ed25519|sk-ecdsa-sha2-nistp256)[[:space:]]+[A-Za-z0-9+/]+=* ]] || {
    echo "Invalid public key format."; exit 1; }

sed -i "/gs-user=${USERNAME}$/d" "$AUTH_KEYS"
KEYLINE="command=\"${AUTH_WRAPPER} ${USERNAME}\",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${PUBKEY} gs-user=${USERNAME}"
echo "$KEYLINE" >> "$AUTH_KEYS"
echo "Key updated for '${USERNAME}'."
●●● gs-listusers.sh
#!/bin/bash
AUTH_KEYS="/home/git/.ssh/authorized_keys"
[ -f "$AUTH_KEYS" ] || { echo "No users registered."; exit 0; }

echo "Registered users:"
grep -oP "gs-user=\K[^\s]+" "$AUTH_KEYS" 2>/dev/null | sort | while read -r user; do
    count=0
    [ -d "/srv/git/repos/${user}" ] && \
        count=$(find "/srv/git/repos/${user}" -maxdepth 1 -name "*.git" -type d 2>/dev/null | wc -l)
    printf "  %-20s  %d repo(s)\n" "$user" "$count"
done
# Set permissions on all scripts
chmod 755 /opt/gitserver/bin/*.sh
chown root:root /opt/gitserver/bin/*.sh

Phase 6 — Post-receive hook

Phase 6

This hook runs on the server after every successful push. It logs push activity to a central log file. The hook is installed as a template; new repositories get it automatically via gs-createrepo.sh.

●●● /opt/gitserver/hooks/post-receive
#!/bin/bash
REPO_DIR=$(pwd)
REPO_NAME=$(basename "$REPO_DIR" .git)
REPO_OWNER=$(basename "$(dirname "$REPO_DIR")")
LOG_FILE="/var/log/gitserver/push.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

while read -r OLDREV NEWREV REFNAME; do
    BRANCH="${REFNAME#refs/heads/}"
    if [ "$OLDREV" = "0000000000000000000000000000000000000000" ]; then
        ACTION="new-branch"
    elif [ "$NEWREV" = "0000000000000000000000000000000000000000" ]; then
        ACTION="delete-branch"
    else
        ACTION="push"
    fi
    echo "[${TIMESTAMP}] ${ACTION} repo=${REPO_OWNER}/${REPO_NAME} branch=${BRANCH} old=${OLDREV:0:8} new=${NEWREV:0:8}" >> "$LOG_FILE"
done
chmod 755 /opt/gitserver/hooks/post-receive
chown root:root /opt/gitserver/hooks/post-receive

# Install into all existing repos (and automatically into all future repos)
gs-install-hooks.sh

Phase 7 — Apache virtual host additions

Phase 7

Add the following inside your existing SSL <VirtualHost *:443> block, before the closing </VirtualHost> tag:

<VirtualHost *:443>
    # ... your existing config ...

    # SSH key upload form (self-service for new users)
    Alias /addkey /var/www/gitserver/addkey
    <Directory /var/www/gitserver/addkey>
        Options -Indexes -ExecCGI
        AllowOverride None
        Require all granted
    </Directory>

    # Gitweb repository browser
    Alias /gitweb /usr/share/gitweb
    <Directory /usr/share/gitweb>
        Options +ExecCGI -Indexes
        AddHandler cgi-script .cgi
        DirectoryIndex gitweb.cgi
        AllowOverride None
        Require all granted
    </Directory>
</VirtualHost>
apache2ctl configtest
systemctl reload apache2

Phase 8 — Gitweb configuration

Phase 8
●●● /etc/gitweb.conf
$projectroot = "/srv/git/repos";
$site_name   = "mikegit.com";
our @git_base_url_list = ("git\@mikegit.com");

$feature{highlight}{default} = [1];
$feature{blame}{default}     = [1];
$feature{pickaxe}{default}   = [1];
$feature{search}{default}    = [1];
a2enconf gitweb
apache2ctl configtest
systemctl reload apache2

# Optional: enable syntax highlighting in gitweb
apt install highlight
Gitweb runs as www-data. The usermod -aG git www-data step in Phase 2 gives it read access to the repos. If gitweb cannot read a repo, verify: id www-data shows git in the groups, and ls -la /srv/git/repos/ shows git as group owner with g=rX permissions.

Phase 9 — fail2ban

Phase 9
# fail2ban was installed in Phase 1. Create a custom jail config:
cat > /etc/fail2ban/jail.d/gitserver.conf << 'EOF'
[sshd]
enabled  = true
port     = ssh
maxretry = 5
bantime  = 3600
findtime = 600

[sshd-aggressive]
enabled  = true
port     = ssh
filter   = sshd
maxretry = 3
bantime  = 86400
findtime = 3600
EOF

systemctl enable fail2ban
systemctl restart fail2ban
fail2ban-client status sshd

# Useful day-to-day commands:
fail2ban-client status sshd           # show banned IPs
fail2ban-client set sshd unbanip <IP> # unban a specific IP

User onboarding

Once your server is set up, new users need to submit an SSH public key to get access. Share these steps with anyone who needs access to a repository.

Step 1 — Generate an SSH key (skip if you already have one):
  ssh-keygen -t ed25519 -C "yourname@yourdomain.com"
  # Accept default path: ~/.ssh/id_ed25519
  # Set a passphrase when prompted

Step 2 — Display your public key:
  cat ~/.ssh/id_ed25519.pub
  # Output: ssh-ed25519 AAAAC3Nza... yourname@yourdomain.com
  # Send this entire line to the server admin

Step 3 — Optional: add ~/.ssh/config entry:
  Host mikegit
      HostName mikegit.com
      User git
      IdentityFile ~/.ssh/id_ed25519
      IdentitiesOnly yes

Step 4 — Test your connection after key is approved:
  ssh -T git@mikegit.com
  # Expected: mikegit.com: Hello alice. Interactive shell access is not permitted.

Step 5 — Use git normally:
  git clone git@mikegit.com:alice/project.git
  git push / git pull  # no password ever required

Per-repository access control

Each user’s access file at /srv/git/access/<username> lists one repository per line with a permission level:

# /srv/git/access/alice
alice/project1 rw   ← alice can push and pull her own repo
bob/sharedlib  ro   ← alice can only pull from bob's repo
carol/infra    rw   ← alice has full access to carol's infra repo

A user with no access file, or whose file has no matching entry, gets “Access denied” before any git command runs. The repo’s existence is not revealed.

# Grant alice rw access to bob's webapp repo
gs-grantaccess.sh alice bob/webapp rw

# Grant carol read-only access to alice's project1
gs-grantaccess.sh carol alice/project1 ro

# Revoke bob's access to alice/project1
gs-revokeaccess.sh bob alice/project1

# Show all repos a user can access
gs-showaccess.sh alice

# Show all users who can access a repo
gs-showaccess.sh --repo alice/project1

Quick reference

User management:
  gs-adduser.sh    <username> <pubkey-file>
  gs-removeuser.sh <username> [--archive]
  gs-updatekey.sh  <username> <new-pubkey-file>
  gs-listusers.sh

Repository management:
  gs-createrepo.sh <username> <reponame> [--description "text"]
  gs-deleterepo.sh <username> <reponame> [--archive]
  gs-listrepos.sh  [username]

Access control:
  gs-grantaccess.sh  <username> <owner/reponame> [rw|ro]
  gs-revokeaccess.sh <username> <owner/reponame>
  gs-showaccess.sh   <username>
  gs-showaccess.sh   --repo <owner/reponame>

Pending key approvals (from web form):
  gs-approve-pending.sh

Service management:
  systemctl reload apache2          # after Apache config changes
  systemctl reload sshd             # after sshd_config changes
  tail -f /var/log/gitserver/push.log   # live push log
  tail -f /var/log/auth.log         # live SSH auth log

Key file locations:
  /home/git/.ssh/authorized_keys    # all user public keys
  /srv/git/repos/<user>/<repo>.git  # bare repositories
  /srv/git/access/<username>        # per-user repo access list
  /opt/gitserver/bin/               # admin scripts
  /etc/gitweb.conf                  # gitweb configuration

Checklist

Setup (Phases 1-4):
  [ ] Packages installed: git gitweb php libapache2-mod-php fail2ban
  [ ] git system user created with git-shell as login shell
  [ ] Directory structure created with correct permissions
  [ ] www-data added to git group: usermod -aG git www-data
  [ ] sshd_config updated with Match User git block
  [ ] sshd -t shows no errors; sshd reloaded
  [ ] Root SSH still works (tested from second terminal)
  [ ] git-auth.sh created and chmod 755

Admin scripts (Phase 5):
  [ ] All gs-*.sh scripts created in /opt/gitserver/bin/
  [ ] chmod 755 and chown root:root applied to all scripts
  [ ] /opt/gitserver/bin on root's PATH

Hooks and web (Phases 6-8):
  [ ] post-receive hook created and gs-install-hooks.sh run
  [ ] Apache vhost updated with gitweb and addkey aliases
  [ ] /etc/gitweb.conf created
  [ ] apache2ctl configtest passes; Apache reloaded

Security:
  [ ] fail2ban running: fail2ban-client status sshd
  [ ] ssh -T git@yourdomain.com returns welcome message, not a shell
  [ ] No repos are world-readable: find /srv/git/repos -perm /o=r returns nothing
  [ ] /home/git/.ssh permissions: 700 for dir, 600 for authorized_keys

Functional test:
  [ ] gs-adduser.sh testuser /tmp/testuser.pub succeeds
  [ ] gs-createrepo.sh testuser testrepo succeeds
  [ ] git clone git@yourdomain.com:testuser/testrepo.git succeeds
  [ ] git push works; push.log shows the event
  [ ] gs-removeuser.sh testuser cleans up
top