gitserver

self-hosted git server tooling
git clone https://git.ryansepassi.com/git/gitserver.git
Log | Files | Refs | README

gitserver

Self-hosted git repo browsing + cloning. Private repos served over Tailscale via Caddy; public repos mirrored to a Bunny Storage Zone behind a Bunny Pull Zone CDN.

Everything on the server lives under ~/repos/:

~/repos/
├── <name>.git/           # bare repos (one per project)
├── bin/                  # tooling (synced from this repo via ./push)
├── assets/               # shared css/logo/favicon
├── caddy/                # caddy site snippet
├── www/                  # stagit output for ALL repos (tailnet-only)
├── www-public/           # stagit output + bare-repo mirror for PUBLIC repos
├── config.env            # GIT_HOSTNAME / PUBLIC_DOMAIN / BUNNY_ZONE
└── bunny.key             # chmod 600, storage zone access key

First-time setup

  1. Server provisioning (once, per box):
    • Debian with Tailscale + Caddy already running.
    • SSH access as an unprivileged user with sudo; ~/repos/ owned by that user.
  2. Local config: copy .env.example.env, fill in:
    • GIT_HOSTNAME — tailnet MagicDNS name (e.g. debian-wedding)
    • PUBLIC_DOMAIN — where public repos will be served (e.g. git.ryansepassi.com)
    • BUNNY_ZONE — Bunny Storage Zone name
  3. First push: GIT_HOST=user@host ./push — copies bin/, assets/, caddy/ to ~/repos/ and writes ~/repos/config.env on the server.
  4. Bunny key on server (once): write the Storage Zone access key to ~/repos/bunny.key, chmod 600. Without this, publish-public no-ops.
  5. Bringup (once): ssh $GIT_HOST ~/repos/bin/bringup
    • installs git rsync build-essential libgit2-dev curl
    • builds stagit from pinned source with SHA verification
    • appends import ~/repos/caddy/*.caddy to /etc/caddy/Caddyfile
    • reloads caddy, runs first stagit-update
  6. Bunny Pull Zone (once, via dashboard):
    • Create Pull Zone pointing at the Storage Zone.
    • Attach custom hostname (PUBLIC_DOMAIN), enable TLS.
    • Edge Rule — for git ref + branch-archive freshness, force no-cache on:
      • */HEAD
      • */info/refs
      • */objects/info/packs
      • */archive/refs/heads/*
      • Actions: Override Cache Time = 0 + Override Browser Cache Time = 0.
    • Everything else (content-addressed objects + sha/tag archives) caches normally.
  7. DNS (once): CNAME PUBLIC_DOMAIN<zone>.b-cdn.net.

Day to day

Add a repo

ssh $GIT_HOST ~/repos/bin/add-repo <name> [--public] [-d "description"]

Creates ~/repos/<name>.git, symlinks the post-receive hook, writes description and url, and kicks off an initial stagit-update. The url file determines the clone URL shown on the stagit page:

On the laptop, add it as a remote:

git remote add wedding ssh://user@debian-wedding/~/repos/<name>.git
git push -u wedding main

Push commits

Normal git push. The post-receive hook runs:

  1. git update-server-info (for dumb-HTTP clone on public repos)
  2. stagit-update <name> — regenerates HTML in www/, and if the repo has a public marker, also in www-public/ + mirrors the bare repo into www-public/git/<name>.git/ (excluding hooks/ and the public marker).
  3. publish-public — PUTs everything in www-public/ to Bunny via HTTPS.

All automatic; no manual step.

Flip a repo public / private

Public requires: the public marker file, and (for clean clone URL display) a correct url file.

# make public
ssh $GIT_HOST 'touch ~/repos/<name>.git/public \
  && echo https://git.ryansepassi.com/git/<name>.git > ~/repos/<name>.git/url \
  && ~/repos/bin/stagit-update <name>'

# make private again
ssh $GIT_HOST 'rm ~/repos/<name>.git/public \
  && echo ssh://user@debian-wedding/~/repos/<name>.git > ~/repos/<name>.git/url \
  && ~/repos/bin/stagit-update <name>'

stagit-update removes the public mirror when the marker is gone; the next publish-public run on the next push will still have stale files on Bunny — see "Cleanup" below.

Update tooling

Edit this repo, then ./push. That rsyncs bin/, assets/, caddy/ with --delete, rewrites the tailscale/hostname placeholders in caddy/*.caddy, reloads caddy, and runs stagit-update if stagit is already installed.

What runs when

| Trigger | What happens | | --------------------------------- | --------------------------------------------------------- | | ./push from laptop | sync tooling, write config.env, reload caddy, stagit-update | | git push <repo> to the server | post-receiveupdate-server-info + stagit-update + publish-public | | bringup on the server | one-time install (stagit, caddy snippet) | | add-repo | creates bare repo + hook + initial HTML | | Bunny CDN | serves public repos automatically; edge rules enforce no-cache on git refs |

Nothing runs on a schedule. There are no cron jobs, no systemd timers. Everything is push-driven.

Browsing

Cloning

Cleanup / recovery

publish-public PUTs only — it doesn't delete remote files that are absent locally. Orphans accumulate when files move or repos get un-publicized.

Full rebuild of www-public/ (safe; purely derived from ~/repos/*.git):

ssh $GIT_HOST 'rm -rf ~/repos/www-public/* && ~/repos/bin/stagit-update'

Delete Bunny orphans (when local was just rebuilt):

ssh $GIT_HOST '
  set -eu
  . ~/repos/config.env
  KEY=$(cat ~/repos/bunny.key)
  EP=https://storage.bunnycdn.com/$BUNNY_ZONE
  cd ~/repos/www-public
  find -L . -type f | sed "s|^\./||" | sort > /tmp/local.list
  python3 - <<PY > /tmp/remote.list
import json, urllib.request, os
KEY = open(os.path.expanduser("~/repos/bunny.key")).read().strip()
BASE = "'"$EP"'"
def ls(p):
    r = urllib.request.Request(BASE + "/" + p, headers={"AccessKey": KEY, "Accept": "application/json"})
    return json.loads(urllib.request.urlopen(r).read())
def walk(prefix):
    for o in ls(prefix):
        rel = (prefix + o["ObjectName"]).lstrip("/")
        if o["IsDirectory"]: walk(rel + "/")
        else: print(rel)
walk("")
PY
  comm -23 <(sort /tmp/remote.list) /tmp/local.list | while read -r f; do
    curl -sS -X DELETE "$EP/$f" -H "AccessKey: $KEY" -o /dev/null -w "DEL %{http_code} $f\n"
  done
'

Secrets layout

| Secret | Lives at | | ----------------------------- | ------------------------------------- | | SSH identity for server | laptop ~/.ssh/ + ~/.ssh/config | | GIT_HOSTNAME, PUBLIC_DOMAIN, BUNNY_ZONE | laptop .env (gitignored), server ~/repos/config.env (written by push) | | Bunny Storage Zone access key | server ~/repos/bunny.key (chmod 600) — never in .env, never synced |

Rotating the Bunny key: generate new one in dashboard, overwrite ~/repos/bunny.key, re-run ~/repos/bin/publish-public.

Troubleshooting