My Radicle Journey¶
Radicle is an open source, peer-to-peer code collaboration stack built on Git. Unlike centralized code hosting platforms, there is no single entity controlling the network. Repositories are replicated across peers in a decentralized manner, and users are in full control of their data and workflow.
We will initialize Radicle on MacOS building it from source.
Rust¶
Radicle is written in Rust, so first we need that.
The recommended approach is to manage Rust toolchain installation through rustup.
How do you get rustup? Of course, you can do curl | bash, but I manage most of software on my macbook through brew, so I’ll use that.
Note, however, that you should never install Rust through brew directly, as this is a recipe for disaster.
brew install rustup
Nothing good comes easy, we gotta update PATH manually:
Please note that Rust tools like rustc and cargo are not available via $PATH by default in this rustup distribution. You might want to add $(brew –prefix rustup)/bin to $PATH to make them easier to access.
Add this line to ~/.zshrc:
export PATH="/opt/homebrew/opt/rustup/bin:$PATH"
Open a fresh terminal to load ~/.zshrc and install stable toolchain:
rustup toolchain install stable
cargo --version
cargo 1.93.0 (083ac5135 2025-12-15)
Heartwood¶
Let’s get the source code for radicle.
We’ll use one of the official seed servers.
Apparently, the repo name is z3gqcJUoA1n9HaHKufZs5FCSGazv5, but we’ll call it heartwood to make life easier (because it’s Radicle.)
git clone https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git heartwood
cd heartwood
cargo install --path crates/radicle-cli --force --locked --root ~/.radicle
cargo install --path crates/radicle-node --force --locked --root ~/.radicle
cargo install --path crates/radicle-remote-helper --force --locked --root ~/.radicle
It will happily download and install whole bunch of dependencies, including cargo itself, which is okay, I guess, but also super confusing.
Now guess, what:
warning: be sure to add
$HOME/.radicle/binto your PATH to be able to run the installed binaries
Just a quick reminder, make sure you don’t forget that nothing good comes easy.
Update ~/.zshrc, open a fresh terminal.
Set up first node¶
First, we gotta create an identity:
rad auth
Follow the interactive wizard to complete the setup. I just pressed Return repeatedly to pick the default option.
I wonder if it’s possible and/or wise to use the same key for radicle and ssh. But I’ll use separate ones for now.
You might think that you’re all set and can clone your first repo, but let me remind you, nothing good comes easy. First, we gotta start the node. On a proper Linux, we’d be using SystemD Unit to run a daemon. But we’re on MacOS. So, let’s just YOLO it for now and figure the long-term solution later:
rad node start
The command launches a server in the background.
And now you might think you’re ready to go.
Since we have rad setup, we no longer need that silly HTTPS git repo of heartwood.
Let’s replace it with a decentralized copy:
cd ..
rm -rf heartwood
rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood
I don’t know about you, maybe it’s a bad luck, but for me the result of rad clone is:
✓ Seeding policy updated for rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 with scope 'all'
Fetching rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 from the network, found 18 potential seed(s).
✗ 0 of 2 preferred seeds, and 0 of at least 1 total seeds… [fetch z6Mkmqo…4ebScxo@rosa.radicle.xyz:8776] <canceled>
✗ Error: fetch: timed out reading from control socket
Let’s see what’s going on with the node:
rad node status
✓ Node is running with Node ID z6Mkno75gzNU1Y59EL9rj8nUVveBUj3kBLteVUwPvpKmx9Qi and not configured to listen for inbound connections.
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Node ID Address ? ⤭ Since │
├───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ z6Mkgytt3PK7AUpgPjsmwFQ5KfLbUuz68nfwsfPHcgdXCneY git.jappie.dev:8776 ✓ ↗ 6.58 minute(s) │
│ z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7 irisradi…c6s5lfid.onion:8776 ✗ ↗ 2.37 minute(s) │
│ z6Mkq3tQJ6eGxUbAs5tq9zLnfjBxsaba8fTkJZn6mF6uxyCg demo.radicle.garden:7005 ✓ ↗ 10.82 minute(s) │
│ z6MkwfrBy9mKTfcVELcV4wc6zfN379FPMnAqsxnwt4j2TdQ2 radicle.jarg.io:8776 ✓ ↗ 8.35 minute(s) │
│ z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo rosa.radicle.xyz:8776 ✓ ↗ 10.97 minute(s) │
│ z6Mkhchm3ofFVSfFb4qNvvedWidgunPSz8YukMB3EiGYRZMR demo.radicle.garden:7015 ✓ ↗ 10.82 minute(s) │
│ z6MkgoE3HwG1TX9YScP71eJKVDGYCbtEdAjwm5QkAgxkGXu5 radicle.aziis98.com:8776 ✓ ↗ 10.75 minute(s) │
│ z6MkqxWYd3U9YN1sD3frLE93sKfjFPCzvUsJEh1UTKRRPf2A demo.radicle.garden:7027 ✓ ↗ 9.58 minute(s) │
│ z6Mkeqfa5JWDhS7kqVJvc8avpEbBbGVHhWjpXj1EgE9uHMWp seed.radicle.xeppaka.cz:8776 ✓ ↗ 10.82 minute(s) │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✗ Hint:
? … Status:
✓ … connected ✗ … disconnected
! … attempted • … initial
⤭ … Link Direction:
↘ … inbound ↗ … outbound
2026-01-23T21:43:24.330-08:00 INFO wire Peer z6Mkeqfa5JWDhS7kqVJvc8avpEbBbGVHhWjpXj1EgE9uHMWp fetched rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 from us successfully
2026-01-23T21:44:50.642-08:00 WARN wire Failed to establish connection to irisradizskwweumpydlj4oammoshkxxjur3ztcmo7cou5emc6s5lfid.onion:8776: no configuration found for .onion addresses
2026-01-23T21:44:50.644-08:00 INFO service Disconnected from z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7 (no configuration found for .onion addresses)
2026-01-23T21:44:54.817-08:00 INFO service Disconnected from z6Mkf3h4S5akszuqqbmckmsSekbUgJEe1nmBxuRJP53bJAqe (connection reset)
2026-01-23T21:44:55.883-08:00 INFO service Connected to z6Mkgytt3PK7AUpgPjsmwFQ5KfLbUuz68nfwsfPHcgdXCneY (git.jappie.dev:8776) (Outbound)
2026-01-23T21:49:08.664-08:00 WARN wire Failed to establish connection to irisradizskwweumpydlj4oammoshkxxjur3ztcmo7cou5emc6s5lfid.onion:8776: no configuration found for .onion addresses
2026-01-23T21:49:08.664-08:00 INFO service Disconnected from z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7 (no configuration found for .onion addresses)
2026-01-23T21:51:30.633-08:00 INFO service Received command ListenAddrs
2026-01-23T21:51:30.634-08:00 INFO service Received command QueryState(..)
2026-01-23T21:51:30.634-08:00 INFO service Received command QueryState(..)
Okay, seems healthy. I guess, there was some initial setup done on the first run. Let’s try again:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood
✓ Creating checkout in ./heartwood..
✓ Remote z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /Users/peterdemin/heartwood/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 133 issues · 15 patches │
╰────────────────────────────────────╯
Run `cd ./heartwood` to go to the repository directory.
Nice, it even explained to me how to change directory.
If you’re wondering what was that remote-tracking output, it actually configured all those remotes in the git repo, using rad:// protocol.
heartwood $ git remote -v
lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz (fetch)
lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz (push)
rad rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5 (fetch)
rad rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6Mkno75gzNU1Y59EL9rj8nUVveBUj3kBLteVUwPvpKmx9Qi (push)
z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz (fetch)
z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz (push)
z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM (fetch)
z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM (push)
z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT (fetch)
z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT (push)
z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW (fetch)
z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW (push)
Publish a repo¶
Let’s add an existing repo to the radicle network. I’ll use this website’s repo as an example.
$ rad init
Initializing radicle 👾 repository in /Users/peterdemin/peterdemin.github.io..
✓ Name peter.demin.dev
✓ Description Peter Demin
✓ Default branch master
✓ Visibility public
✓ Repository peter.demin.dev created.
Your Repository ID (RID) is rad:zb9rTT3zoR5aD1svmWYW25yc5kVe.
You can show it any time by running `rad .` from this directory.
✓ Repository successfully announced to the network.
Your repository has been announced to the network and is now discoverable by peers.
You can check for any nodes that have replicated your repository by running `rad sync status`.
To push changes, run `git push rad master`.
While it was running, it showed a countdown of announcements, and if I’m not mistaken, the repo was announced to four peers. Let’s see if they are gonna show up in remotes:
$ git remote -v
origin git@github.com:peterdemin/peterdemin.github.io.git (fetch)
origin git@github.com:peterdemin/peterdemin.github.io.git (push)
rad rad://zb9rTT3zoR5aD1svmWYW25yc5kVe (fetch)
rad rad://zb9rTT3zoR5aD1svmWYW25yc5kVe/z6Mkno75gzNU1Y59EL9rj8nUVveBUj3kBLteVUwPvpKmx9Qi (push)
Hmm… Nope, there’s just one. And it’s <this repo ID>/<this node ID>.
Looks like my only upstream is myself.
But you know what, there’s this magic sync command, let’s try that.
$ rad sync --inventory
✓ Announcing inventory to 8 peers..
I’m sure pretty good at announcing inventory. Who knew, I’m a natural. Fine, let’s try the other one:
$ rad sync status
✗ Hint:
? … Status:
✓ … in sync ✗ … out of sync
! … not announced • … unknown
Huh, I could’ve sworn, I saw something about replication and announcements. How about we push things a little:
$ git push rad master
Everything up-to-date
Wow, immediately returning up-to-date response. Maybe, because it’s pushing to itself?..
Set up another node¶
At this point, the peer node of radicle network is up and running, announcing it’s presence, and serving a repo.
Let’s see how we can use this network to get the repo from another node.
I repeated the setup on a Debian Trixie VM using Vagrant:
Vagrantfile:
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
config.vm.box = "debian/trixie64"
config.vm.network "private_network", ip: "192.168.56.57"
config.vm.synced_folder "./", "/vagrant", type: "virtiofs"
config.vm.provider :libvirt do |libvirt|
libvirt.memory = "4096"
libvirt.driver = "kvm"
libvirt.uri = 'qemu:///system'
libvirt.cpus = 4
libvirt.numa_nodes = [{ :cpus => "0-3", :memory => 8192, :memAccess => "shared" }]
libvirt.memorybacking :access, :mode => "shared"
end
config.vm.provision "shell", path: "build-radicle.sh"
end
Provisining script in build-radicle.sh:
#!/bin/bash
set -eo pipefail
sudo apt-get update
sudo apt-get install -y rustup build-essential git
rustup toolchain install stable
git clone https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git heartwood
cd heartwood
cargo install --path crates/radicle-cli --force --locked --root ~/.radicle
cargo install --path crates/radicle-node --force --locked --root ~/.radicle
cargo install --path crates/radicle-remote-helper --force --locked --root ~/.radicle
sudo cp ~/.radicle/bin/* /usr/bin/
Create a node identifier:
$ rad auth
Initializing your radicle 👾 identity
✓ Enter your alias: demin.dev
✓ Enter a passphrase: ********
✓ Creating your Ed25519 keypair...
✓ Your Radicle DID is did:key:z6MkpA56z2R7powZHMdCJ6PsecwMgTnmVCXtn3h1LiPBtrMd. This identifies your device. Run `rad self` to show it at all times.
✓ You're all set.
To create a Radicle repository, run `rad init` from a Git repository with at least one commit.
To clone a repository, run `rad clone <rid>`. For example, `rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5` clones the Radicle 'heartwood' repository.
To get a list of all commands, run `rad`.
$ rad node start
✓ Node started (105073)
To stay in sync with the network, leave the node running in the background.
To learn more, run `rad node --help`.
$ rad clone rad:zb9rTT3zoR5aD1svmWYW25yc5kVe
✓ Seeding policy updated for rad:zb9rTT3zoR5aD1svmWYW25yc5kVe with scope 'all'
Fetching rad:zb9rTT3zoR5aD1svmWYW25yc5kVe from the network, found 2 potential seed(s).
✗ Target not met: could not fetch from [z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo, z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7], and required 1 more seed(s)
! Warning: Failed to fetch from 2 seed(s).
! Warning: z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo: an I/O error occurred during the fetch handshake (error reading from stream: channel timed out)
! Warning: z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7: Could not connect. No addresses known.
✗ Error: no seeds found for rad:zb9rTT3zoR5aD1svmWYW25yc5kVe
Whoops! Looks like my first peer node is not as peer as the two servers owned by Radicle team…
$ rad node status
✓ Node is running with Node ID z6MkpA56z2R7powZHMdCJ6PsecwMgTnmVCXtn3h1LiPBtrMd and not configured to listen for inbound connections.
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Node ID Address ? ⤭ Since │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ z6Mkr5ad8ZN5tyJygyp7wgujJLSykAvXznQyUtV3kh8CsTyd radicle.qmooku.com:8776 ✓ ↗ 32.933 second(s) │
│ z6MkwfrBy9mKTfcVELcV4wc6zfN379FPMnAqsxnwt4j2TdQ2 radicle.jarg.io:8776 ✓ ↗ 28.933 second(s) │
│ z6MkmWBnyEnoQSWEuvukXPPzPtQNPoBZ66c4aRVz2d39Escp rad.daidalos.xyz:8776 ✓ ↗ 29.933 second(s) │
│ z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo rosa.radicle.xyz:8776 ✓ ↗ 40.933 second(s) │
│ z6MkrNbURE9T9GQ3CpAYHGyXfEvSqMe6SczFDwHddt1jcabR rad.glyphs.tech:7114 ! ↗ │
│ z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7 irisradi…c6s5lfid.onion:8776 ✗ ↗ 6.933 second(s) │
│ z6MkidNH5DAvU3woJsXdFwfSTVT32iYQCAYep8emjCqVJyz5 radicle-seed.29051830.xyz:8776 ✓ ↗ 32.933 second(s) │
│ z6MkmNgM276APif8WG2sp9bS82rJwg9JCpdk3xv53kTc9KYj radicle.schuppentier.org:8776 ! ↗ │
│ z6Mkh3MbEZxUvVrCDJ2rJ23V33ptNgJTjm3ChumQSewJb454 pool.net.eu.org:8776 ✓ ↗ 27.933 second(s) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✗ Hint:
? … Status:
✓ … connected ✗ … disconnected
! … attempted • … initial
⤭ … Link Direction:
↘ … inbound ↗ … outbound
2026-01-24T22:12:08.105Z INFO service Received command Seeds(rad:zb9rTT3zoR5aD1svmWYW25yc5kVe)
2026-01-24T22:12:08.107Z INFO service Received command QueryState(..)
2026-01-24T22:12:08.107Z INFO service Received command Fetch(rad:zb9rTT3zoR5aD1svmWYW25yc5kVe, z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo)
2026-01-24T22:12:15.169Z WARN wire Failed to establish connection to irisradizskwweumpydlj4oammoshkxxjur3ztcmo7cou5emc6s5lfid.onion:8776: no configuration found for .onion addresses
2026-01-24T22:12:15.169Z INFO service Disconnected from z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7 (no configuration found for .onion addresses)
2026-01-24T22:12:17.111Z WARN service Fetch failed for rad:zb9rTT3zoR5aD1svmWYW25yc5kVe from z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo: an I/O error occurred during the fetch handshake (error reading from stream: channel timed out)
2026-01-24T22:12:17.112Z INFO service Received command QueryState(..)
2026-01-24T22:12:21.888Z INFO service Received command ListenAddrs
2026-01-24T22:12:21.888Z INFO service Received command QueryState(..)
2026-01-24T22:12:21.889Z INFO service Received command QueryState(..)
I’m not an expert, but by matching the hashes to hosts, I see that rad clone command attempted to clone my repo from rosa.radicle.xyz:8776 and iris.radicle.xyz node served through .onion address.
The error print channel timed out, while it should have been more like repo not found.
Decentralize my ass.
Rad has an option to force cloning from a particular node:
$ rad seed rad:zb9rTT3zoR5aD1svmWYW25yc5kVe --from z6Mkno75gzNU1Y59EL9rj8nUVveBUj3kBLteVUwPvpKmx9Qi
✓ Seeding policy exists for rad:zb9rTT3zoR5aD1svmWYW25yc5kVe with scope 'all'
Fetching rad:zb9rTT3zoR5aD1svmWYW25yc5kVe from the network, found 1 potential seed(s).
✗ Target not met: could not fetch from [z6Mkno75gzNU1Y59EL9rj8nUVveBUj3kBLteVUwPvpKmx9Qi], and required 1 more seed(s)
No dice, I can’t fetch from my laptop.
Parting thoughts¶
I poked around for a few hours trying to figure out how to synchronize code between my two radicle nodes, and ultimately failed. I tried to get a better understanding of the system by reading the user guide, but the page has too much unrelated information and not enough answers.
I feel like giving up on radicle.
Nothing good comes easy, but sometimes nothing comes at all. Ah, well, at least I poked cargo a bit, that was fun.
Going through the troubles of setting up decentralized forge made we question the premise.
What is missing from the existing tailscale+git+ssh combination to make decentralized collaboration possible.
Create a decentralized replica of a repo:
ssh server.tail1234.ts.net 'git init --bare repo'
git remote add server server.tail1234.ts.net:repo
git push server master
The identity, authentication, and authorization already covered by ssh.
Access provided by tailscale.
Content synchronization done by git.
What else do you want? Collaboration through issues and patches?
Don’t put those in git, they don’t belong in version control system.
Run a mailing list, a forum, an XMPP chat room, or a Zulip instance.
Run Gerrit, or one of many alternatives for code reviews.
Or maybe consider Fossil.
To add another maintainer, share your server through Tailscale, and add their public key to authorized keys.
For redundancy, add as many mirrors as you want with git clone --mirror.
There’s many ways to shave a cat and it doesn’t matter which one you pick. Sometimes, it’s best to use GitHub, and maybe that’s why most big projects do. Sure, GitHub is proprietary, centralized, governed by an organization with values that might not align with yours. But does it really matter?