Soft Serve + Tailscale

2026 Mar 10 | 1539 words

in this post

I'm quite happy with my primary Git host, Codeberg, which also serves this website via Pages. I mirror Ephem and my Neovim colorschemes to GitHub for discoverability, but overall I enjoy the silence and lack of Copilot. However, besides the very premise of free and open software being public availability, I really am almost exclusively a maintainer rather than contributor. Not for lack of trying, mind you! But this means all the social aspects of web UIs like GitHub and Gitea and Forgejo are noise I don't really need.

Recently, Codeberg had a few hours of downtime due to a DDoS attack while I was in the middle of extending my colorscheme builds to automate port generation, which didn't affect me in any meaningful way as a solo developer. My local commits were made and perfectly safe, even though I knew upstream had a single tag I forgot to pull. It did occur to me, however, that it's pretty silly to rely on Codeberg as a remote when switching between clients that all share a tailnet. I could have used rsync or scp or tailscale file cp, but this seems contrary to Git. Another wrinkle is I source even my own Neovim plugins from a remote unless I'm actively developing them, in which case I add their local directories to my runtimepath. Up until this I used Lazy for plugin management, but if Lazy it can't reach a remote to check for updates at startup, it complains via notification.

my setup

I work on Ephem and pysweph on my devbox, a clamshelled MacBook Air on a shelf with a headless Debian 13 installation. I can always access this machine thanks to Tailscale, but other projects are better done locally, like previewing this website with python3 -m http.server 8000 and ports for my Neovim colorschemes that need testing on a display server.

Treating my devbox as some source of truth has been imprecise when it has historically been a peer to my other clients because it had the same remotes for all my repositories. My attempts at self-hosting on this machine before this were a Gitea instance I didn't have a need for at the time and Miniflux, both in Docker containers, so the devbox has really mostly been a long-distance computer rather than a server.

soft serve

Git itself is simple and usable as a CLI. It consists of branches, commits, merges, tags, and hooks. CI like GitHub Actions extend and outsource hooks to the cloud; pull requests add a front-end to merges; releases are tags with source distributions and longer messages. Apart from CI that requires infrastructure, I really don't have a lot of needs:

Knowing Git is simple doesn't mean I want to tangle with bare repositories1 or access a web UI, so Soft Serve is a logical in-between. It's a self-hostable Git server with a Charm-style, clickable TUI. I set it up just as a systemd service, sans Docker though they do make an image, with the following service unit:

[Unit]
Description=Soft Serve git server 🍦
Requires=network-online.target
After=network-online.target

[Service]
Type=simple
Restart=always
RestartSec=1
ExecStart=/usr/bin/soft serve
Environment=SOFT_SERVE_DATA_PATH=/home/sailorfe/.local/share/soft-serve
EnvironmentFile=/etc/soft-serve.conf
WorkingDirectory=/home/sailorfe/.local/share/soft-serve
User=sailorfe

[Install]
WantedBy=multi-user.target
$ systemctl daemon-reload
$ systemctl start soft-serve.service
$ systemctl status soft-serve.service
$ journalctl -u soft-serve.service
$ systemctl enable soft-serve.service

I didn't want to deal with the permissions headache of creating a system user to run Soft Serve from its own /home folder, so using XDG_DATA_HOME/soft-serve for my own user works fine. I updated my .ssh/config to have a softserve entry with my devbox's Tailscale IP as the host and Soft Serve's default port 23231. I then changed the origins of all local copies of my current repos to ssh://softserve/$REPO and git push --mirror'd them onto Soft Serve.

# ~/.ssh/config
Host softserve
  HostName 100.125.176.126  # my tailscale IP
  Port 23231
$ git set-url origin ssh://softserve/perona.nvim
$ git remote -v
origin  ssh://softserve/perona.nvim (fetch)
origin  ssh://softserve/perona.nvim (push)
$ git push --mirror -u origin main

server-side hooks

As far as I can tell, it's impossible to set hooks for a repository hosted on a server you don't own. Git ignores the .git/hooks directory, so you can't really push your local hooks upstream, nor should you if you have collaborators. Cloud-based CI like GitHub Actions more or less runs hooks on VMs or containers on your behalf while local (per-repo) and global (per-user) hooks run client-side.

The goal of this is migration is to treat Soft Serve as truth and have it interact with public remotes so I don't have to do it locally. At minimum, all of my active public repositories have Codeberg remotes, so I added these to their bare repositories in my SOFT_SERVE_DATA_PATH.

$ cd .local/share/soft-serve/repos/"$REPO".git
$ git remote add codeberg git@codeberg.org:"$USER"/"$REPO".git
# if applicable:
$ git remote add github git@github.com:"$USER":"$REPO".git

Then most of them get this hook.

# .local/share/soft-serve/repos/../hooks/post-receive.d/mirror
#!/usr/bin/env bash
LOGFILE="$HOME/.local/share/soft-serve/mirror.log"

while read old new ref; do
    [[ "$ref" == "refs/heads/main" ]] || continue
    git push codeberg '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' >> "$LOGFILE" 2>&1 &
#   git push github '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' >> "$LOGFILE" 2>&1 &
done

I'm avoiding git push --mirror except for my dotfiles because despite my solo maintenance, I still welcome contributions, and --mirror is destructive and deletes all remote refs that don't exist locally. Publishing this blog post is my test of this repo's new deploy hook, which just runs my make deploy command in a local directory on my devbox, also post-receive:

# .local/share/soft-serve/repos/pages.git/hooks/post-receive.d/deploy
#!/usr/bin/env bash
export PATH="$HOME/.local/bin:$PATH"

SITE_DIR="$HOME/p/www/pages"
LOGFILE="$HOME/.local/share/soft-serve/deploy.log"

while read old new ref; do
    [[ "$ref" == "refs/heads/dev" ]] || continue
    unset GIT_DIR GIT_WORK_TREE
    git -C "$SITE_DIR" pull && make -C "$SITE_DIR" deploy
done >> "$LOGFILE" 2>&1

I get a sense that most people's local hooks are pre-commit linting or similar, which I'll look into for my Python projects with ruff. You can read about all the different hook types in the Git docs, but they're divided into client-side and server-side. It's a square-as-rectangle situation, of course, in that anything client-side can be implemented server-side too, but not vice versa.

Client-side

Server-side

on remote vim plugins

I was so excited about how fast my fetches and pushes and pulls were with Tailscale that I changed my Lazy setup to use ssh://softserve/ on my own plugins, which it cannot parse if passed as plain

-- doesn't work
return {
    "ssh://softserve/perona.nvim"
}

But I did a quick grep through the source2 and found you can actually do, in a similar way to dir = "",

-- *should* work; haven't tested
return {
    url = "ssh://softserve/perona.nvim"
}

Without url =, dir =, or https://, Lazy prepends https://github.com/ to a plain source string, which seems par for the course for Neo/vim plugin managers3. I was already fighting GitHub assumptions by fetching plugins from Codeberg instead, but I became annoyed enough by having to look up this sort of thing that I ditched Lazy altogether and switched to managing my plugins with Git submodules. It just made it clear that plugin managers obscure Git workflows that we can handle directly.

This isn't that casual a thing to do, I admit, but as of writing I only have 22 Neovim plugins. And I suppose a large part of this blog post is a sentiment of Give Up GitHub! (sic). For reference, this is from lazy/build.lua:

-- lazy.nvim/lua/lazy/build.lua
if url and url:find("://github.com/") then
    url = url:gsub("^.*://github.com/", "")
    local parts = vim.split(url, "/")
    url = parts[1] .. "/" .. parts[2]
    url = url:gsub("%.git$", "")
end

tl;dr my soft serve flow

I've only been in this for a week, but this is the rhythm, more or less.

# on client
$ git init
$ git remote add origin ssh://softserve/..
$ git push -u origin
$ ssh minimerry     # my devbox magic DNS

# on server, if open source
$ cd .local/share/soft-serve/repos/*.git
$ git remote add codeberg git@codeberg.org:sailorfe/repo
$ git remote add github git@github.com:sailorfe/repo
$ $EDITOR hooks/post-receive.d/mirror
$ chmod +x hooks/post-receive.d/mirror

Completely closed repos include personal notes and databases I prefer to version control that really have no business being on Codeberg anyway, and rough drafts that will get a public remote later rather than going to the hassle of creating a private repo in a web UI.


  1. Timothy Miller's post on this is interesting. He went the more "proper" systemd route of creating a Git user with useradd --system git

  2. In lazy/example.lua, referenced in the docs

  3. Tested with vim-plug. It may have some behavior like Lazy's url =, but I haven't found it.