Why Tailscale + Firewalld Is My Ultimate Remote Access Stack

Setting up secure remote access usually feels like a balancing act between security and sheer frustration. For a long time, my server setup relied on Fail2ban throwing up dynamic walls via traditional iptables. It worked, but it was messy, cluttered, and stopping the service felt like waiting for water to boil as thousands of rules were torn down one by one.

Recently, I made two major upgrades to my network architecture: migrating my firewall backend entirely to Firewalld (using optimized ipsets), and rolling out Tailscale for zero-config mesh networking.

Here is why this combination has completely transformed how I manage my servers and devices.


Clean Infrastructure: The Firewalld Advantage

If you are still managing raw iptables chains for services like Fail2ban, do yourself a favor and migrate to Firewalld. Moving my setup to Firewalld made overall server configuration infinitely easier.

Instead of dealing with an unreadable wall of text when checking active blocks, Firewalld handles everything through structured zones and clean, dynamic kernel ipsets. Fail2ban now behaves itself flawlessly in the background, keeping my main firewall rules clean and freeing up system resources.


Tailscale: Secure Networking That Just Clicks

Tailscale is built on top of WireGuard®, creating a secure, encrypted mesh network (a “Tailnet”) across your devices, no matter where they are in the world. You can download the client directly from the Tailscale Website.

There is no port forwarding required on your home router, and Firewalld makes it incredibly simple to handle your security permissions. To keep your network segmented, Firewalld allows you to isolate your virtual private network interface into its own strict, custom security perimeter.

Rather than dumping your mesh traffic into a generic, open “trusted” zone, you can create a custom zone that explicitly allows only the services you choose. Assuming your virtual mesh network interface is named vpn-mesh0, here are the three commands to lock it down:

bash

# 1. Create a dedicated firewall zone for your secure network mesh
sudo firewall-cmd --permanent --new-zone=tailmesh

# 2. Assign your virtual network interface directly to this new zone
sudo firewall-cmd --permanent --zone=tailmesh --add-interface=vpn-mesh0

# 3. Permit ONLY explicit services (e.g., SSH) through the mesh
sudo firewall-cmd --permanent --zone=tailmesh --add-service=ssh

# 4. Reload firewalld to activate the changes
sudo firewall-cmd --reload

Use code with caution.

By explicitly isolating the mesh traffic to its own zone, you enforce zero-trust security. Even if a client device on your network is compromised, the attacker cannot scan or access any unapproved ports on your server, keeping your environment perfectly locked down.


Smarter Connectivity: VPN On-Demand & Local WiFi Exceptions

A common issue with traditional VPNs is the “always-on” headache. You want your traffic encrypted when you are sitting in a sketchy coffee shop, but keeping the VPN tunnel active when you are at home or work can break local casting, slow down file transfers, or create routing loops.

Tailscale solves this beautifully by allowing you to configure VPN On-Demand with smart exceptions.

Using Tailscale’s client configurations (which you can dive deep into via the official Tailscale Documentation), you can set up smart triggers based on the Wi-Fi network you are currently connected to:

  • The Untrusted Network Trigger: The moment your phone or laptop connects to an open public Wi-Fi network, Tailscale automatically spins up your connection and routes your traffic through your home “Exit Node” for complete encryption.
  • The Trusted Local Exception: When you walk through your front door and connect to your home Wi-Fi, the client recognizes the SSID. It immediately disables the heavy routing or exit-node tunneling, granting you seamless, full-speed access to local network storage, smart home devices, and local media servers without unnecessary overhead.

The Verdict

By pairing Firewalld on the backend with Tailscale on the frontend, I’ve achieved the holy grail of homelabbing and system administration: total security without sacrificing convenience. The server stays tightly locked down and easy to manage, while my client devices adapt intelligently to whatever network environment I throw them into.

If you’re looking to simplify your remote access without cutting corners on your firewall, this is the blueprint to follow.


I Finally Dropped My Custom 1996 iptables Firewall Script—And Why It Was Time

Thirty years. That is how long my home network routing infrastructure relied on a monolithic shell script I wrote back in 1996. I crafted it back in the golden era of early Linux packet filtering, packed it with raw configuration lines, and tweaked it over decades as a hobby project to survive moving from ipfwadm to ipchains, and eventually to iptables.

It was a personal point of pride. Whenever I set up a new tool in my home lab, I didn’t click buttons; I opened a massive text file, added a manual tracking line, flushed the kernel tables, and reloaded the whole script.

But this week, on my Fedora router, the limitations finally caught up. It was time to pull the plug, learn the modern way of doing things, and migrate my entire home routing setup to firewalld and native nftables.

Here is the story of how I transitioned my home lab, the weird architectural traps I fell into, and how I built a modern, locked-down zone security posture.


The Catalyst: Tracking Down the Ghosts in the Logs

The transition started where all good home lab projects begin: digging through log streams. By default, firewalld drops packets silently. If you want to see what is actually happening behind the scenes, you have to tell it to start logging denied traffic.

I turned on global drop logging using this quick command:

bash

sudo firewall-cmd --set-log-denied=all

Use code with caution.

(Pro-tip: If your logs get too flooded later on after everything is working, you can easily turn this back off by running sudo firewall-cmd --set-log-denied=off).

With logging activated, my journalctl stream instantly exposed the ghosts haunting my network. My internal server was actively throwing network blocks:

text

filter_IN_internal_REJECT: IN=eth1 OUT= MAC=... SRC=10.0.10.55 DST=10.0.10.200 PROTO=TCP DPT=443 SYN

Use code with caution.

Because of these newly introduced blocks from the transition to firewalld, the computers inside my house suddenly couldn’t load local secure web pages, my email client setups were dropping synchronization steps, and my smart devices were choking on network discovery packets. While my legacy 1996 script had seamlessly allowed this internal traffic for years, firewalld‘s strict, out-of-the-box zone behaviors completely locked down the single machine from serving both functions until I configured it correctly.


Step 1: Restoring the Internet Gateway (Masquerading)

The first order of business was transforming my Fedora server into an efficient edge gateway router for the house. In the old days, this meant writing explicit POSTROUTING -o eth0 -j MASQUERADE loops.

With firewalld, we instead split the hardware interfaces into discrete logical security boundaries: external (the WAN modem link on eth0) and internal (the private LAN switch on eth1). To feed the household devices out to the web, we built a dedicated outbound routing policy:

bash

# Set up a clean outbound forwarding pipeline with NAT masquerading
sudo firewall-cmd --permanent --new-policy=internalToExternal
sudo firewall-cmd --permanent --policy=internalToExternal --add-ingress-zone=internal
sudo firewall-cmd --permanent --policy=internalToExternal --add-egress-zone=public
sudo firewall-cmd --permanent --policy=internalToExternal --set-target=ACCEPT
sudo firewall-cmd --permanent --policy=internalToExternal --add-masquerade
sudo firewall-cmd --reload

Use code with caution.

Suddenly, outbound data—including seamless features like Wi-Fi Calling on our phones—slid out to the internet natively without requiring a single manually defined high-number port mapping.


Step 2: Falling Into the “Policy Object” Trap

My first instinct as a hobbyist was to consolidate everything into elegant inter-zone Policy Objects. I attempted to route all inbound public web, mail, and application traffic through a custom externalToInternal forward policy.

The firewall immediately threw a massive architectural roadblock:
Error: INVALID_ZONE: 'forward-port' cannot be used because egress zone 'internal' has assigned interfaces

The lesson learned:firewalld Policy Objects completely forbid port-forwarding rules if the destination zone is attached to a real, physical network card. Policies are designed for virtual spaces (like isolated Docker networks or hypervisor VM bridges).

To achieve a pristine setup, the correct move was to put forwarding rules back onto the zone itself, but keep the raw underlying system ports completely closed off to the public router space.


Step 3: Embracing “Option B” (Strict Local Lockdown)

When configuring internal communication, I faced a fork in the road. Option A was a loose, blanket ACCEPT target for the whole internal network. Option B was a hardened, granular lockdown where only verified application services could move data.

I chose Option B.

Because firewalld drops negative priority policies when traffic is destined for the host itself, trying to manage local server loops through a policy wrapper caused endless rejections. The correct architectural solution was to map my application signatures directly onto our physical internal zone gate.

We stripped out manual numeric ports and used pre-configured service wrappers to expose exactly what we needed, keeping everything else locked tight against unauthorized lateral movement:

bash

# Lock down the internal gate to ONLY trusted local infrastructure services
sudo firewall-cmd --permanent --zone=internal --add-service=http
sudo firewall-cmd --permanent --zone=internal --add-service=https
sudo firewall-cmd --permanent --zone=internal --add-service=ssh
sudo firewall-cmd --permanent --zone=internal --add-service=dns
sudo firewall-cmd --permanent --zone=internal --add-service=smtp
sudo firewall-cmd --permanent --zone=internal --add-service=smtps
sudo firewall-cmd --permanent --zone=internal --add-service=imaps
sudo firewall-cmd --permanent --zone=internal --add-service=plex
sudo firewall-cmd --permanent --zone=internal --add-service=samba
sudo firewall-cmd --permanent --zone=internal --add-port=8123/tcp  # Home Assistant
sudo firewall-cmd --reload

Use code with caution.


The Final Verdict: A Pristine Architecture

After sweeping out legacy clutter (like default desktop samba-client wrappers and unneeded dhcpv6-client daemons on the inner switch interface), the firewall configuration achieved absolute stability.

Running a diagnostic audit prints out an incredibly clean, legible system baseline:

text

external (active)
  interfaces: eth0
  services: http https imaps smtp smtps ssh

internal (active)
  interfaces: eth1
  services: dns http https imaps plex samba smtp smtps ssh
  ports: 8123/tcp

Use code with caution.

Everything is fully persistent and gracefully survives cold reboots. Local devices can map storage drives over Samba, stream Plex, resolve local DNS queries, and view internal pages. Meanwhile, the public web interface remains blind to everything except our secure edge proxies—and public pings remain active to satisfy network path diagnostics and MTU discovery boundaries as recommended by universal best practices.

It took me thirty years to finally retire that old text script. But seeing a silent journalctl stream and a flawless, granular zone layout?

It was an awesome weekend project upgrade.