Moving from Wireguard to Tailscale for home server access

I have a few containerized services that I want to access when I'm not at home. Until now, that meant exposing them to the internet.

I have firewall ACLs in place, use HTTPS so credentials and other data are encrypted, and use fail2ban to watch for repeated failed login attempts.

For a number of years, being a relatively uninteresting home internet connection felt like a little extra security through obscurity. But obscurity isn't a security control, and public services get scanned regardless of how interesting I think they are.

I've noticed more probing of the applications I expose. AI may make some attacks easier to automate, but my logs don't tell me whether AI is involved. Either way, I'd rather remove unnecessary public access than keep giving scanners a login page to poke at.

(Very) short Tailscale intro

I had heard of Tailscale and knew it used WireGuard under the hood, but hadn't really looked into it until recently.

Tailscale builds a private network, called a tailnet, between my devices. Its coordination service handles things such as device discovery, public key distribution, and access policies. It isn't a central VPN server through which all my application traffic passes through.

Tailscale tries to establish direct, encrypted connections between devices. When that isn't possible, it can relay the traffic. The traffic remains encrypted between the Tailscale peers; a relay doesn't decrypt my application data.

Tailscale's attraction for me is how much of the peer discovery, connectivity, and configuration it takes care of.

Why Tailscale over manually managed WireGuard?

Once I read up on Tailscale, I was pretty much sold: WireGuard tunnels with much less manual configuration for the way I use my devices.

Suppose I'm in Boston, connecting to a device in New York, and I've configured a traditional VPN hub at home in Seattle. If my routing sends that connection through the hub, the path looks like this:

Boston <--> Seattle <--> New York

That's a consequence of my configuration, not a requirement of WireGuard. Only traffic routed through that tunnel takes the detour; it doesn't automatically include every internet connection or DNS lookup.

With a direct Tailscale connection, the application traffic can instead take this path:

Boston <--> New York

The coordination service helps the devices find each other. There isn't a requirement for the initial setup to travel through my Seattle home, and a direct connection isn't guaranteed on every network.

For my actual home services, the destination is at home, so traffic still needs to reach home. The benefit here is simpler private access and fewer publicly exposed services, rather than eliminating the home server from the path.

My Tailscale implementation

I'm all about containerization, so just about every service I host is in a container.

For this setup, I use one dedicated Tailscale container on the same Linux Docker host as my existing Caddy reverse proxy. That host has a reserved LAN address of 192.168.1.10. Both Tailscale and Caddy use host networking.

My backend containers remain independent. They don't join a special Tailscale Docker network, share the Tailscale container's network namespace, or need their own Tailscale installation.

Away from home:
Phone running Tailscale
  --> encrypted Tailscale connection to home-gateway
  --> 192.168.1.10:443 (Caddy, HTTPS)
  --> 127.0.0.1:8080 (a backend's published port)
  --> backend container

At home, with Tailscale disconnected:
LAN device --> 192.168.1.10:443 --> Caddy --> backend container

The gateway advertises 192.168.1.10/32. A /32 represents exactly one IPv4 address, so I don't need to advertise my entire home subnet just to reach Caddy.

That limits the destination address, not the destination ports. I still need access policy and firewall rules to control which services on that host are reachable.

The central Tailscale container

This example assumes a Linux Docker host with /dev/net/tun available. I don't run a second native Tailscale daemon alongside this container, because host networking means they would share the host's network stack.

My dedicated Compose file looks like this:

services:
  tailscale:
    image: tailscale/tailscale:latest
    container_name: tailscale
    hostname: home-gateway
    network_mode: host
    environment:
      TS_AUTHKEY: ${TS_AUTHKEY}
      TS_AUTH_ONCE: "true"
      TS_STATE_DIR: /var/lib/tailscale
      TS_USERSPACE: "false"
      TS_ROUTES: 192.168.1.10/32
    volumes:
      - tailscale-state:/var/lib/tailscale
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - NET_ADMIN
    restart: unless-stopped

volumes:
  tailscale-state:

TS_USERSPACE=false selects kernel networking, and the persistent state volume preserves the node's identity across container recreation. There are no published Docker ports because this container uses host networking.

I initial use latest for set up purposes, and for a maintained deployment, I would pin a tested version or digest and update it deliberately.

I generate a non-ephemeral auth key in the Tailscale admin console and put it in a local .env file:

TS_AUTHKEY=replace-with-my-tailscale-auth-key

That file contains a secret, so I keep it out of source control and restrict its permissions. A one-time key is sufficient for initial enrollment when state is retained. After enrollment, I can remove the key from the Compose environment and .env; the stored node identity is what keeps the container enrolled.

If I use a gateway tag, I first define its owners in my tailnet policy and select that tag when creating the key. A tag is a service identity, not an automatic permission to reach every service.

For the standard Linux subnet-router setup, I enable forwarding on the host, rather than trying to set host-network sysctls inside Compose:

To check to see if forwarding is enabled on the host:

sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding

If either net.ipv4.ip_forward or net.ipv6.conf.all.forwarding are 0, then forwarding is disabled.

To enable it:

sudo tee /etc/sysctl.d/99-tailscale-forwarding.conf >/dev/null <<'SYSCTL'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
SYSCTL
sudo sysctl -p /etc/sysctl.d/99-tailscale-forwarding.conf

Then bring up the Tailscale Docker container:

docker compose up -d
docker exec tailscale tailscale status

Here, Caddy is on the gateway host itself, so its requests terminate locally rather than being forwarded to another LAN machine. I check the host's input firewall rules as well as any forwarding rules. I allow the intended HTTPS traffic through tailscale0 and from my trusted LAN, without broadly opening other host services.

Approving the route and allowing access

Advertising a route is only the first step. In the Tailscale admin console, I find home-gateway, open its route settings, and approve 192.168.1.10/32. Device approval, when enabled, is a separate step; a pre-approved enrollment key does not by itself approve subnet routes.

I also allow the intended users or devices to reach 192.168.1.10 on TCP port 443 in my tailnet access policy. Route approval and access permission are separate controls. Android accepts approved subnet routes automatically.

For example, a narrowly scoped grant could look like this inside the existing grants array:

{
  "src": ["my-login@example.com"],
  "dst": ["192.168.1.10/32"],
  "ip": ["tcp:443"]
}

I replace the sample login with my actual tailnet identity. This is a fragment, not a replacement for the whole policy. Permissions are additive: adding a narrow grant doesn't cancel an existing allow-all rule. I review those broader rules too. If I want HTTP redirects, I allow TCP 80 separately; HTTP/3 would also require UDP 443.

Keeping the backend containers independent

Each backend keeps its own Compose project, volumes, and update schedule. For a backend listening on container port 80, the relevant port mapping is:

# Fragment to add to the existing backend service:
ports:
  - "127.0.0.1:8080:80"

I substitute the application's actual listening port and choose a different host port for each backend.

Because Caddy uses host networking, it can reach 127.0.0.1:8080 on the Docker host. Publishing to loopback avoids intentionally exposing that backend port on every host interface. I still check the resulting exposure and keep Docker current.

If a backend runs on another machine, 127.0.0.1 won't reach it. I would use that machine's private address, firewall its backend port appropriately, and consider HTTPS for the additional network hop.

Cloudflare wildcard DNS

My Cloudflare zone has this record:

Type Name Content Proxy status
A *.nodinrogers.com 192.168.1.10 DNS only

In Cloudflare's record editor, the name can be entered as * within the nodinrogers.com zone.

The gray-cloud, DNS-only setting matters. Cloudflare supplies the DNS answer; its ordinary public reverse proxy doesn't connect to this private LAN address.

I check for explicit records that override the wildcard, including stale A, AAAA, or CNAME records for the names I use. A wildcard also doesn't replace the apex record for nodinrogers.com. Cloudflare's wildcard documentation explains the matching rules.

Public DNS returning a private IP doesn't make the server publicly reachable, but it doesn't make the DNS information private either. Some resolvers block these answers as DNS rebinding protection. If that happens, I use a narrowly scoped exception or an internal DNS arrangement rather than disabling protection globally.

MagicDNS isn't required for these existing domain names. What matters is that the phone resolves web.nodinrogers.com to 192.168.1.10 and has a working route to that address.

HTTPS with my existing Caddy proxy

I still want HTTPS even though remote access also passes through an encrypted Tailscale tunnel. HTTPS authenticates the website and protects browser traffic on my LAN when Tailscale is disconnected.

For a private service, I use ACME DNS-01 validation. Caddy proves control of my domain by creating a DNS TXT record; the certificate authority doesn't need an inbound connection to my home web server.

I already have a working wildcard certificate configuration, so I keep it and add the new hostname's routing there. A simplified wildcard site block looks like this:

*.nodinrogers.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }

    @web host web.nodinrogers.com
    handle @web {
        reverse_proxy 127.0.0.1:8080
    }

    handle {
        respond "Not found" 404
    }
}

I merge that pattern into my existing configuration rather than adding a duplicate wildcard block. I retain Caddy's persistent certificate storage and ensure it listens on the LAN address, not only on loopback.

Before reloading, I validate the configuration:

docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile

In this example, HTTPS terminates at Caddy and the final backend hop uses HTTP through a local published port. That is distinct from end-to-end HTTPS all the way into the backend container.

Access without manually connecting each time

On my Android phone, I install Tailscale, sign into the appropriate tailnet, approve Android's VPN connection prompt, and connect. If device approval is enabled, I approve the phone too.

I leave the exit node set to None, keep subnet routes enabled if that setting is shown, and make sure my browser isn't excluded from Tailscale through app-based split tunneling.

After testing access, I enable Android's Always-on VPN option. On a Pixel-style settings layout, the path is:

Settings → Network & internet → VPN → Tailscale's gear icon

Menu names can vary by manufacturer. I enable Always-on VPN and leave Block connections without VPN disabled for this split-routing setup.

This keeps Tailscale available in the background; it isn't a domain-triggered connection that starts when I open a particular bookmark and stops when I close it.

The routing decision is based on the destination IP. When web.nodinrogers.com resolves to 192.168.1.10, its traffic follows the approved host route. With no exit node selected, ordinary public internet destinations use my usual connection. DNS handling is a separate setting and may still be managed by Tailscale.

What happens on my home Wi-Fi?

Devices on my LAN can use the same HTTPS URL without Tailscale because they can already reach 192.168.1.10.

A phone with Tailscale connected may still use the advertised /32 route while at home. I don't assume always-on VPN automatically switches this destination to direct LAN access. If I specifically want to test the direct LAN path, I temporarily turn off always-on VPN and disconnect Tailscale.

Checking the whole path

I test both local and remote access before removing my old access method:

  1. On the LAN with Tailscale disconnected, open https://web.nodinrogers.com and check the certificate.
  2. On the phone, turn off Wi-Fi, leave cellular data enabled, connect Tailscale, and open the same URL.
  3. Confirm the ordinary internet still works with no exit node selected.
  4. Restart the gateway container and confirm it retains its identity and access still works.
  5. Remove the old public exposure, then test again from outside my home with Tailscale disconnected. The home service should no longer be reachable through the old public address or hostname.

Useful checks from the server or a connected laptop include:

docker exec tailscale tailscale status
docker logs --tail 100 tailscale
dig +short A web.nodinrogers.com
curl -I https://web.nodinrogers.com

I expect DNS to return 192.168.1.10. An HTTP response may be a redirect or authentication response rather than 200 OK; the point is to confirm that I reached the intended service over valid HTTPS.

A smaller public attack surface, not a replacement for security

The migration only removes public exposure once I close the old paths. I remove unnecessary router port forwards, and review IPv6 firewall rules as well as IPv4. Changing DNS alone doesn't stop someone connecting to an already exposed public address.

I keep application authentication, updates, backups, and useful logging. I protect my Tailscale account with strong authentication, review enrolled devices and access rules, and protect the gateway's state and API credentials. Host networking and NET_ADMIN give the gateway significant access to the host, so that container deserves the same care as other network infrastructure.

I also remember that all the websites behind Caddy share the same destination IP and port. A network permission for 192.168.1.10:443 doesn't distinguish one hostname from another. Per-application restrictions belong in Caddy or the applications' authentication layers. LAN access also needs its own controls; tailnet policy doesn't govern connections that never enter Tailscale.

One gateway is convenient, but it's also a dependency: if that container or host is down, remote access through it is down. And the browser doesn't necessarily see the same source-address behavior through a proxy or subnet router that it would on a direct connection, so I verify logging before relying on IP-based bans.

For my setup, the appeal is straightforward. I keep my existing domain names, HTTPS, and independent containers, while making remote access depend on an authorized private connection instead of a publicly reachable login page.