# Remote Access with Tailscale There are two ways into the homelab now. The public one already existed: Nginx Proxy Manager maps real domains like `anomalylab.ai` and `photos.ethanyoung.dev` to the right containers, Let's Encrypt issues the certs, and the router forwards 80 and 443 to the proxy. Clients reach AnomalyLab that way and the family streams Jellyfin that way. None of it changes. I'm new to it, so the rest of this note is what I worked out getting started. ## What Goes where Rule of thumb: if anyone other than me needs a service, it goes through NPM and gets a subdomain. The router hands 80/443 to the proxy and it routes from there. | Domain | Service | |---|---| | `anomalylab.ai` | AnomalyLab frontend | | `flower.anomalylab.ai` | Flower (Celery dashboard) | | `photos.ethanyoung.dev` | Immich | | `movies.ethanyoung.dev` | Jellyfin | | `chat.ethanyoung.dev` | Open WebUI | | `auth.ethanyoung.dev` | 2FAuth | | `mc.ethanyoung.dev` | Crafty 4 | | `convert.ethanyoung.dev` | ConvertX | | `share.ethanyoung.dev` | Zipline | | `files.ethanyoung.dev` | file service on `:30051` | | `speedtest.ethanyoung.dev` | OpenSpeedTest | | `notes.ethanyoung.dev` | proxied to Obsidian Publish (not self-hosted) | Everything else stays off NPM because it has no reason to answer the open internet: the TrueNAS UI, NPM's own admin page, the AnomalyLab backend and its Postgres, the Ollama API, SSH to the desktop. Tailscale is how I reach those. ## The Mental Model A tailnet is your private network. Mine is `tailfe5e90.ts.net`. Four rules cover most of it: 1. **A device is an identity.** Log in once and the machine joins. There are no keys to copy between machines. 2. **Names beat IPs.** MagicDNS is on, so `truenas-scale` resolves from any of my devices and I never type `100.66.174.111`. 3. **It behaves like a LAN.** Once two machines are on the tailnet, SSH, HTTP, SMB, and ping all work the way they would on the same switch. 4. **Nothing is public unless I say so.** Traffic stays inside the tailnet. Putting a service on the open internet is a separate, deliberate step (Funnel, below). The "Expiry disabled" tag on my nodes keeps their keys from rotating on a timer, which is what I want for machines that should just stay connected. I'd turn expiry back on for any device I might actually lose. ### My Tailnet | Role | Name | Tailscale IP | |---|---|---| | PC | `ethan-desktop-ubuntu-24` | 100.96.204.33 | | Laptop | `ethan-thinkbook-14-g2-are` | 100.78.40.35 | | Phone | `pixel-7a` | 100.102.96.33 | | Server | `truenas-scale` | 100.66.174.111 | ## Laptop to Claude Code on the PC This is the one I built it for. I want to run Claude Code on the desktop, drive it from the laptop anywhere, and not lose the session when I close the lid. I don't strictly need Tailscale for the SSH part. What I need it for is reaching the desktop without forwarding port 22 to the open internet, which I won't do. Tailscale gives me that reach without opening anything. The plain version works today: ```bash ssh ethan@ethan-desktop-ubuntu-24 ``` Run it inside `tmux` so the session outlives the connection. I can lose WiFi or close the laptop and Claude Code keeps running on the desktop, waiting for me to reconnect. `tmux` is not on a fresh Ubuntu desktop — install it once on the PC or the attach command fails with `bash: line 1: tmux: command not found`: ```bash # on the PC, once sudo apt install -y tmux ``` ```bash # on the PC, first time tmux new -s cc claude # from the laptop, any time after ssh ethan@ethan-desktop-ubuntu-24 -t tmux attach -t cc ``` The `-t` forces a terminal so tmux behaves. Detach with `Ctrl-b d` and reattach later. If the `cc` session doesn't exist yet (first connect, or after a reboot), create it instead: `ssh ethan@ethan-desktop-ubuntu-24 -t tmux new -s cc`. I don't have to detach cleanly. The session lives on the desktop, not on the SSH connection, so it survives either way I leave: a clean `Ctrl-b d`, or a dirty drop (lid closed, WiFi lost, SSH killed) where tmux auto-detaches. Either way `cc` keeps running and I reattach to it exactly as I left it. Once Claude Code is inside tmux I'm good full-time without thinking about it. What actually ends a session: typing `exit`/`Ctrl-d` *inside* it (closes that shell), rebooting or shutting down the desktop, or an explicit `tmux kill-session -t cc`. So the only things to avoid are rebooting the PC and exiting from inside the session. ### New Terminal Inside the Session To open another shell without leaving `cc`, use tmux's own windows and panes. All shortcuts are the prefix `Ctrl-b` followed by a key: | Keys | Action | |---|---| | `Ctrl-b c` | new window (full-screen shell in the same session) | | `Ctrl-b n` / `Ctrl-b p` | next / previous window | | `Ctrl-b 0`–`9` | jump to window by number | | `Ctrl-b w` | list windows, pick one | | `Ctrl-b "` | split current pane horizontally (stacked) | | `Ctrl-b %` | split current pane vertically (side by side) | | `Ctrl-b` + arrow | move between panes | Everything opened this way lives inside `cc`, so it all persists and reattaches together — no second SSH connection needed. First connect from a new laptop prompts to trust the host key. The desktop is already in `known_hosts` under its old LAN address (`192.168.1.217`), so the fingerprint matches the same machine — answer `yes`. A *system restart required* notice or a reboot kills any plain (non-tmux) session, which is the whole reason for running inside tmux. If I want to stop managing SSH keys, Tailscale SSH moves authentication onto tailnet identity instead. There's no `authorized_keys` to maintain, and the policy file decides who can connect. Turn it on at the desktop: ```bash sudo tailscale set --ssh ``` Then add an `ssh` block to the tailnet policy under Access Controls. The minimum for reaching my own machines: ```json "ssh": [{ "action": "check", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["autogroup:nonroot", "root"] }] ``` `action: check` makes me re-verify in the browser every so often, after which `ssh ethan@ethan-desktop-ubuntu-24` authenticates through Tailscale. Either way the work still happens inside tmux; Tailscale SSH only replaces the key handshake. On the phone, any SSH client (Termius, JuiceSSH) pointed at the same name works for a quick look, but the actual work stays on the laptop. ## TrueNAS Admin UI from the Laptop and Phone The box at `192.168.1.233` is the same machine as `truenas-scale`, already on the tailnet at `100.66.174.111`, with its admin UI served by nginx on 80/443. So reaching it remotely is just: ``` http://truenas-scale/ ``` No subnet router, no gateway, no exit node. The phone needs the Tailscale app installed and signed in, then the URL opens in any browser; the laptop is the same. (A subnet router only matters for LAN gear that can't run Tailscale itself, which the NAS obviously can.) The private endpoints all live on this same box and answer at `truenas-scale:<port>` once I'm on the tailnet: | Endpoint | URL over tailnet | |---|---| | TrueNAS admin | `http://truenas-scale/` | | Nginx Proxy Manager admin | `http://truenas-scale:30020` | | AnomalyLab backend | `http://truenas-scale:8000` | | Postgres (AnomalyLab) | `truenas-scale:5432` | | Ollama API | `http://truenas-scale:30068` | | Plex (direct) | `http://truenas-scale:32400` | The public services answer on their tailnet IP too, since the containers bind `0.0.0.0`, but normally I just use the domains. The tailnet path is the backup for the day DNS, the certs, or the proxy break: the services stay reachable at `truenas-scale:<port>` without depending on any of that. ## Testing a Local Dev Server from the Phone The case I keep hitting: an AnomalyLab UI change that only misbehaves on a real phone — soft keyboard covering the input, viewport `dvh`/`interactive-widget` quirks, touch scroll. The deployed site at `anomalylab.ai` is no help because it runs the *old* code until the 2 AM deploy. The tailnet lets me put the in-progress dev server in front of the actual phone before any commit. Bind Vite to all interfaces on the desktop instead of localhost: ```bash # on ethan-desktop-ubuntu-24, in the frontend dir npm run dev—--host 0.0.0.0 --port 5190 ``` Then on the phone (Tailscale on) open the desktop's **tailnet IP**, not the MagicDNS name: ``` http://100.96.204.33:5190 ``` The IP matters: Vite 5 rejects requests whose `Host` header is an unknown hostname (`ethan-desktop-ubuntu-24` → blank page / "host not allowed"), but it lets raw IPs through, so `100.96.204.33` works without touching `server.allowedHosts`. The dev server proxies `/api/v1` to the backend on `:8000` on the same box, so login and API calls work normally — I'm testing the real frontend against the real dev backend, just from the phone. Same trick from a git worktree: `npm install` in the worktree first (a symlinked `node_modules` trips Vite's `/@fs` allow-list with a 403, so the page never hydrates), then run the dev server from the worktree dir and it serves that branch's code. ## Worth Knowing for Later Features I haven't set up yet but will want. ### Serve: Drop the Port Numbers `tailscale serve` puts a local port behind a real HTTPS name with a valid cert, so Jellyfin stops being `:30013` and becomes a clean URL: ``` tailscale serve 30013 ``` That gives `https://truenas-scale.tailfe5e90.ts.net` with no cert warning, inside the tailnet only. It needs HTTPS certs enabled on the DNS page first. One thing that would have caught me out: Tailscale on the NAS runs as the `ix-tailscale` container app, not on the host, so `serve` and the rest have to run inside that container or through its config rather than from the TrueNAS shell. The desktop runs Tailscale natively, so the same commands work there directly. ### Exit Node: Route My Traffic through home On a network I don't trust I can send all my laptop or phone traffic out through the house. Advertise the PC (or NAS) as an exit node, approve it in the console, then select it on the client: ```bash sudo tailscale set --advertise-exit-node # on the node sudo tailscale set --exit-node=ethan-desktop-ubuntu-24 # on the client ``` For geo-located exits instead of my own IP there's the **Mullvad add-on**, which is a separate paid purchase on top of Pro rather than something Pro includes. Worth buying when I actually have a use for it. ### Subnet Router: Reach the Rest of the LAN This is the case where I genuinely do need routing: gear that can't run Tailscale, like the router admin page or a printer. Advertise the subnet from a machine that's always on, such as the NAS: ```bash tailscale set --advertise-routes=192.168.1.0/24 ``` Approve the route in the console and clients opt in with `--accept-routes`. The NAS UI doesn't need this. I'd set it up the first time I want to hit `192.168.1.1` from outside. ### Taildrop: Files between My Own Devices Send a file from phone to laptop to PC with no cloud in between. Turn on "Send files" in the admin console and it shows up in each platform's native share menu, encrypted and peer to peer, between my own devices only. Handy for screenshots and the odd photo dump to the server. ### Funnel: a Public Door I Mostly Won't Use Funnel exposes one tailnet service to the open internet over HTTPS, on ports 443, 8443, or 10000. ``` tailscale funnel 443 ``` I already have a public door in NPM, with proper domains and certs, so most of what Funnel does is covered. The one case it wins is a throwaway link I don't want to bother creating an NPM host and DNS record for. The danger is the same as the proxy: Funnel means anyone with the URL gets in. So a database, an admin panel, or the AnomalyLab backend never goes through it. When I'm unsure, Serve keeps it inside the tailnet. ### Access Control AnomalyLab production lives on this box, Postgres included on `:5432`. Tailnet-only isn't the same as locked down: by default every device on my tailnet can reach every port on every other device. For a single user that's fine. The moment I share the tailnet or add a device I trust less, I'll lock it down with tags and grants in the policy file so the phone can't open the production database just by being on the network. ## Related - [[Making TrueNAS Available Outside Local Network NOTES]] - [[Self Hosted Software]] - [[Do Not Monolith NAS & Server]]