Tech Digest

Guide

Remote access without port forwarding

There are four ways to reach a home server from outside, they fail in different ways, and the one most people try first is the one that gets them owned. Here is how to choose.

Last reviewed

How do you access a home server remotely without port forwarding?

Install a mesh VPN. Tailscale, or Headscale if you want the coordination server to be yours, gets every device onto one private network using outbound connections only, so it works behind CGNAT and needs no router configuration. Reach for a plain WireGuard endpoint such as wg-easy only if you have a real public IP and want no third party involved, and use an outbound tunnel such as Cloudflare Tunnel only for things that genuinely need to be public. Opening a port is a legitimate fourth option, but only behind a reverse proxy with authentication in front, never straight at an application's own login page.

You built the server, it works on the sofa, and now you want it to work from a train. There are exactly four ways to do that, and the difference between them is not convenience. It is what an attacker on the public internet can reach.

The four routes#

1. Mesh VPN. Every device runs an agent that dials out to a coordination server, gets told about your other devices, and then negotiates a direct encrypted link to them. Tailscale is the product; Headscale is the self-hosted coordination server that official Tailscale clients also speak to. Nothing is exposed to the internet, no router configuration exists, and it works behind CGNAT.

2. Plain WireGuard. One UDP port at your house, one config file per client. wg-easy wraps this in a web UI that generates configs and QR codes. Small, fast, no third party, and completely dependent on you having a real inbound path.

3. Outbound tunnel. A daemon on your server dials out to a provider, which then routes public traffic back down that connection. Cloudflare Tunnel is the common one. No inbound port, works behind CGNAT, and the price is that a company now terminates TLS for your traffic and sees the plaintext.

4. Forward a port, properly. 443 open, one Caddy or Traefik in front of everything, and authentication that runs before the application does. This is a legitimate architecture. It is only dangerous in the version where step two and three are missing.

Decision tree#

Work down this list and stop at the first line that applies.

  1. Only you and your household need access, from devices you control. Mesh VPN. Stop here. This is the correct answer for something like 80 percent of homelabs and it is where most people should have started.
  2. You are behind CGNAT. Mesh VPN, or an outbound tunnel. Routes 2 and 4 are unavailable to you and no amount of router configuration changes that.
  3. Something has to be reachable by a person who will never install a VPN client. A public status page, a shared photo album link, a webhook receiver from a SaaS product. Outbound tunnel or route 4, for that one hostname only, with everything else still behind the VPN.
  4. You stream a lot of media to people outside the house. Route 4 with a real public IP. Not a free tunnel, for terms of service reasons covered below.
  5. You want no third party in the path at all and you have a public IP. Plain WireGuard, or route 4 behind a proxy with forward auth.

Note that these compose. The common good setup is a mesh VPN for everything plus exactly one publicly reachable hostname, rather than an all-or-nothing choice.

CGNAT: what it is and how to prove it#

Carrier-grade NAT means your ISP gave you an address from a shared pool rather than a public one, and translates it at their border alongside hundreds of other customers. Outbound works normally. Inbound does not exist, because there is no port on their translator that belongs to you and no way for you to request one. Port forwarding on your own router still appears to work, which is why people spend an evening debugging a rule that was never going to matter.

It is extremely common on 4G and 5G home broadband, on many fiber overbuilds, and on IPv4 at ISPs that ran out of addresses.

bash
# What the internet sees as your address
curl -4 https://ifconfig.co
dig +short myip.opendns.com @resolver1.opendns.com

# Now compare with the WAN address your router holds.
# On OpenWrt or any Linux router:
ip -4 addr show dev wan | grep inet

# On the LAN side, the first public hop tells the same story
traceroute -n -m 5 1.1.1.1

If the router's WAN address is inside 100.64.0.0/10, 10.0.0.0/8 or 172.16.0.0/12 while ifconfig.co reports something else, you are behind CGNAT. RFC 6598 reserves 100.64.0.0/10 specifically for this, so it is close to a positive identification.

One false positive worth knowing: Tailscale hands out addresses from 100.64.0.0/10 too. A machine already on a tailnet will show a 100.x address that has nothing to do with your ISP. Check the router.

Some ISPs will move you to a real public IPv4 on request, sometimes free, sometimes for a few dollars a month. That single phone call is often the cheapest fix available. Many also give you native IPv6 with real inbound, which solves the problem for every client that has IPv6, which is not all of them.

The install is genuinely three commands, and the interesting part is what you do afterwards.

bash
# On the server, join and advertise the LAN so tailnet clients
# can reach devices that will never run an agent (printers, IoT, the NAS UI)
tailscale up --advertise-routes=192.168.1.0/24 --accept-dns=false

# Against a self-hosted Headscale control server instead:
tailscale up --login-server=https://headscale.example.com \
             --advertise-routes=192.168.1.0/24

# On Headscale, approve the node and enable the advertised route
headscale nodes list
headscale nodes approve-routes --identifier 3 --routes 192.168.1.0/24

# Optional: make one machine the exit node for untrusted wifi
tailscale up --advertise-exit-node

The subnet router is the piece people miss. Without it you can reach machines running the agent and nothing else. With it, your phone on a hotel network can open the web UI of a device that has no idea a VPN exists.

If you self-host the control server, take the operational cost seriously. Headscale is BSD-3-Clause, idles at about 40 MB, and sits at ops 3 out of 5 in this index for good reasons: it is pre-1.0, you must upgrade one stable minor at a time (0.27 to 0.28 to 0.29, never skipping, because migrations for databases older than 0.25.0 were removed in 0.28), and v0.29.3 documents a minimum Tailscale client of 1.80.0. There is no web UI; users, pre-auth keys and route approval are all subcommands. Version 0.29 also redefined a bare * in an ACL to mean the tailnet range rather than everything, which silently broke subnet routing for people who upgraded without reading. Back up /var/lib/headscale, including noise_private.key: lose it and every node re-registers from scratch.

Plain WireGuard, when it fits#

wg-easy is the sane way to run a WireGuard endpoint if you have inbound. One container, NET_ADMIN and SYS_MODULE, a read-only bind of /lib/modules, 51820/udp published and 51821/tcp kept firmly on the LAN.

Two things will trip you up. The latest Docker tag still points at v14, so pin :15 or you are running the old code on the old CC BY-NC-SA licence without knowing. And there is no v14 to v15 upgrade path at all: you export from v14, deploy v15 into a clean volume and import during the setup wizard, which is not available once you click past it. v15 also dropped 32-bit ARM entirely, so a Pi 3 on a 32-bit OS cannot run it.

The security property that makes WireGuard worth the trouble: the UDP port does not respond to anything that fails authentication. To a scanner it is closed. Compare that with an HTTPS port, which answers every stranger with a TLS handshake and a login form.

Headscale vs wg-easy goes through the tradeoff in detail.

Outbound tunnels and the thing nobody mentions#

Cloudflare Tunnel is legitimately good engineering: cloudflared makes outbound connections only, so there is nothing to forward and nothing to scan, and it works behind CGNAT.

Read the terms before you route your media library through it. Cloudflare's service-specific terms say they may disable or limit your use of the CDN if you use it "to serve video or a disproportionate percentage of pictures, audio files, or other large files" without the relevant paid services. A Jellyfin or Plex library streaming to relatives is precisely that description, and people do get throttled or contacted. The free plan also caps request bodies at 100 MB and returns 413 above it, so uploading a video to Immich or a large file to Nextcloud through the tunnel fails in a way that looks like an application bug.

The structural cost is bigger than either. TLS terminates at Cloudflare, not at your server, so your traffic is plaintext inside their network by design. If part of why you self-host is that you did not want a company reading your data, routing all of it through a company is a strange place to end up. Use tunnels for the handful of things that must be public, and a VPN for everything else.

Split-horizon DNS, so one hostname works everywhere#

You want photos.example.com to work on the sofa and on the train, with no bookmarks that differ. Three arrangements, in increasing order of how much you will like them:

  • Public DNS only. The name resolves to your public address everywhere. At home, traffic leaves your network and comes back, which some routers refuse to do at all (this is NAT hairpinning, and its absence is why the site works from your phone on mobile data and not on your own wifi).
  • Split horizon. Your internal resolver answers with 192.168.1.20, public DNS answers with the public or tunnel address. Fast at home, works away, and the certificate is valid in both cases as long as you used DNS-01. This needs a resolver you control: Pi-hole, AdGuard Home or Technitium DNS Server all do it.
  • Tailnet address in both. Publish the 100.x tailnet address in public DNS. It resolves identically everywhere, only works if you are on the VPN, and that is exactly the property you want for a private service. No split horizon to maintain, no hairpinning question.

The DNS guide below covers the resolver configuration, including the DNS rebinding protection that will silently break the second option.

The mistake: exposing an app's own login page#

This is the most common failure in self-hosting and it deserves plain language. Do not port forward straight at an application. Not Nextcloud, not Home Assistant, not Node-RED, not a media server.

The reasoning is not that these are bad projects. It is that a self-hosted application's login form is not a security boundary that anyone has hardened. Concretely, from this index: Nginx Proxy Manager boots with admin@example.com and changeme on port 81 and has no rate limiting in front of it. Technitium DNS Server boots with admin and admin. Node-RED ships with no authentication at all, and its function node executes arbitrary JavaScript as the process user while the exec node runs shell commands. None of these are secrets; they are in the documentation, which means they are in every scanner.

The fix is one layer, and it takes ten minutes:

caddyfile
# Everything internal: only answer to the tailnet and the LAN
(private) {
	@public not remote_ip 100.64.0.0/10 192.168.1.0/24
	respond @public 403
}

nodered.example.com {
	import private
	reverse_proxy 10.0.0.30:1880
}

# Public, but authenticated before the app is ever reached
files.example.com {
	forward_auth 10.0.0.31:9091 {
		uri /api/authz/forward-auth
		copy_headers Remote-User Remote-Groups Remote-Email
	}
	reverse_proxy 10.0.0.20:8080
}

The first block means an unauthenticated stranger gets a 403 from the proxy and never opens a TCP connection to Node-RED itself. The second sends every request to Authelia first, which returns 200 or a redirect to its own login page, and only then does the request reach the application. authentik does the same job with more features and about a gigabyte of RAM; Pocket ID does passkey-only OIDC in 30 MB if your apps speak OIDC natively.

Whatever route you choose, put multi-factor on the one account that matters and keep Vaultwarden behind either the VPN or a proxy you trust. It is one of the very few self-hosted apps designed on the assumption that it will be exposed.

What to do next#

Install a mesh VPN today, before anything else, because it makes every other decision reversible. Then read Reverse proxy and TLS to put one certificate story in front of your services, and DNS for self-hosters to make the names resolve correctly from both sides. When you are ready to open exactly one hostname to the world, A security baseline for a home server covers what else should be true first, and CrowdSec vs Fail2ban covers what to do about the traffic that arrives afterwards.

Questions#

How do I know if I am behind CGNAT?

Compare the WAN address your router reports with what the internet sees. Run curl -4 https://ifconfig.co from inside the network and look at the router's WAN status page. If they differ, your ISP is translating and you have no inbound. A WAN address inside 100.64.0.0/10 is the giveaway, because RFC 6598 reserves that range for exactly this. One catch: Tailscale also assigns addresses from 100.64.0.0/10, so check the router, not a machine already on a tailnet.

Does a VPN work behind CGNAT?

A mesh VPN does, because both peers dial out and a relay introduces them. Tailscale and Headscale both do NAT traversal and fall back to a DERP relay when direct connection fails. A plain WireGuard server does not, because your phone has to open a connection to a UDP port at your house and there is no inbound path to it. If you are behind CGNAT and want plain WireGuard, you need a small VPS with a public IP to host the endpoint.

Is Cloudflare Tunnel free for streaming my media library?

Not within the terms. Cloudflare's service-specific terms state they may disable or limit your use of the CDN if you serve video or a disproportionate percentage of pictures, audio files or other large files without the relevant paid services. Running Jellyfin or Plex Media Server transcodes through a free tunnel is exactly that. The proxy also caps request bodies at 100 MB on Free and Pro plans and returns 413 above it, which breaks large uploads to Immich and Nextcloud.

Can I just open port 443 and use my app's login page?

You can, and it is the most common mistake in self-hosting. Most self-hosted apps have never had a security audit, many ship with published default credentials, and almost none rate limit their login form. Put a reverse proxy in front, then either restrict by source network or add forward authentication with Authelia, authentik or Pocket ID so an unauthenticated request never reaches the application at all.

How do I make the same hostname work at home and away?

Split-horizon DNS. Your internal resolver answers photos.example.com with the LAN address, public DNS answers it with the tunnel or public address, and clients use whichever resolver they can reach. On a mesh VPN the cleaner version is to answer with the tailnet address in both places, so the name resolves to something reachable from anywhere and you never think about it again. See the DNS guide for the resolver configuration.

Should I run Headscale or just use Tailscale's servers?

Use Tailscale's coordination server unless you specifically object to your device inventory living there, because the free tier is generous and the failure modes are somebody else's. Headscale is a clean-room reimplementation of that control server that official Tailscale clients speak to, and it is genuinely good, but it is pre-1.0 with real breaking changes: you must upgrade one minor at a time and v0.29.3 documents a minimum client version of 1.80.0. That is an ops load of 3 out of 5 for something whose failure locks you out of your own network.

What ports does WireGuard need open?

One UDP port, 51820 by default, forwarded to the server. That is the entire attack surface, and it is a good one: WireGuard does not respond at all to packets that fail authentication, so the port is invisible to scanners. wg-easy adds a web UI on 51821/tcp for generating client configs and QR codes, and that port should never be forwarded. Since v15 it refuses to serve the UI over plain HTTP unless you set INSECURE=true.

Sources#

Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.