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 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.
- 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.
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-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 |
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
- 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.
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
- 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.