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
- Server provisioning (once, per box):
- Debian with Tailscale + Caddy already running.
- SSH access as an unprivileged user with sudo;
~/repos/owned by that user.
- 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
- First push:
GIT_HOST=user@host ./push— copiesbin/,assets/,caddy/to~/repos/and writes~/repos/config.envon the server. - Bunny key on server (once): write the Storage Zone access key to
~/repos/bunny.key,chmod 600. Without this,publish-publicno-ops. - 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/*.caddyto/etc/caddy/Caddyfile - reloads caddy, runs first
stagit-update
- installs
- 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.
- 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:
- private →
ssh://USER@$GIT_HOSTNAME/~/repos/<name>.git - public →
https://$PUBLIC_DOMAIN/git/<name>.git
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:
git update-server-info(for dumb-HTTP clone on public repos)stagit-update <name>— regenerates HTML inwww/, and if the repo has apublicmarker, also inwww-public/+ mirrors the bare repo intowww-public/git/<name>.git/(excludinghooks/and thepublicmarker).publish-public— PUTs everything inwww-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-receive → update-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
- Private:
http://$GIT_HOSTNAME/on the tailnet — lists all repos. Caddy'sremote_ipmatcher rejects anything outside100.64.0.0/10+fd7a:115c:a1e0::/48with a 403. - Public:
https://$PUBLIC_DOMAIN/— lists only public repos.
Cloning
- Private:
git clone ssh://user@$GIT_HOSTNAME/~/repos/<name>.git - Public:
git clone https://$PUBLIC_DOMAIN/git/<name>.git(dumb-HTTP via Bunny CDN, fromwww-public/git/<name>.git/).
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
- Public repo shows stale refs after push. Confirm the Bunny edge rule
applied:
curl -I https://$PUBLIC_DOMAIN/git/<name>.git/info/refsshould showcdn-cache: BYPASSandmax-age=0. stagit not installed yetwarning frompush. You haven't runbringupyet.publish-public: ~/repos/bunny.key missing. Write the key file on the server.- 403 on private tailnet URL. Not on the tailnet, or tailnet IP ranges in
caddy/git.caddydon't match your network. - Everything looks broken, rebuild from truth (bare repos are the source):
rm -rf ~/repos/www ~/repos/www-public/* && ~/repos/bin/stagit-update.