SSH into a home laptop from a phone over Tailscale
A Windows laptop at home is made reachable from an iPhone anywhere, with no open router port and no rented server. The piece rebuilds each layer from below: why NAT makes the laptop unreachable, how Tailscale separates coordination from traffic, what a WireGuard tunnel carries, how hole punching opens two NATs, and what an SSH public-key login proves. It ends with the Windows-specific failures met on the way, the operating rules of an always-on machine and an estimate of its yearly energy cost.
The laptop in this piece is a ThinkPad T14s that sits on a desk at home and is the only computer I have there. I wanted its terminal on my phone from anywhere, with Claude Code running in it as if I were at the desk, and I did not want to rent a server or open a port on the router to get it. The setup took one afternoon, 20 October 2026, and the networking was the quick part: most of the time went into Windows, and the most useful finding of the day was a Python process that had been burning a processor core for twenty-two hours.
What you get here is every layer of that connection rebuilt from below: why a laptop at home cannot be reached at all, how two devices behind two different NATs end up talking directly, what an SSH login actually proves, where Windows got in the way, and what a laptop that never sleeps costs in electricity. The material is that afternoon's logs and screenshots. Public addresses in the figures and logs are replaced with addresses from the ranges reserved for documentation, private ones are shortened to 100.x, and anything that identifies the account is blacked out; names, timings and versions are real.
The end state: Claude Code launched on the laptop from the phone, at the PowerShell prompt of the SSH session. The lines above it close an earlier answer whose PowerShell tool had failed, one of the Windows problems described further down; the session identifier and the plan are blacked out.
In use26 · oct 20Read noteThe first real use came the same week. Sitting in the little garden opposite the hospital with a pain au chocolat, studying Chinese, I wrote my notes straight into the inbox of the Mandarin deck on the laptop at home. Until then they went to a WhatsApp chat with myself, as a buffer, and had to be copied over later. Skipping that step is a delight.
Why a laptop at home cannot be reached
Start with what the phone has to do. To open an SSH session it sends a TCP packet to the laptop's address on port 22. On the home wifi the laptop's address is 192.168.1.20, which means nothing outside the house, since thousands of other homes have a machine at 192.168.1.20 as well. Only the router holds an address the rest of the internet can route to, 203.0.113.45 here, and the whole household shares it.
The router makes that sharing work with network address translation. When the laptop sends a packet out, the router replaces the laptop's address and port with its own public address and a port of its choosing, and writes the pair in a table. A reply addressed to that public port is translated back and handed to the laptop. A packet that arrives with no matching line in the table belongs to nobody, and the router drops it. Every conversation through a NAT therefore has to start from the inside, and a phone that wants to start one with the laptop has nothing to knock on.
The phone is in the same position one level down. On 4G it does not get a public address of its own: the operator runs a second NAT (carrier-grade NAT) and puts thousands of phones behind each public address. So there are two devices, each behind its own NAT, each free to start conversations and neither able to receive one.
The two NATs between the phone and the laptop. The home NAT's table (a, on the right) is the only reason a reply ever finds its way back to the laptop, and it only holds the destinations the laptop has written to.
There are three classic ways out, and they cost different things.
Port forwardingThe router is told to send everything that arrives on a public port to the laptop. It solves the home side, and it puts the SSH server in front of the whole internet, where automated scanners try passwords on port 22 around the clock.
A relay serverA rented machine with a public address, which both devices dial out to. Nothing at home is exposed, but every byte takes a detour through a server you pay for and maintain.
A mesh VPNBoth devices dial out to a coordinator that only introduces them, then try to reach each other directly, with a relay kept for when they cannot. This is what Tailscale does, and the rest of the piece takes it apart.
One service introduces, the devices talk
Tailscale splits the work in two, and the split comes first because it decides what the company behind it can and cannot see. The control plane is a coordination server on the internet. When I installed the client on the laptop and signed in with a Google account, the laptop generated a key pair, kept the private half and uploaded the public half together with the addresses it could be reached at. The phone did the same a few minutes later. The server then told each device about the other: this is its public key, these are its addresses. That is all the server does with them.
The data plane is the set of encrypted tunnels between the devices, and the server is not part of it. When I type on the phone, the keystrokes go to the laptop inside a WireGuard tunnel whose keys exist only on those two devices. The server knows that the phone connected at 16:54 from some address; it has no way of knowing what was typed.
Two planes. The lines from the coordination server carry public keys and addresses over HTTPS; the tunnel between the devices carries the traffic. The relay at the bottom only enters when no direct path forms.
The split also decides what breaks. If the coordination server went down during a session, the session would go on, because the tunnel needs nothing from it. What would stop is change: a new device could not join, and a device that moved to another network could not announce its new address. The clients are open source and the server is not, but its part can be replaced by Headscale, an independent open-source reimplementation of the coordination server1.
1Headscale implements the control server only; the official clients connect to it unchanged.
The control plane's view before the phone joined: one machine, its client version and its operating system. Account details and the address are blacked out.
A tunnel with nothing to keep alive
A tunnel is a packet inside a packet. The laptop and the phone each get an extra network interface with an address in 100.64.0.0/10, a range set aside for operator NATs that never appears on the public internet. When the phone sends something to the laptop's 100.x address, the operating system hands the complete IP packet to WireGuard, which encrypts it and sends the result as the payload of an ordinary UDP datagram addressed to wherever the laptop can actually be reached. The laptop unwraps it, decrypts it and delivers the inner packet to its own network stack as if it had arrived on a cable.
WireGuard identifies a peer by its public key and nothing else. Each device holds a Curve25519 key pair, and before the first data packet the two run a handshake of one round trip, a key exchange in which each proves it holds the private key matching its public key and both derive fresh session keys. Data then travels encrypted and authenticated with ChaCha20-Poly1305, and the session keys are replaced every two minutes1. A packet that fails authentication gets no answer of any kind, so to a scanner the tunnel's port looks closed.
1Donenfeld, WireGuard: Next Generation Kernel Network Tunnel, NDSS 2017; the rekeying interval is the protocol constant of 120 seconds.
The design has a consequence that matters later for the electricity bill. WireGuard keeps no connection that has to be held open: a peer is a key and a last known address, and a handshake happens only when there is something to send. Tailscale adds a little on top (small packets to keep NAT entries alive while a path is in use, and a quiet link to a relay), but an idle tunnel costs almost nothing.
## Measured on this laptop Tailnet range100.64.0.0/10shared address space, not routed on the internet Inner MTU1280 bytesthe Tailscale interface ## From the protocol Overhead60 bytesper packet over IPv4: 20 IP, 8 UDP, 16 header, 16 tag Key exchangeX25519Curve25519 CipherChaCha20-Poly1305 Rekey120 s
One keystroke on the wire. From the inner IP header to the end of the SSH packet, the routers in between see only ciphertext; the tunnel adds 60 bytes to each packet and caps the inner one at 1280.
Inside the tunnel, the SSH session is encrypted a second time with keys of its own. That looks wasteful and is deliberate, because the two layers fail independently: a mistake in the VPN's configuration still leaves the session encrypted, and a weak SSH setup stays invisible to anyone outside the tailnet.
Punching through two NATs
The phone on 4G and the laptop at home both sit behind NATs that drop unsolicited packets, and the tunnel between them still runs directly, with no server in the middle. It works because by the time each packet arrives, it is no longer unsolicited.
The procedure is NAT traversal, and in the sequence below it takes six messages. Each device first asks a public server what address its packets appear to come from; Tailscale's relay servers answer this question with STUN, and the answer is the outside half of the NAT entry that the question itself just created. The two devices then exchange those answers through the coordination server. Finally both send small pings to each other's outside address. The phone's first ping reaches the home router and is dropped, since the laptop has never written to the phone. That dropped ping has done its job anyway: the carrier NAT now holds an entry for the laptop's address, so when the laptop's ping arrives a moment later, the carrier NAT lets it through. From then on both NATs hold the entry the other side needs.
NAT traversal in six messages, listed on the right. The failed ping in step 4 is what makes step 5 succeed.
The method rests on one property of the NATs involved: the outside port learned in the first step has to be the same one the NAT uses when the device writes to its peer. NATs that keep the same port whatever the destination (endpoint-independent mapping) allow it; NATs that pick a new port for every destination (often called symmetric) defeat it, because the address one side learned is not the one the other side would have to hit1. Tailscale checks its own side with tailscale netcheck, and on the laptop it gave this:
1RFC 4787, NAT Behavioral Requirements for Unicast UDP, 2007, sets out the mapping behaviours; Ford, Srisuresh and Kegel describe the technique in Peer-to-Peer Communication Across Network Address Translators, USENIX ATC 2005.
TEXT Copy
* UDP: true * MappingVariesByDestIP: false * Nearest DERP: Madrid - mad: 21.4ms (Madrid)
MappingVariesByDestIP: false is the line that matters: the home router keeps one outside port per inside port, so the address the phone learns is the one it has to hit. With the phone on the home wifi the question did not even arise. tailscale status showed the path as active; direct 192.168.1.x:41641, because Tailscale also offers each device's local addresses as candidates, found both devices on the same network and kept the traffic inside the house. When no direct path forms, packets go through a DERP relay1. It speaks HTTPS on port 443, which passes almost any network, and it forwards WireGuard packets it cannot decrypt. The relay costs a detour and its bandwidth limits; the nearest one here is in Madrid, 21 ms away. It costs nothing in privacy, since the packets it carries are the same ciphertext a direct path would carry.
1Tailscale, DERP servers; a longer account of the whole procedure is Tailscale's How NAT traversal works.
The phone's view of the tailnet: both devices online. Account and addresses are blacked out.The laptop as the phone sees it: a MagicDNS name, an IPv4 and an IPv6 address inside the tailnet, and a key that would expire in five months.
What an SSH login proves
With the tunnel up, the phone can reach port 22 on the laptop, and SSH takes over. An SSH login holds two separate checks in a fixed order, and they protect against different things.
In the first, the phone checks the laptop. Both sides run a key exchange that gives them a shared secret and a session identifier, and the laptop signs that identifier with its host key. The phone compares the host key with the one it saved on the first connection: that was the fingerprint prompt Termius showed the first time, which I accepted. A different key on a later connection would mean a different machine is answering. In the second, the laptop checks the phone, either by password or by public key.
An SSH login holds two checks. In the first the phone verifies the laptop's host key; in the second the laptop verifies a signature that was made inside the phone's Secure Enclave.
I started with a password, and it failed, for a reason the Windows section explains. The server log kept the attempts:
TEXT Copy
16:32:50 sshd: Failed password for yagom from 100.x port 53125 ssh2 16:32:51 sshd: Failed password for yagom from 100.x port 53125 ssh2 16:54:20 sshd: Accepted publickey for yagom from 100.x port 53148 ssh2: ECDSA SHA256:khtO…
The third line is the key login. Termius created the key as an SSH ID inside the iPhone's Secure Enclave, a chip that generates private keys and signs with them but has no operation for reading a key out. That chip only handles NIST P-256 keys1, which is why the log says ECDSA where a key generated in software would usually be Ed25519. Termius also published the public half at sshid.io, which is harmless, because a public key is meant to be public. Installing it on the laptop took one line in one file, and that file was not the obvious one (the Windows section again).
1Apple, SecureEnclave, CryptoKit documentation: the enclave offers P-256 signing and key agreement only.
With a key, the login is a signature. The phone signs data that includes the session identifier, the laptop verifies the signature against the public key it has on file, and nothing secret crosses the network. Because the session identifier is new on every connection, a recorded signature is useless for any other login1. A password, by contrast, travels to the server and can be guessed, so once the key worked I switched password login off altogether, with PasswordAuthentication no in C:\ProgramData\ssh\sshd_config. The only way in is now the key in the phone.
1RFC 4252, The Secure Shell (SSH) Authentication Protocol, section 7: the signed data begins with the session identifier.
Termius falls back to asking for a password whenever it has no key to offer. The prompt appeared before the key existed, and again after, until the host entry was pointed at the key.
Where Windows got in the way
None of the networking above needed any fixing. Windows did, in six places, and each failure was quiet: a progress bar that sat still, a login refused with no reason given, a file the server silently ignored. Inside the laptop a login crosses the same stations in a fixed order: the wifi adapter receives the UDP datagram, Tailscale decrypts it into a TCP connection to port 22, the firewall decides whether that connection may pass, sshd accepts it, the key check decides who is logging in, and a shell starts. The first five problems below sit on that path in that order; the sixth is about power.
The SSH server is not installed. OpenSSH Server ships as an optional Windows component, and Add-WindowsCapability hands the request to the servicing stack, which re-evaluates the whole component store before adding anything. The servicing log filled with lines about WinPE packages the machine does not have, and the progress bar sat still for most of twenty-two minutes (15:17 to 15:39) before the install finished. The firewall rule it creates is too wide. The rule OpenSSH installs admits port 22 from any address on networks Windows classes as private, which includes the home wifi. I disabled it and added one that admits port 22 only from 100.64.0.0/10, so the SSH server answers the tailnet and nothing else. The default shell has to be one a service can launch. sshd runs as SYSTEM and starts whatever DefaultShell names1. The PowerShell 7 on this laptop came from the Microsoft Store, where the executable is a per-user alias that activates a packaged app, and from a service session it fails. I set Windows PowerShell 5.1 instead. Claude Code, run over SSH, hit the same wall on its own: its PowerShell tool failed with The system cannot execute the specified program, as the first screenshot shows, and it fell back to Bash.
1Microsoft, OpenSSH Server configuration for Windows.
Administrators keep their keys somewhere else. For an account in the Administrators group, sshd ignores the user's .ssh\authorized_keys and reads C:\ProgramData\ssh\administrators_authorized_keys, and it ignores that file too unless only SYSTEM and Administrators can access it1. The one-liner sshid.io suggests appends to the first file and would have changed nothing.
1Microsoft, Key-based authentication in OpenSSH for Windows.
Windows Hello hid the password. The account is a Microsoft account, so its SSH password is the Microsoft account password. The machine had only allow Windows Hello sign-in for Microsoft accounts switched on, which removes the password option from the lock screen, and SSH password logins failed even with the right password. Turning the option off was one way out; moving to keys, which keeps the machine passwordless and is better anyway, was the other, and the one I took. The machine must stay awake. With the charger connected, standby and hibernation were set to never, the lid was already set to do nothing when closed, and the screen now turns off after five minutes. A sleeping laptop draws almost nothing and answers nothing.
The whole server side fits in one elevated script, which is what I ran (the key is installed by a second, shorter one):
POWERSHELL Copy
# OpenSSH Server, reachable only through Tailscale Add-WindowsCapability -Online -Name (Get-WindowsCapability -Online -Name 'OpenSSH.Server*').Name Set-Service sshd -StartupType Automatic; Start-Service sshd
# Shell for SSH sessions: Windows PowerShell 5.1 New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name DefaultShell ` -Value "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" -PropertyType String -Force
# Firewall: port 22 from the tailnet only Disable-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' New-NetFirewallRule -Name 'SSH-Tailscale-Only' -DisplayName 'SSH (Tailscale only)' ` -Direction Inbound -Protocol TCP -LocalPort 22 -RemoteAddress 100.64.0.0/10 -Action Allow
# Never sleep on mains power powercfg /change standby-timeout-ac 0 powercfg /change hibernate-timeout-ac 0
Check the server log before the client's error.
Every failure in this section showed up in Get-WinEvent -LogName 'OpenSSH/Operational' with a clear line (failed password, accepted key, connection aborted) while the phone only said that it could not log in.
What a laptop that never sleeps costs
The arithmetic is short. A constant draw of one watt for a year is 8.76 kWh, which at an assumed 0.15 € per kWh is about 1.30 € a year. So the question is how many watts the laptop draws while it waits, and the honest answer is that it depends almost entirely on what it is left doing (standby power).
When I looked, it was doing a lot. The screen was set to never turn off, on mains or on battery. Three development servers had been running since 14 and 18 October, two of them for projects I was not working on. And a Python script, launched by another Claude Code session the previous afternoon at 17:37, had been stuck in a loop for twenty-two hours, holding a full processor core. Total processor load sat around 50% with nobody at the keyboard. Closing the forgotten servers and the stuck script brought it down to about 30%, the rest being a build of this very site running in another session.
Estimated draw in three states and its yearly cost. The setup itself (Tailscale and the SSH server waiting for packets) adds too little to draw at this scale; the state of the machine decides the bill.
These are estimates from typical draws for a laptop of this class, not measurements. The laptop can measure itself without a power meter: unplugged, screen off, for one hour, its average draw is its battery capacity times the fraction it lost, so a 58 Wh battery that drops 9% in an hour is drawing about 5.2 W. A laptop that stays plugged in also has a battery problem. A lithium-ion battery ages fastest when it is kept full and warm, which is exactly the state of a laptop used as a server1. Lenovo Vantage has a charge threshold for this, and I switched it on with its default values: charging starts below 75% and stops at 80%. The battery reported 98% of its design capacity after 32 cycles, so it starts healthy.
1Battery University, BU-808: How to Prolong Lithium-based Batteries, on the effect of state of charge and temperature on capacity loss.
The battery before the change: 98% of design capacity after 32 cycles, on a 90 W adapter.The charge threshold, captured just before it was switched on; the range was left at its defaults. The battery serial is blacked out.
Running it day to day
Most events need nothing from me, because Tailscale and the SSH server both start as Windows services before anyone logs in. The ones that do need something are the ones a single machine cannot recover from on its own.
Event What happens What to do
Shut down Unreachable; the console shows it offline. Nothing at home can switch it on remotely Press the power button when home
Reboot or update Both services start before login; reachable again in a minute or two Nothing
Sleep Unreachable. Disabled on mains power Keep it plugged in
Power cut Stays on battery, but the router is down too; on battery Windows may sleep Nothing until the power returns
Lid closed On mains power, nothing happens Close it if you like
Key expiry Tailscale keys expire after 180 days; disabled for the laptop Keep expiry on for the phone
Phone lost The key cannot leave the Secure Enclave, but an unlocked phone can still sign Remove the phone in the console and delete its line from the keys file
The 180-day key expiry is Tailscale's default1. For the laptop it would mean an unreachable machine twice a year until someone signs it in again at the desk, so I disabled it there; the phone keeps it, because a lost phone's key should lapse on its own. Renaming a device in the Tailscale console changes its MagicDNS name. I renamed the laptop from its Windows name to thinkpad-64, and the old name stopped resolving from the phone on the spot; the Windows name itself did not change.
1Tailscale, Key expiry.
What I would add next
Sessions that survive the phone losing signal come first: today a dropped connection ends the shell and the Claude Code session running in it, and on Windows the usual answer (a terminal multiplexer such as tmux) only exists inside WSL. And the power measurement above, so the cheapest row of the chart becomes a number instead of a range.