Getting a sprite to talk to my tailnet-only Forgejo
I want to build a small project and run the whole thing -- code, data, hosting -- on a sprite, to find out whether I like the concept. A sprite is a persistent Linux VM from fly.io that pauses when idle and wakes on an inbound HTTP request. The first problem is not the app, it is git: my Forgejo is only reachable inside my tailnet.
The sprite now reaches it over SSH through my Hetzner box, with no Tailscale in the sprite at all. Tailscale does work on a sprite, it just does not survive a pause; that part is further down.
Git through a jump host
The VPS is always on and already in the tailnet, so it can be the jump host:
Host vps HostName <vps-hostname> User <user> IdentityFile ~/.ssh/id_ed25519_vps Host forgejo HostName <forgejo-ip> User git ProxyJump vps IdentityFile ~/.ssh/id_ed25519_forgejo IdentitiesOnly yes
ProxyJump only makes the VPS open a TCP socket to port 22.
The SSH session stays end-to-end, so the Forgejo deploy key authenticates directly and the VPS never sees plaintext git traffic.
id_ed25519_forgejo is a deploy key, added on the repository itself under Settings -> Deploy Keys with write access enabled.
That scopes it to this one repo -- an account SSH key would hand the sprite every repository I have.
The sprite's key is restricted on the VPS side:
restrict disables port forwarding as well, and permitopen only narrows forwarding that is already allowed -- it does not enable it.
Without port-forwarding in that list the jump fails with administratively prohibited.
With both, the sprite gets a TCP path to Forgejo and nothing else: no shell, no PTY, no other destination.
Services on a sprite
PID 1 is tini and there is no systemctl, which I first read as "no daemons".
There is an in-VM CLI called sprite-env instead, and it does the part of systemd that matters here:
sprite-env services create <name> --cmd <binary> --args "a,b,c" sprite-env services list | get | start | stop | restart | delete sprite-env services signal <name> TERM
Services restart automatically on boot, which on a sprite means after every cold pause.
--cmd takes the binary only, arguments go into a comma separated --args; there is also --env, --dir and --needs for dependencies.
State comes back as JSON, and stdout and stderr land in /.sprite/logs/services/<name>.log:
{"name":"tailscaled","state":{"status":"running","pid":1167, "started_at":"2026-08-02T19:37:05Z","next_restart_at":"0001-01-01T00:00:00Z"}}
Exactly one service may claim --http-port, and that service is auto-started when an HTTP request arrives at the sprite's public URL -- the same request that wakes a paused sprite.
The documentation is explicit that you should not start a background process yourself next to a service, because the service manager owns the process lifecycle.
Tailscale works until the sprite pauses
Kernel mode works, so no userspace networking is needed.
/dev/net/tun exists and opens O_RDWR as the unprivileged sprite user, even though the mode has no read bits:
CAP_NET_ADMIN is in CapEff and ip link add dummy0 type dummy works.
A fresh sprite has no egress policy -- /.sprite/policy/network.json does not exist -- and tailscale netcheck reports UDP: true.
So tailscaled becomes a service, wrapped in sudo because it needs root:
sprite-env services create tailscaled --cmd /usr/bin/sudo \ --args "-n,/usr/sbin/tailscaled,--state=/var/lib/tailscale/tailscaled.state,--socket=/var/run/tailscale/tailscaled.sock,--port=41641"
Logging the node in is a separate step, and the sprite has no browser, so it needs an auth key:
Use a non-ephemeral key -- an ephemeral node gets reaped while the sprite sleeps.
The state lives in /var/lib/tailscale/tailscaled.state on the persistent disk, so this is a one-off.
/etc/resolv.conf sits on a read-only overlay, so tailscaled cannot install MagicDNS.
*.ts.net names then fall through to public DNS, which answers with addresses that are not my tailnet and do reply to ping:
/etc/hosts is writable, so pin the peer there, or put the tailnet IP straight into ~/.ssh/config.
That holds until the first pause:
tx 2340 rx 0 -- the peer is still listed as active, packets go out, nothing comes back.
Every connection hangs for 2m13s and then times out, and it does not recover on its own.
tailscale ping still answers via DERP while real traffic is black-holed, so it is not a usable health check.
Restarting the service fixes it, and it comes back with a direct connection instead of DERP:
This is not a Tailscale bug. A pause freezes the process and drops its TCP connections; tailscaled thaws with dead socket state. From inside the sprite you can only detect it and restart, never prevent it.
With the jump host there is no daemon left in the sprite that could keep broken sockets after a pause. When the sprite pauses, it aborts the git command that is running, and the next one opens a new SSH connection and works. That is what makes the extra hop the right trade here.