Two Tailnets, One Shared Node: Sovereign Privacy For Family Sysadmin
A friend wanted to use my big server for occasional KI inference. The intuitive answer is “add the friend’s machine to my Tailscale tailnet.” That answer is wrong in a specific way that matters for sovereignty. The right answer is two tailnets and one shared node, restricted by ACL to exactly the service the friend needs.
This post is about why the right answer is right, what the wrong answer breaks, and the exact ACL configuration that scopes the shared node to a single port.
What “adding the friend to my tailnet” actually does
Tailscale identifies users by their SSO provider. If I sign in with GitHub as cipherfoxie@github, every node I add is bound to that identity. If I invite a friend to join my tailnet under their own GitHub account, they appear as a separate identity inside my tailnet, with the access policies my tailnet ACL grants them.
This sounds clean. It has three quiet costs.
First, the friend’s machines are listed in my admin console. I can see his hostnames, his IP assignments, his connection history, when he was last online. He cannot see equivalent information about my machines unless I share it back. The relationship is asymmetric in a way the friend may not realize.
Second, if I ever leave Tailscale, lose access to my GitHub, or get my tailnet suspended for any reason, the friend’s setup goes with me. His ability to reach my server is bound to my identity holding up under load that may have nothing to do with him.
Third, the friend has no separate ACL surface of his own. He cannot apply tags to his own devices, cannot share his own services with others, cannot run his own subnet routers, cannot set device posture rules. He is a guest in someone else’s tailnet. Guest is not a sovereign role.
This is the wrong primitive for the “I want my friend to use my server” case.
What the right primitive looks like
Tailscale has a feature called node sharing that solves this cleanly. The pattern is:
-
The friend creates his own Tailscale account under his own identity (own email, own SSO provider). This gives him his own free-tier tailnet.
-
I share one node from my tailnet to his tailnet by inviting his email address from my admin console.
-
He accepts the share. The node appears in his tailnet’s machine list, with my original tailnet’s IP, marked as “shared from cipherfoxie@github”. His tailnet remains his.
-
ACL on my side governs what his identity is allowed to reach on the shared node. By default a shared node is wide open. The discipline is to restrict.
Now the architecture is symmetric. He has his own admin console, his own ACL surface, his own device list. I cannot see his machines unless he shares them back to me. The reachability across the boundary is one node, one direction at a time, fully governed by ACL on the sharing side.
For the case where I want to occasionally help him debug, the same pattern in reverse: he shares one node of his to my tailnet. That gives me a one-shot reach into his setup without making me a permanent guest there.
This is the right primitive. Two tailnets, sharing scoped at the node level, ACL-restricted at the port level.
The ACL that restricts the share to one port
By default Tailscale opens all ports on a shared node to the recipient tailnet. If I share my server, the friend can in principle hit any service that listens, including services I would prefer to keep private (the local dashboard, the local Caddy reverse proxy, the SSH daemon, anything I forget to firewall).
The discipline is to put an explicit ACL rule that scopes what the shared-receipt user can reach. The new Tailscale ACL UI calls these “grants” and renders them in JSON like this:
{
"groups": {
"group:friends": ["FRIEND_EMAIL@example.com"]
},
"acls": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["*"],
"ip": ["*"]
},
{
"action": "accept",
"src": ["group:friends"],
"dst": ["SHARED_NODE_TAILSCALE_IP"],
"ip": ["tcp:30001"]
}
]
}
Three things matter in this config.
The first rule scopes the “wide open” default to autogroup:member, which is my own devices. Without this rewrite, the default rule says src: ["*"] and that includes shared-receipt users. The scope to autogroup:member is the key change.
The second rule grants exactly one identity (the friend) access to exactly one IP (the shared server) on exactly one port (30001, which is the local vLLM endpoint). Anything else on that server is invisible to the friend’s port scan. Anything else in my tailnet is invisible to the friend full stop.
I tested the scope after applying the rule. From the friend’s laptop, a tcp connect to my server’s tailnet IP on port 30001 returns “open.” Connects to ports 22, 80, 443, 8770, 8443, and four other listening services return “blocked.” The ACL works.
The verification protocol I used is worth describing because the failure mode of an ACL change is usually invisible. The protocol is: run nc -zv SHARED_IP PORT from the friend’s machine for each port in the set you want open, then for a representative sample of ports you want closed. The open ones return “Connection succeeded.” The closed ones either time out or return “Connection refused” depending on whether the ACL drops the packet (timeout) or whether the upstream service is not listening (refused). Both are acceptable. The signal that matters is the open-set.
I ran the verification three times: once immediately after applying the ACL, once after a Tailscale magic-DNS refresh, and once 24 hours later to confirm there was no drift. All three runs produced the same result. The ACL is stable in the sense that I cannot see what would make it spontaneously degrade.
One subtle pitfall: the ACL change does not take effect until the affected machines re-evaluate. Tailscale does this on every keepalive, but the first re-evaluation may lag the admin save by 30-90 seconds. The verification protocol should explicitly wait for that window before declaring the test passed. Otherwise you get the surprising result of “ACL says blocked, but the connection works” because the machine’s local policy is still using the previous ACL state.
Why the local-Ollama default matters too
The ACL gets the network layer right. There is a second layer worth getting right, which is application defaults.
OpenWebUI on the friend’s laptop ships with two model backends: local Ollama at http://172.17.0.1:11434 and the shared DGX Spark vLLM at the cross-tailnet IP. The default model the friend sees is qwen3:8b, which is local. The shared qwen3.6-35b is in the dropdown but requires deliberate selection.
This is privacy-by-default in practice. Every casual question the friend types goes to the local model, which leaks nothing across the network boundary. DGX-Spark-side logs see no activity. Only when the friend deliberately picks the larger model does any metadata cross to my server, and at that point he has chosen consciously.
The configuration that achieves this is one environment variable on the OpenWebUI container:
- DEFAULT_MODELS=qwen3:8b
And one description field on the DGX Spark model entry that warns the user: this model runs on someone else’s server, who can see metadata about your calls. The warning is the consent mechanism. Without it, the friend might not know.
What the server-side sees and does not see
I want to be specific about what privacy “DGX-Spark-side metadata only” actually means.
The vLLM process on my server logs at INFO level by default. The INFO-level fields per request are: timestamp, source IP, request ID, prompt token count, completion token count, total duration. The prompt itself and the response itself are not logged at INFO. They are logged at DEBUG, which is off in production.
If the friend uses DGX Spark Qwen, I can determine: that he made a request at 14:33 UTC, that the request came from his tailnet IP, that the request had roughly 500 prompt tokens and got roughly 200 response tokens, and that the whole thing took 4.2 seconds. I cannot determine: what he asked, what the model said, or whether he liked the answer.
This is the honest description of the privacy guarantee. It is not “I see nothing.” It is “I see metadata that lets me debug load issues, I do not see content.” The friend can verify the claim by reading the actual vLLM logs himself, which he can do because the shared node lets him SSH in for log inspection if he wants to. The transparency is the safeguard.
If I wanted stronger privacy than this, the next step would be to run the vLLM process with --disable-log-requests and lose the metadata too. That is a future improvement I have not made yet. For the current setup, the metadata is the right tradeoff for being able to keep the server healthy under load.
There is a second layer worth describing: the friend’s laptop has its own logs of what the friend asked. Those logs live on his disk, encrypted by LUKS at rest, accessible only to his account, and never sent to me. OpenWebUI by default stores chat history in a local SQLite database under /data/openwebui/data/. Even if I had a backdoor to his Tailscale node (I do not, by ACL), I would still not have access to the SQLite database without breaking out of the ACL-restricted port. The defense-in-depth is the combination of the ACL boundary and the LUKS-at-rest boundary. Either one alone would be uncomfortable. Both together is enough.
I also configured OpenWebUI’s analytics export to off. The package ships with an optional telemetry exporter that sends usage stats back to the OpenWebUI maintainers. Disabling it is one environment variable: ANONYMIZED_TELEMETRY=false. The friend’s traffic does not leave his tailnet for any reason other than the deliberate model-selection of DGX Spark Qwen. This is the same default I use on my own machines, exported to his.
What this pattern generalizes to
The two-tailnet-one-shared-node pattern is not specific to AI servers. It generalizes to any case where you operate infrastructure for a less-technical friend or family member.
Home assistant for your parents: separate tailnets, you share the HA UI port, you do not see their light switches.
Self-hosted file backup for a sibling: separate tailnets, you share the Syncthing port, they cannot reach your other services.
Lightning node for a partner: separate tailnets, you share only the LND gRPC endpoint, their wallet sees that one port.
In each case the pattern is the same: two identities each with their own tailnet, one shared node at the boundary, an explicit ACL rule that scopes the share to one service.
What to set up before the friend’s first login
The migration sequence I followed and would recommend for anyone doing this for the first time:
-
Friend creates own Tailscale account with own SSO provider. Verify the email arrives and the admin console loads.
-
Friend installs Tailscale on their machine and signs in. Their first node now lives in their own tailnet.
-
You share the destination node from your admin console using their account email. They receive an invitation.
-
They accept. The shared node appears in their machine list with your IP, marked as shared.
-
You update your ACL to add the
groups: friendsentry with their email, and thesrc: group:friends, dst: SHARED_IP, ip: tcp:PORTrule. Save. -
You also update the default “accept everything” rule (if you have one from new-tailnet defaults) to scope its src to
autogroup:memberso the new ACL line is actually the only thing the friend can reach. -
From the friend’s machine, run a tcp connect to the shared IP on the intended port. Verify it works. Then run connects to other ports on the same IP. Verify they fail.
-
Optional: friend shares one of their nodes back to you for support purposes.
The whole sequence takes about ten minutes once you have done it once. The hardest part is remembering to scope the original accept everything rule down to your own members.
Closing
Family sysadmin is one of those situations where the easy answer is wrong in a way the friend will not notice for years. The hard answer is the right answer, and it does not actually take much more effort once the pattern is in your hands. Two tailnets, one shared node, one scoped ACL rule. The friend gets their own sovereign surface. You get to keep yours. The shared resource works as advertised.
If you want a worked example with screenshots of the admin UI in both tailnets, leave a comment or zap and I will write one up. The example I described in this post is the one running on my desk right now.
What I would change next time
The setup as described is the one I shipped. There are three improvements I would make if I were doing this over.
Use tags instead of email-based group entries. The current ACL references the friend by his email address directly. This works for a one-person share. It does not scale to “I want to share with three friends.” The cleaner pattern is to apply a tag to the shared node (e.g., tag:friend-share) and then write the ACL rule against the tag rather than against any specific identity. The shares are still per-identity at the share level, but the ACL gets to be identity-agnostic. I will refactor this once I add a second friend.
Document the recovery procedure. What happens if the friend loses access to his Tailscale account (lost password, lost MFA token, account suspension by his identity provider)? Right now: he is unreachable. I have no out-of-band way to reach his Tailscale node, and his node has no out-of-band way to reach mine. The fix is to add a low-trust fallback: a Wireguard tunnel between two hosts using static keys, configured but not active. If the Tailscale relationship breaks, the Wireguard tunnel comes up and we have a path to debug. The keys live in a sealed envelope at his place and mine. Defense in depth for the network primitive itself.
Audit the shared node’s actual exposure quarterly. The ACL is correct today. ACLs drift. New services get installed. Ports get opened. The discipline I am adopting is a quarterly nmap from the friend’s tailnet against the shared node, with the expected open-set written down, and any deviation triggers a review. The first such audit is scheduled for 2026-08-29. Without the calendar item, the audit will not happen. With the calendar item, it will.
What this primitive does not give you
Honest disclaimers. The two-tailnet-one-shared-node pattern does not protect against:
The shared node itself being compromised. If my server gets root-kit, the friend’s traffic that crosses my server is now visible to the attacker, and the friend has no way to know. The mitigation is the same as for any infrastructure: hardening, AIDE, intrusion detection, regular updates. The pattern is a logical-access control, not a physical-trust control.
Tailscale itself being compromised or coerced. The control plane (Headscale-equivalent or Tailscale-hosted) authenticates the keys that establish peer connections. If the control plane is compromised, an attacker could in principle inject themselves as a peer. The mitigation is to run Headscale on your own infrastructure if your threat model demands it. For my actual threat model (a friend, not a state actor), the Tailscale-hosted control plane is fine.
Side-channel attacks against the shared service. If the shared port is something like a SSH login or a database query interface, an attacker who has network reach can still mount whatever attack the service itself permits. The ACL just gates the reach. The service still has to be secure on its own merits.
These are not problems with the pattern. They are problems with networked infrastructure in general. Naming them explicitly is part of the engineering-honesty discipline I try to apply to every privacy claim I publish.
Sibling posts on this thread
24 Hours Setting Up a Lenovo Legion Pro 7 Gen 10 is the full day-of mechanics post that includes this share-and-ACL setup as the network-layer milestone.
Sovereign Friend-Setup is the concept post on why two tailnets are right where one tailnet looks easier.
Dashboard As Learning-Cockpit shows where the friend sees that this network primitive exists, via the Lernen-Tab’s Sicherheit-und-Netz section.
We Were Wrong About Local 8B Tool-Use is the model-side technical post that depends on the laptop having a local default model so the network primitive can stay scoped to occasional escalation.