I’ve been using Claude Code heavily for development work, and there’s something magical about having one perfectly configured machine with all my tools, dotfiles, and project context exactly as I want them.

One nagging issue I’ve had is switching devices (from desktop to laptop or vice-versa) meant:

  1. Spending time syncing everything through Git (losing flow state)
  2. Inevitably different state (linux to mac)

Previously I’d just used basic SSH, maybe with some jump server setup, but never felt great about the experience.

My goals were simple - SSH access that was:

  • Secure by default (no exposed ports)
  • Works seamlessly at home and remotely
  • Great DevEx that simplifies access regardless of location

I’d heard good things about Tailscale, a mesh VPN that’s free for personal use. It also has Tailscale SSH which handles SSH access natively.

Figured I’d write myself a quick guide for “6 months from now me” on how I got it working.

Setup#

You don’t need Tailscale on your router. With Tailscale installed directly on each endpoint, they connect peer-to-peer. Your router just needs to not block UDP outbound (most don’t by default). I previously ran Tailscale on an OPNSense router, but after switching to a UniFi Ubiquiti Gateway Max I dropped that entirely — it’s unnecessary for point-to-point SSH access.

ArchLinux desktop (zoe):

sudo pacman -S tailscale
sudo systemctl enable --now tailscaled
sudo tailscale up --ssh

If you see health check warnings about iptables MARK, make sure you’re using iptables-nft instead of legacy iptables:

# Check which you're running
iptables --version
# If it says "legacy", switch to nft
sudo pacman -S iptables-nft
# You may also need the nft nat table/chain
sudo nft add table ip nat
sudo nft 'add chain ip nat POSTROUTING { type nat hook postrouting priority srcnat; policy accept; }'
sudo systemctl restart tailscaled

Also watch out for DNS issues on Linux — if tailscale status shows health check warnings:

# See: https://tailscale.com/kb/1188/linux-dns
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
sudo systemctl restart systemd-resolved
sudo systemctl restart NetworkManager
sudo systemctl restart tailscaled

MacBook (mac):

The App Store GUI version is sandboxed and can’t run --ssh. Use the brew variant instead:

brew install tailscale
brew services start tailscale
# Note you may need to allow in settings for tailscale on startup
tailscale up --ssh

One gotcha: the brew install can’t manage system DNS resolvers the way the App Store version can, so MagicDNS may not work. Rather than fighting it, add an entry to ~/.ssh/config on the Mac with everything you need — DNS bypass, agent forwarding, port tunneling, and keepalives:

Host zoe
    HostName 100.89.244.68  # Tailscale IP, find via `tailscale status`
    ForwardAgent yes
    LocalForward 8889 localhost:8889
    LocalForward 8084 localhost:8084
    ServerAliveInterval 15
    ServerAliveCountMax 3

Now ssh zoe just works regardless of DNS, forwards your YubiKey’s SSH agent, tunnels dev server ports, and drops cleanly on network changes instead of hanging.

Within minutes, both devices showed up in my Tailscale network:

❯ tailscale status
100.89.244.68   zoe                   tsoporan@    linux
100.120.25.92   mac                   tsoporan@    macOS

YubiKey, GPG & Commit Signing#

I use a YubiKey for development — it holds both GPG and SSH keys. When working remotely, the YubiKey is plugged into the laptop, not the desktop. This creates a challenge for signed git commits.

The problem with GPG signing remotely: GPG agent socket forwarding over SSH is fragile and often doesn’t work, especially with Tailscale SSH. After much frustration, I switched from GPG-signed commits to SSH-signed commits. Git has supported gpg.format = ssh since v2.34, and it’s much simpler because SSH agent forwarding (ForwardAgent yes) already works.

Switch to SSH signing on the desktop:

# Export your YubiKey's SSH public key
gpg --export-ssh-key your@email.com
 
# Configure git to use SSH signing
git config --global gpg.format ssh
git config --global user.signingkey "key::ssh-rsa AAAA..."  # paste the public key
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
 
# Create the allowed signers file
echo "your@email.com ssh-rsa AAAA..." > ~/.ssh/allowed_signers

Don’t forget to add the same public key as a Signing Key (not just authentication) in your GitHub/GitLab SSH key settings.

GPG agent with SSH support on both machines:

Both the desktop and laptop need enable-ssh-support in ~/.gnupg/gpg-agent.conf and the SSH_AUTH_SOCK environment variable pointing to the gpg-agent SSH socket:

# ~/.gnupg/gpg-agent.conf
enable-ssh-support
 
# ~/.zshrc (or equivalent)
export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)

The zellij session trap: If you use a terminal multiplexer like Zellij, the session preserves the environment from when it was originally created. When you SSH in and attach an existing session, panes still have the old SSH_AUTH_SOCK pointing to the local gpg-agent — not the forwarded agent from your laptop.

The fix has two parts. First, create ~/.ssh/rc on the desktop to update a stable symlink on every SSH login:

#!/bin/bash
if [ -n "$SSH_AUTH_SOCK" ] && [ "$SSH_AUTH_SOCK" != "$HOME/.ssh/agent-forward.sock" ]; then
    ln -sf "$SSH_AUTH_SOCK" "$HOME/.ssh/agent-forward.sock"
fi

Then update ~/.zshrc to use the symlink when inside zellij, and the local gpg-agent otherwise:

if [[ -S "$HOME/.ssh/agent-forward.sock" ]] && [[ -n "$ZELLIJ" || -n "$ZELLIJ_SESSION_NAME" ]]; then
    export SSH_AUTH_SOCK="$HOME/.ssh/agent-forward.sock"
elif [[ -z "$SSH_AUTH_SOCK" ]] || [[ "$SSH_AUTH_SOCK" == *"gpg-agent"* ]]; then
    export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)
fi

Important: After your first remote SSH login, you need to start a fresh zellij session for the new SSH_AUTH_SOCK to take effect in all panes. Subsequent SSH reconnections will update the symlink automatically — existing panes will pick up the new agent on the next command.

With ForwardAgent yes in ~/.ssh/config, just ssh zoe forwards the YubiKey’s SSH key, and signed commits work seamlessly on the desktop.

Testing#

At this point we have both devices setup with tailscale + ssh. You should be able to seamlessly do ssh zoe from the Mac and get right in.

The Tailscale admin console also has a built-in SSH test feature. Click it, set username, and everything should work.

To verify the connection path, use tailscale ping zoe from the Mac — it’ll tell you if you’re going direct or via a DERP relay. Direct is faster, but DERP is still end-to-end encrypted and perfectly usable for SSH.

Since I use zoe as primary for development, I now just connect and zellij attach main to resume my Zellij session and get back to work exactly where I left off.

Port Forwarding & Dev Services#

SSH tunneling is the easiest way to access dev servers running on your desktop from the laptop. With the LocalForward lines in ~/.ssh/config (shown above), everything is automatic when you ssh zoe.

For HTTPS services using local dev certs (e.g. mkcert), your Mac won’t trust the certificate authority by default. You need to copy the mkcert root CA from zoe and trust it on the Mac:

# Find where mkcert stores its CA on zoe
mkcert -CAROOT
# e.g. /home/tsoporan/.local/share/mkcert
 
# From the Mac, copy it over and trust it
scp zoe:/home/tsoporan/.local/share/mkcert/rootCA.pem ~/mkcert-rootCA.pem
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/mkcert-rootCA.pem

Restart your browser after — HTTPS dev services will now load without SSL errors.

If your dev services use custom local domains (e.g. canopy.local.bungalow.dev), add them to /etc/hosts on the Mac as well:

127.0.0.1 canopy.local.bungalow.dev

macOS can be stubborn about DNS caching, so flush after editing:

sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder

The Magic#

What’s actually happening here is pretty cool. Tailscale uses multiple NAT traversal techniques to establish direct encrypted connections between devices without exposing any ports to the internet.

The core problem: When you’re behind a router, you have two obstacles:

  • Stateful firewalls - allow outbound traffic, block inbound unless there was a matching outbound packet first
  • NAT devices - rewrite your private IP to a public one, but create different mappings for different destinations

The “simultaneous transmission” trick: Both devices send packets to each other at the same time. Each device’s outbound packet “opens” its firewall for the return traffic, even if the initial packets get lost. It’s like both sides knocking on each other’s door simultaneously.

STUN discovery: Tailscale asks servers “what’s my public IP:port from your perspective?” This reveals what your traffic looks like on the internet after NAT translation.

Different NAT behaviors:

  • Easy NATs (most home routers): Create one mapping per device that works for any destination
  • Hard NATs (corporate firewalls): Create different mappings for every destination you talk to

Port mapping protocols: When available, Tailscale can ask your router via UPnP/NAT-PMP to forward a specific port, making connections much easier.

The birthday paradox: For stubborn NATs, Tailscale can use probability theory - open many ports on one side and randomly probe from the other. Math works out that you find a match much faster than brute force.

ICE algorithm: The elegant solution is “try everything at once and pick what works best.” Tailscale simultaneously attempts:

  • Direct LAN connection (when you’re home)
  • Multiple STUN-discovered endpoints
  • Port-mapped connections
  • Various IPv4/IPv6 combinations

When I’m at home: traffic routes directly through my LAN. When remote: encrypted tunnel through Tailscale’s network, either direct (peer-to-peer via NAT traversal) or via DERP relays. Same commands, zero configuration.

The fallback is always DERP relays - if direct connection fails, traffic routes through Tailscale’s servers, but it’s still end-to-end encrypted. You can check your connection path anytime with tailscale ping <host>.

Final Thoughts#

I’m always looking for small productivity hacks that improve my workflow. Tailscale deserves all the praise it gets for taking something that’s typically annoying/complicated to set up right and making it just work.

Having one consistent development environment that follows me everywhere has been a game-changer. No more context switching, no more “let me just sync this,” no more productivity drops when working remotely.

Big fan.