spinney / docs

Putting it on a server, from a fresh VPS to a hardened setup with backups

Deploying spinney

This takes a fresh VPS to a finished git server:

browse and clone:  https://git.example.com          Caddy (HTTPS) → Anubis → spinney
push:              git@server.your-tailnet.ts.net    Tailscale → SSH → git account
admin:             alice@server.your-tailnet.ts.net  Tailscale → SSH → sudo

The web site is the only thing open to the internet. SSH, both your admin logins and your pushes, is reachable only from your Tailscale network (your "tailnet"). Without Tailscale, SSH stays open to the internet but is still key-only and limited to known accounts.

One command does the work, from your own machine: ./install.sh root@your-server. Steps 1–3 below prepare for it; step 4 runs it. Throughout, replace git.example.com with your domain and alice with your user name.

What you need

On a VPS that already runs other services: the hardening closes every port except 80 and 443 (and SSH, unless it moves to Tailscale).

1. Prepare on your machine

In the spinney folder, edit the site's settings and about page:

$EDITOR config/spinney.toml     # title, description; see config/README.md
$EDITOR config/about.md         # who you are and what's here (or remove `about` from the config)

You can leave the title and clone address at their placeholders: the deploy fills them in from your domain. Check the file with go run . -check.

2. DNS and the provider's firewall

  1. At your DNS provider, create an A record for git.example.com with the VPS's IPv4 address, and an AAAA record with its IPv6 address if it has one. On Cloudflare, set the record to DNS only (grey cloud): through Cloudflare's proxy, Anubis would see every visitor as Cloudflare.
  2. If the provider has its own firewall (DigitalOcean Cloud Firewall, Hetzner Cloud Firewall, AWS security groups, …), allow incoming TCP 22, 80, 443 and UDP 443 for now. With Tailscale you remove 22 again in step 5.

Check that DNS has arrived:

dig +short git.example.com      # must print the VPS address

3. Bring the server up to date

ssh root@VPS
apt update && apt full-upgrade -y
reboot                          # if a new kernel was installed

4. Deploy

From the spinney folder on your machine:

./install.sh root@git.example.com      # or an ssh alias, or ssh://root@host:2222

It connects once and asks (Enter takes the default in brackets):

    Domain name for the site [git.example.com]:
    Email for Let's Encrypt notices (optional) []:
    Put Anubis bot protection in front [Y/n]:
    Anubis challenge: nojs (no JavaScript) or pow (proof-of-work) [nojs]:
    Public key files allowed to push, comma-separated (none: no pushing) [~/.ssh/id_ed25519.pub]:
    Also allow the SSH keys of GitHub users to push, comma-separated (none: no) [none]:
    Refuse pushes that rewrite or delete branches [Y/n]:
    Let push keys rename and delete repositories [y/N]:
    Harden the server (firewall, key-only SSH, automatic updates) [Y/n]:
    SSH and pushes only over Tailscale (only the web site stays public) [Y/n]:
    Tailscale auth key:
    Admin account to log in as instead of root (none: keep using root) [alice]:
    Sudo password for alice:
    Again:
    Site title [git.example.com]:

The defaults are the most secure choices:

Then it shows a summary and asks once more. Nothing changes before that. When you confirm, it:

  1. fills in the title and clone address in config/spinney.toml and the domain in config/about.md where they are still placeholders, and saves your answers in config/deploy.env (never the key or password)
  2. builds spinney for the server's CPU and uploads it with config/ and your push keys to ~/spinney on the server
  3. runs the installer there (the exact command is kept as ~/spinney/install-command.sh)
  4. opens a new SSH connection, as the admin account and over Tailscale if you chose them, to check that you can still log in. If that fails, it undoes the SSH hardening over the connection it kept open, so you are never locked out
  5. checks that pushing works (ssh git@… list)

The last lines tell you how to reach the server from now on:

==> Done
    site: https://git.example.com
    admin: log in as alice and use sudo
    ssh:  only over Tailscale: ssh alice@server.your-tailnet.ts.net
    push: git remote add origin git@server.your-tailnet.ts.net:NAME.git  (new names are created on push)
    deploy again (same answers): ./install.sh alice@server.your-tailnet.ts.net --yes

From then on you deploy as the admin account; the installer runs with sudo, which asks for its password.

--dry-run shows the whole run without changing anything, on your machine or the server. Flags answer questions in advance: --domain, --email, --anubis, --challenge pow, --push-key FILE, --github-keys USER, --deny-force-push, --manage-repos, --self-update, --harden, --tailscale / --no-tailscale, --tailscale-key FILE, --admin-user NAME. With --yes, give the Tailscale key with --tailscale-key FILE or TS_AUTHKEY; no sudo password is asked, so root logins stay on until you set one (sudo passwd alice) and deploy again. On macOS, run the script with a newer bash (brew install bash).

5. After deploying

  1. Turn off key expiry for the server in the Tailscale admin console (Machines → the server's menu → Disable key expiry), unless you used a tagged key. Otherwise the server drops off the tailnet when its key expires (after 180 days by default), and SSH with it.
  2. Close SSH in the provider's firewall too: remove TCP 22 from the rules of step 2. The server's own firewall already drops it; this adds a third lock. Keep the provider's recovery console as the way in.
  3. Check from outside the tailnet (turn Tailscale off for a moment): nc -vz -w5 git.example.com 22 should time out, and curl -sI https://git.example.com should answer 200.
  4. Give the server a short SSH name and push your first project: see usage.md.

6. Backups

Nothing backs up /srv/git for you. Use one or both of these.

Your provider's backups (DigitalOcean: Droplet → Backups → Enable) snapshot the whole server and restore it in one click.

A daily copy on your own machine, with a key that can only read /srv/git, so a stolen laptop gives no shell on the server:

ssh-keygen -t ed25519 -N "" -C git-backup -f ~/.ssh/git-backup
ssh alice@server.your-tailnet.ts.net sudo apt install -y rsync
echo "restrict,command=\"/usr/bin/rrsync -ro /srv/git\" $(cat ~/.ssh/git-backup.pub)" |
  ssh alice@server.your-tailnet.ts.net 'cat >> ~/.ssh/authorized_keys'

~/.config/systemd/user/git-backup.service:

[Unit]
Description=Copy the git server's repositories

[Service]
Type=oneshot
ExecStart=/usr/bin/rsync -a -e "ssh -i %h/.ssh/git-backup -o IdentitiesOnly=yes -o BatchMode=yes" alice@server.your-tailnet.ts.net:/ %h/backups/git/

~/.config/systemd/user/git-backup.timer:

[Unit]
Description=Daily copy of the git server's repositories

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=30m

[Install]
WantedBy=timers.target
mkdir -p ~/backups/git
systemctl --user daemon-reload
systemctl --user enable --now git-backup.timer
systemctl --user start git-backup.service && ls ~/backups/git

There is no --delete, so a repository deleted on the server stays in the backup. A missed day runs the next time the machine is on. Test a restore once: git clone ~/backups/git/project.git /tmp/restore-test.

Day to day

Task How
Change a setting, the about page or the icon Edit the files in config/, then ./update.sh. If the config was edited on the server, it stops and shows the difference; ./update.sh --force overwrites it.
Update spinney git pull in the spinney folder, then ./update.sh. Or, on a server installed with --self-update: sudo spinney-update there (see below).
Add or remove a push key Run ./install.sh alice@server… without --yes and change the key list (it is the complete list).
Let another account log in over SSH On the server: ~/spinney/install-command.sh --ssh-allow NAME (remembered from then on).
Reboot after kernel updates Updates install by themselves but never reboot. When /var/run/reboot-required exists: sudo reboot.
Keep a repository off the web site ssh git@server… private NAME on (or create NAME --private before the first push); it's gone within 15 seconds, no deploy needed. hidden = true under [repos."NAME"] in the config does the same through a deploy.
Change the theme or fonts [theme] (and your own [themes]) in config/spinney.toml (see config/README.md), then ./update.sh. Fonts are downloaded once into /var/cache/spinney/fonts; a server installed before [theme] existed needs ./install.sh alice@server… --yes once to create that folder (until then fonts are downloaded again on every restart).
Follow a repository Add it under [mirrors] in config/spinney.toml (see config/README.md), then ./update.sh. The first [mirrors] entry, and removing the last one, need ./install.sh alice@server… --yes instead, which sets up or removes the timer.
Logs journalctl -u spinney -u anubis@spinney -u caddy -f (followed repositories: -u spinney-mirror)

./update.sh (or ./update.sh alice@server…; without it, the server install.sh last deployed to) builds spinney, uploads it with config/ and restarts it, and touches nothing else: no packages, Caddy, Anubis, SSH or firewall. It checks the config with the new build first and changes nothing if it's invalid. It also leaves the config alone if someone edited it on the server (in /etc/spinney) since the last upload: it shows how server and local differ and stops. Copy the change into config/ and run it again, or run ./update.sh --force to upload yours over it (the server's version is kept as .bak). Use ./install.sh … --yes for anything else, such as the domain, Anubis, push keys or hardening.

Updating from the server itself

With --self-update (or a yes to the deploy question, default no), the server gets spinney-update, a root-only command that updates spinney without your machine:

ssh -t alice@server… sudo spinney-update            # the newest release
ssh -t alice@server… sudo spinney-update --check    # only show what's new
ssh -t alice@server… sudo spinney-update v0.1.0     # that release, older ones too

(-t gives sudo a terminal to ask for the password on.)

It fetches the release tags from https://git.spinney.dev/spinney (or --update-from URL), builds the newest one with Go (installed by the installer, a pinned and checksum-verified release) as a throwaway unprivileged user, and prints the release notes. Read their "Upgrading" part: some releases need install.sh as well. It then checks /etc/spinney/spinney.toml with the new build and switches to it only if the config passes. If the new build doesn't answer after the restart, the previous one is put back. It never changes the config.

Without a release named, it only moves forward. It stops when the running spinney is newer than the newest release, or wasn't built from one (a deploy with update.sh from a checkout with commits or changes of your own). Naming a release switches anyway. The git account can't run it: replacing the running server stays with the admin, not with whoever holds a push key.

Re-running the installer is always safe. It keeps the repositories, the list of accounts allowed to log in, and Anubis's signing key, and applies whatever changed.

Moving over from gitshow

spinney was called gitshow before its first release. A server installed then has gitshow.service, /etc/gitshow, gitshow-update and so on. To move it over:

  1. In your checkout, rename config/gitshow.toml to config/spinney.toml (install.sh and update.sh stop with a reminder until you do).
  2. Run ./install.sh alice@server… --yes once. Before installing, it stops the old services and moves every file to its new name: the config, the font cache, Anubis's settings and signing key, the Caddy site, the push keys and the SSH and kernel hardening (with its list of allowed accounts). It then removes the old units, the old binary, gitshow-update (--self-update installs spinney-update in its place) and the gitshow service account. The repositories don't move.
  3. Afterwards, gitshow/ in your home on the server is the old upload folder; the installer says so, and you can delete it.

Until then, update.sh refuses to update a gitshow server. Repositories made private with gitshow (gitshow.private in their config) stay private.

Installing on the server by hand

Instead of ./install.sh USER@HOST, you can copy the files and run the installer on the server. Build for the server first; CGO_ENABLED=0 matters, or the binary may not start on an older distribution:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o spinney .   # GOARCH=arm64 for ARM
cp ~/.ssh/id_ed25519.pub push-keys.pub                          # one line per key allowed to push
ssh root@VPS mkdir -p spinney
scp -r spinney install.sh config push-keys.pub root@VPS:spinney/

On the server, keep the command in a script so every update uses the same flags, and start without the hardening:

cd ~/spinney
cat > install-command.sh <<'EOF'
#!/bin/sh
cd "$(dirname "$0")" && exec ./install.sh --domain git.example.com --anubis \
    --push-key push-keys.pub --deny-force-push "$@"
EOF
chmod +x install-command.sh
./install-command.sh --dry-run     # optional: see what it would do
./install-command.sh

Check the site in a browser and push a project (see usage.md). Then add the hardening. Keep this SSH session open until a second one works:

sed -i 's/--deny-force-push/--deny-force-push --harden --admin-user alice/' install-command.sh
./install-command.sh
passwd alice                       # the password sudo will ask for
./install-command.sh               # with a password set, root logins go off

From a second terminal: ssh alice@VPS sudo true. To move SSH onto Tailscale as well, put the auth key in a file only root can read and pass it once:

(umask 077 && cat > ts-authkey)    # paste the key, then Ctrl-D
sed -i 's/--harden/--harden --tailscale/' install-command.sh
./install-command.sh --tailscale-key ts-authkey && rm ts-authkey

Then check ssh alice@server.your-tailnet.ts.net sudo true from a machine in the tailnet before closing the first session.

Installer options

Option Effect
--domain HOST Caddy with automatic HTTPS for this name
--email ADDR email for Let's Encrypt notices
--no-caddy no Caddy even with --domain
--anubis, --challenge nojs|pow Anubis in front of spinney, and its browser check
--push-key FILE keys allowed to push (repeatable; replaces the list)
--github-keys USER also allow USER's keys from https://github.com/USER.keys (repeatable; fetched on every run)
--deny-force-push refuse pushes that rewrite or delete branches
--manage-repos let push keys rename and delete repositories over SSH (off: a stolen key could erase one)
--self-update, --update-from URL install spinney-update, which builds the newest release on the server and switches to it; releases from URL (https, default https://git.spinney.dev/spinney)
--harden firewall, key-only SSH for listed accounts, automatic updates, kernel settings, Caddy admin API on a root-only socket
--ssh-allow USER with --harden: also let this account log in
--admin-user NAME admin account with sudo; with --harden, root logins go off once it has a key and a password
--admin-key FILE, --admin-password-hash FILE that account's keys (default: those of whoever runs the installer; when deploying, those root logs in with) and password hash
--tailscale, --tailscale-key FILE with --harden: join the tailnet and accept SSH only from it
--undo-ssh-hardening back to the system's own SSH settings, SSH open in the firewall
--uninstall remove spinney, spinney-update, Anubis and the Caddy site (keeps repositories, config, the git account and the hardening)
--dry-run print every step without changing anything

What gets installed

Part Where Notes
spinney /usr/local/bin/spinney, unit spinney.service, config /etc/spinney/, cache /var/cache/spinney runs as the spinney user, listens on 127.0.0.1:8080, sandboxed by systemd; writes only the cache ([theme] fonts)
Caddy distribution package, site in /etc/caddy/Caddyfile.d/spinney.caddyfile HTTPS from Let's Encrypt, HSTS, HTTP/3; a customised Caddyfile is kept
Anubis /usr/local/bin/anubis (a pinned, checksum-verified release), unit anubis@spinney listens on 127.0.0.1:8923
Repositories /srv/git folder root:git 1775, repositories owned by git
Followed repositories units spinney-mirror.service and .timer only with [mirrors] in the config; runs as git every 30 minutes, writing only to /srv/git
Self-update /usr/local/sbin/spinney-update, Go in /usr/local/lib/spinney-update/, clone and caches in /var/lib/spinney-update only with --self-update; root runs it, the build runs as a throwaway user (systemd DynamicUser)
git account home /srv/git, shell git-shell, keys in /etc/ssh/spinney_authorized_keys can only run git and a few commands
Hardening /etc/ssh/sshd_config.d/10-spinney-hardening.conf, ufw or firewalld, unattended-upgrades or dnf-automatic, /etc/sysctl.d/99-zz-spinney-hardening.conf, /etc/modprobe.d/spinney-hardening.conf
Tailscale from Tailscale's signed package repository

security.md explains how these fit together.

Undoing the hardening

If SSH ever locks you out, use the provider's recovery console (or a session that is still open):

sudo ~/spinney/install.sh --undo-ssh-hardening

This returns sshd to the system's own settings (root and password logins as they were before) and opens SSH in the firewall again. If that isn't enough, turn the firewall off: sudo ufw disable (on Fedora, sudo systemctl stop firewalld). Fix the problem, then deploy again.

To remove spinney: sudo ~/spinney/install.sh --uninstall.

Troubleshooting

Symptom Likely cause and fix
No certificate, browser warning DNS doesn't point at the server yet, or 80/443 are closed in the provider's firewall. journalctl -u caddy says which; Caddy retries by itself.
502 Bad Gateway spinney or Anubis isn't running: systemctl status spinney anubis@spinney.
SSH times out With Tailscale: your machine isn't in the tailnet, or the server dropped off it (key expiry). Check tailscale status.
Permission denied (publickey) The key isn't allowed. For pushing, compare ssh-add -L with /etc/ssh/spinney_authorized_keys.
Root can't log in Expected with an admin account: log in as it and use sudo. Root logins stay off on later runs; --undo-ssh-hardening turns them back on.
Too many authentication failures Your SSH agent offers several keys and the server allows 3 tries. Set IdentitiesOnly yes and the right IdentityFile for the server in ~/.ssh/config (see usage.md).
A "Making sure you're not a bot" page That's Anubis. It continues by itself after a moment. git clone and curl are never checked.
install.sh: … is invalid A typo in config/spinney.toml; the message names the key. Check with go run . -check.
Another service on the server is unreachable The firewall closed its port: sudo ufw allow PORT/tcp, or on Fedora sudo firewall-cmd --permanent --add-port=PORT/tcp && sudo firewall-cmd --reload.
Fedora: pushes or the site fail Possibly SELinux, which is untested in enforcing mode: sudo ausearch -m avc -ts recent.