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 Rules — two rules, both with Override Cache Time = 0 + Override Browser Cache Time = 0:
      • git no cache — matches */HEAD, */info/refs, */objects/info/packs (dumb-HTTP clone freshness).
      • stagit no cache — matches */index.html, */log.html, */files.html, */refs.html, */atom.xml, */tags.xml, */file/*, *style.css, and the site root (https://PUBLIC_DOMAIN / https://PUBLIC_DOMAIN/).
      • Content-addressed objects (/objects/<sha>/...) and commit/sha/tag archive tarballs cache normally.
      • Note: branch archives (*/archive/refs/heads/*) are rewritten on every push to that branch, so they will be stale at the edge until TTL expires. Add a third rule matching */archive/refs/heads/* if you care about branch-tarball freshness.
  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.

Remove a repo

ssh $GIT_HOST ~/repos/bin/remove-repo <name>

Deletes the bare repo, all stagit HTML (private + public), the public clone mirror, archive tarballs, and stagit caches. Rebuilds indexes and runs publish-public-prune to drop Bunny orphans. Requires typing the repo name to confirm.

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. Run ~/repos/bin/publish-public-prune to clean them up.

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
remove-repo deletes bare repo + HTML + archive + Bunny orphans
publish-public-prune deletes Bunny objects absent from local www-public/
Bunny CDN serves public repos automatically; edge rules enforce no-cache on git refs + stagit HTML

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. remove-repo handles this automatically; otherwise run:

ssh $GIT_HOST ~/repos/bin/publish-public-prune

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

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

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