Solitary

Route a cell through a VPN

Send a cell's traffic through a WireGuard tunnel.

A cell can route its outbound traffic through a WireGuard tunnel brought up on the VM. The container needs no VPN software and no configuration of its own: it runs on the machine's network, so it inherits the tunnel.

Configure the cell

Point network.vpn at a WireGuard configuration and keep the file private:

network:
  vpn: ./vpn.conf
  allow:
    - github.com
    - api.anthropic.com

The path is relative to the cell definition. Use the .conf your provider gives you, unchanged.

The configuration is not part of the cell definition. It holds a private key, so solitary never writes it into the machine's definition file; it is read from your disk on every up and placed into the running machine separately. That is what keeps a cell definition shareable: publish cell.yaml, keep vpn.conf and .env out of version control, and whoever clones the cell supplies their own — from any provider, with no edit to cell.yaml, because the peer to allow is read out of whichever configuration is present.

# .gitignore, in a repo of cell definitions
.env
vpn.conf

What happens to traffic

Everything the cell reaches leaves through the tunnel, so it has its own exit address rather than your host's. The allow list is enforced exactly as before, with one difference: what it allows is reachable through the tunnel only.

If the tunnel is down, nothing leaves — the same traffic does not quietly go out the way it came. The single exception is the cell's resolver reaching the servers under resolvers:, because the tunnel's own peer has to be resolved before there is a tunnel to resolve it through.

Staying connected

wg-quick resolves the peer's name once, when it brings the tunnel up, and pins the address it got. A provider that balances one name across servers — or retires the server this cell dialled — leaves the interface up and handshaking with an address that answers nothing. Inside the cell that looks exactly like the network being down, and nothing about it recovers on its own.

So the machine keeps a watchdog on the tunnel, once a minute:

  • It sets a persistent keepalive on the peer, so the tunnel handshakes about every two minutes whether or not the cell is using it. That holds the machine's NAT mapping open, and it makes the age of the last handshake mean something: silence is a fault rather than an idle cell.
  • After three minutes without a handshake — or three minutes after the tunnel started without one at all — it restarts the tunnel. That resolves the peer's name again, through the cell's own resolver, which is also what allows the answer through the firewall, and dials from a fresh port. It has to be a restart: while the interface is up, every route leads into it, the resolver's own queries included, so the name cannot be asked again through a tunnel that is not answering. Nothing is lost that was not already — a tunnel that quiet has outlived its keys, and it comes back with the same address, so connections through it carry on.
  • If the interface is gone entirely, which is what wg-quick leaves behind when it fails, it starts it again.

The tunnel is also restarted once at every boot, after the firewall is loaded. It comes up alongside the firewall rather than after it, and the firewall forgets the peer's address when it loads; restarting it means the peer is resolved — and allowed — again.

The keepalive is set on the running interface, not written into your file: the .conf stays exactly as the provider gave it. The machine logs what it does to the journal under the solitary tag.

solitary up does the same check on the way past. A tunnel whose configuration has not changed is normally left alone, connections and all — but one that has stopped answering is brought up again:

The tunnel to de-ber.prod.surfshark.com has stopped answering; bringing it up again...

A machine created before this existed picks up the watchdog at its next boot: solitary down <cell> && solitary up <cell>.

Take it back off

Deleting network.vpn ends the tunnel at the next boot: the machine disables the interface and deletes the configuration it came up from, private key and all. A running cell keeps the tunnel it started with until it is stopped and started again.

Three things a configuration must not have

All three are refused when the cell is read, rather than leaving you with a machine whose tunnel never comes up:

  • A missing Endpoint. There is nothing to connect to, and nothing for the firewall to allow.
  • A DNS = line. A cell resolves through its own resolver. If you want the tunnel's resolver, name its address under resolvers: instead.
  • An IPv6 Endpoint. The peer must use IPv4 because the tunnel firewall drops non-loopback IPv6 traffic. Most providers publish both, so this is usually a line to change rather than a provider to leave.

Not the host resolver

Do not pair a tunnel with resolvers: [host]. Solitary warns about this combination on every up and again in the dashboard's network view, because it undoes much of what the tunnel is for.

The host's resolver is discovered as the address the machine's own network hands it, which is link-local to that network. Queries to it therefore keep taking the interface the tunnel replaced — by design, since that is the only way to reach it. Application traffic leaves from somewhere else, but every name it looks up is still read by whoever runs that resolver. This also permits the VM resolver to contact a host service.

Name a resolver by address instead:

network:
  vpn: ./vpn.conf
  resolvers:
    - 10.0.0.53
  allow:
    - github.com

It is a warning rather than a refusal: a network whose names only its own resolver knows is a real thing to be on, and pairing it with a tunnel for everything else is a legitimate — if narrow — choice.

The kill switch is enforced even when network.allow is omitted. While the tunnel is absent, normal application traffic is blocked. The VM resolver can still query the configured upstream DNS servers for bootstrap; those DNS queries can leave outside the tunnel. WireGuard PreUp, PostUp, PreDown and PostDown hooks are refused because they execute as VM root. SaveConfig is also refused because it can replace the managed configuration.

On this page