VPN-protected services

Fabio Natali, 9 November 2026

Intro

I've published a few self-hosting tutorials and repositories over the past couple of years, covering services such as Nextcloud, Mattermost, Prosody, GoToSocial, and Kimai. Some are for personal use, others I use at work. Some, by their very nature, need to be accessible from the public internet; others (e.g. our Nextcloud, Mattermost, and Kimai instances) are only meant for me and my colleagues.

I've always thought that the company's internal services should be behind a VPN. Two recent PHP vulnerabilities, CVE-2026-109618 and CVE-2026-109619, commonly referred to as PHP2Shell, gave me the final nudge. A VPN doesn't fix vulnerable software, but it reduces the number of people who can exploit the vulnerabilities in the first place.

This post recaps what I found while researching VPN tools and topologies, and the simple setup I eventually chose. Caveat: there are a million ways to set up a VPN. This one fits my use case; it won't necessarily fit yours!

Objective

The objective is to limit access to services that don't need to be publicly exposed. Connections to these services already use HTTPS, so the extra thing I want from the VPN is access control: only enrolled devices belonging to authorised users should be able to reach them.

Application logins, TLS, and software updates are still needed. The VPN adds another layer in front of the applications, it doesn't replace the existing protections.

VPN tools and services

Being a happy WireGuard user already, I limited my research to the following WireGuard-based options:

  • Vanilla WireGuard: small, straightforward, and entirely under our control. I'd need to handle access management (create, distribute, and revoke keys to access the VPN) and routing.
  • WireGuard Portal: a self-hosted web interface for managing WireGuard interfaces and peers. It makes configuration and user management more convenient, although probably not as convenient as with Tailscale or NetBird.
  • Tailscale: a managed WireGuard-based VPN that gives you, among other things, device enrolment, access policies, and NAT traversal. Tailscale's core client code is open source, including the daemon and command-line interface. Its Linux and Android clients are fully open source, while some other graphical clients and its hosted control server are proprietary. At least one open source Tailscale-compatible control server does exist though, called Headscale.
  • NetBird: another managed overlay network, similar to Tailscale. The whole NetBird stack (clients and control server) is open source and self-hostable.

When evaluating these options and thinking of a good VPN topology, things started becoming complicated pretty quickly…

VPN topology

I first considered two ways of connecting all the machines: a mesh and a hub-and-spoke network.

A mesh

In a mesh, peers can communicate directly, subject to the access policies. Here's a small example with two user devices and two application servers, each on its own VPS. The lines show direct VPN tunnels between user devices and application servers.

[Phone]  ---+           +--- [Mattermost]
            +--- VPN ---+
[Laptop] ---+           +--- [Nextcloud]

Hub and spoke

In a hub-and-spoke network, traffic between the spokes passes through a central gateway. In this version, both the user devices and the application servers participate in the VPN.

[Phone]  -- VPN --+           +-- VPN -- [Mattermost]
                  |           |
                  +-[Gateway]-+
                  |           |
[Laptop] -- VPN --+           +-- VPN -- [Nextcloud]

Both designs, as drawn here, involve installing VPN software on every application server as well as on user devices. That started a little train of thought…

  • Packaging: the VPN software needs to be available for all my servers' operating systems.
  • DNS: I'd like to keep using the services' current domain names. If those names need to resolve to different addresses inside and outside the VPN, I'll need split DNS, or a similar mechanism to steer requests to the VPN addresses.
  • Certificates: the servers still need to pass ACME challenges. I could retain a public HTTP endpoint for HTTP-01 or switch to the DNS-01 challenge, but I'm not particularly keen on the extra work needed to set that up.
  • Account management: generating and distributing WireGuard keys manually is fine for a few devices, but less appealing when colleagues need to join, replace devices, or leave.

None of these problems is a blocker in itself, but the complexity does add up in a way that I wasn't imagining at the beginning. Some of the tools above solve much of this already, but what I'd imagined as a Saturday-afternoon project was getting out of hand!

A gateway, with the services outside the VPN

Then I had a realisation: for the access control I wanted, the application servers didn't need to be part of the VPN at all. I could route requests through one gateway and have each application server accept HTTPS connections only from that gateway's public addresses.

[Phone]  -- VPN --+                        +-- HTTPS -- [Mattermost]
                  |                        |
                  +-[Gateway + source NAT]-+
                  |                        |
[Laptop] -- VPN --+                        +-- HTTPS -- [Nextcloud]

The gateway and user devices run NetBird (or WireGuard or Tailscale). The application servers don't. The WireGuard tunnel ends at the gateway, while HTTPS remains encrypted between the user device and the application server. The gateway simply forwards the packets.

The crucial ingredient is source NAT. When forwarding a request, the gateway replaces the device's VPN source address with its own public egress address. The application server's firewall recognises that address and lets the connection through. Replies return to the gateway, which reverses the translation and sends them back through the tunnel.

The services still have public IP addresses, but their HTTPS ports reject connections from everyone else. That's what "behind a VPN" means in this setup. The firewall trusts the gateway; NetBird's policies decide which users and devices may send traffic through it.

This simplifies a few things:

  • Existing DNS records stay unchanged: the same public addresses are used inside and outside the VPN.
  • Let's Encrypt can keep using HTTP-01, provided the web server serves the challenge directly over public TCP port 80 and doesn't expose the application there.
  • No VPN software is needed on the application servers.

There are tradeoffs:

  • The gateway becomes a shared dependency for availability and bandwidth, and its public egress addresses need to stay stable.
  • The application servers see the gateway's address rather than individual devices' addresses. This might be inconvenient for logging and auditing purposes.
  • A large part of this setup's security is offloaded onto the firewalls of the individual application servers. They need to be configured properly to only allow traffic from the gateway.

I'm not claiming this is an original design or a particularly creative approach, at all. It's probably relatively standard. I was just relieved when I realised there was a simple way of implementing this, keeping my existing DNS and certificate setup and without installing VPN software on every application server.

Implementation

I decided to give NetBird Cloud a try. It takes care of user management, device enrolment, and access policies, while we operate the gateway itself. The NetBird control plane could be self-hosted, but that's extra infrastructure and I prefer to rely on NetBird Cloud for the time being. Vanilla WireGuard would be an option too, it keeps the software stack smaller while adding a little complexity around user/device management. Tailscale would also support the same design. I like having the freedom to migrate to other solutions if I ever want to.

My proof-of-concept implementation has two parts, both available on GitLab:

The examples below follow those repositories. You'll need a machine with Guix, a Hetzner API token and a NetBird Cloud account. Provisioning Hetzner VPSes incurs the usual hosting costs.

The gateway

The gateway runs on a Hetzner VPS provisioned with guix deploy.

The package

For this setup, I use a small package definition based on copy-build-system. Like in my GoToSocial tutorial, this takes a shortcut: it installs an upstream pre-built binary instead of compiling from source.

This example pins NetBird 0.82.0 and its archive hash, and supports x86_64-linux only. The wrapper adds the runtime tools NetBird needs to PATH.

(define-module (packages)
  #:use-module (guix packages)
  #:use-module (guix download)
  #:use-module (guix gexp)
  #:use-module (guix build-system copy)
  #:use-module ((guix licenses) #:prefix license:)
  #:use-module (gnu packages admin)
  #:use-module (gnu packages linux)
  #:export (netbird))

(define-public netbird
  (package
    (name "netbird")
    (version "0.82.0")
    (source
     (origin
       (method url-fetch)
       (uri (string-append
             "https://github.com/netbirdio/netbird/releases/download/v"
             version "/netbird_" version "_linux_amd64.tar.gz"))
       (sha256
        (base32
         "02q0yni004nrpv9gda92mrsz2b1f00b32vviphbi7lh3wdfzcsdm"))))
    (build-system copy-build-system)
    (arguments
     (list
      #:install-plan
      #~'(("netbird" "bin/"))
      #:phases
      #~(modify-phases %standard-phases
          ;; Unpack the flat archive without entering a subdirectory.
          (replace 'unpack
            (lambda* (#:key source #:allow-other-keys)
              (invoke "tar" "--extract" "--gzip" "--file" source)))
          (add-after 'install 'wrap-runtime-tools
            (lambda _
              (wrap-program (string-append #$output "/bin/netbird")
                `("PATH" prefix
                  (,(string-append #$iproute "/sbin")
                   ,(string-append #$nftables "/sbin")
                   ,(string-append #$procps "/bin")
                   ,(string-append #$inetutils "/bin")))))))))
    (inputs (list iproute nftables procps inetutils))
    (supported-systems '("x86_64-linux"))
    (home-page "https://netbird.io/")
    (synopsis "WireGuard-based mesh VPN client and routing peer")
    (description
     "NetBird connects devices through WireGuard tunnels with managed peer
discovery, access policies and routing.  This package installs the upstream
statically linked Linux client binary; it does not run the management server
or a desktop interface.")
    (license license:bsd-3)))

The service

Next, a native Shepherd service starts the NetBird daemon and creates its state directory.

(define-module (services)
  #:use-module (gnu services)
  #:use-module (gnu services shepherd)
  #:use-module (guix gexp)
  #:use-module (packages)
  #:export (netbird-service-type))

;; -----------------------------------------------------------------------------
;; Native NetBird service

(define netbird-shepherd-service
  (shepherd-service
   (provision '(netbird))
   (requirement '(networking nftables-gateway))
   (documentation "NetBird Cloud routing peer; enrol after deployment.")
   (start
    #~(make-forkexec-constructor
       (list #$(file-append netbird "/bin/netbird")
             "service" "run" "--log-file" "console")
       #:log-file "/var/log/netbird.log"))
   (stop #~(make-kill-destructor))))

(define netbird-activation
  #~(begin
      (use-modules (guix build utils))
      (mkdir-p "/var/lib/netbird")
      (chmod "/var/lib/netbird" #o700)))

(define netbird-service-type
  (service-type
   (name 'netbird)
   (extensions
    (list
     (service-extension activation-service-type
                        (const netbird-activation))
     (service-extension shepherd-root-service-type
                        (const (list netbird-shepherd-service)))
     (service-extension profile-service-type
                        (const (list netbird)))))
   (default-value #f)
   (description "Run NetBird and initialise its state directory.")))

The nftables-gateway requirement refers to the firewall service in the repository's src/systems.scm; it isn't a built-in Guix service. Starting it before NetBird ensures the host firewall is in place before NetBird adds its own rules.

The activation code restricts /var/lib/netbird to root. NetBird keeps its runtime identity there; it must survive reboots and redeployments. Setup keys and runtime credentials must be kept outside the Guix store (which is world-readable).

Forwarding and firewall

The gateway repository contains the full system and machine definitions. In addition to the service above, it enables IPv4 and IPv6 forwarding persistently.

NetBird manages the routing policies and masquerading rules. The host firewall permits SSH and the configured WireGuard UDP port, 51820 in this example, while allowing outbound connections for NetBird's coordination and relay services. The gateway needs no HTTP server or TLS certificate for this role.

There's one detail to watch with nftables: don't flush the whole ruleset on a running NetBird gateway, as that would remove NetBird's dynamic rules too. The repository's firewall service updates only its own inet gateway table.

Deployment

To deploy, clone the repository and copy its sample configuration:

git clone https://gitlab.com/reckondigital/netbird.reckon.digital.git
cd netbird.reckon.digital
cp data.sample.json data.json

Edit data.json with your SSH and deployment values, then render and deploy the system. Set GUIX_HETZNER_API_TOKEN in your environment before running guix deploy.

make build
cd build
guix deploy --load-path=. --expression='(list (@ (machines) machine-netbird))'

For the first deployment, leave ipv6_address empty. Once Hetzner has assigned the VPS its addresses, set ipv6_address to the chosen address with its /64 prefix, check public_interface, and rebuild and deploy again from the repository root. The code configures IPv6 statically, including its default route. Record the gateway's actual public IPv4 and IPv6 egress addresses; these are what the application firewalls will allow.

The application servers

Each application server must accept HTTPS only from the gateway. Here's a complete nftables example for the standalone test server. The addresses are documentation placeholders: replace them with the gateway's actual public egress addresses.

flush ruleset

define gateway_ipv4 = 192.0.2.1
define gateway_ipv6 = 2001:db8::1

table inet firewall {
    chain inbound {
        type filter hook input priority 0; policy drop;
        iif lo accept
        ct state invalid drop
        ct state established,related accept
        ip protocol icmp accept
        meta l4proto ipv6-icmp accept
        tcp dport ssh accept

        # HTTP is public; the web server restricts what it serves.
        tcp dport http accept
        ip saddr $gateway_ipv4 tcp dport https accept
        ip6 saddr $gateway_ipv6 tcp dport https accept
        tcp dport https reject with tcp reset
    }
    chain forward {
        type filter hook forward priority 0; policy drop;
    }
}

Public HTTP, private application

Opening port 80 is only half the ACME configuration. A firewall can't distinguish http://example.com/.well-known/acme-challenge/... from other HTTP paths; the web server must do that.

In the test server implementation, Guix's Certbot integration serves the ACME webroot under /.well-known/ and redirects other HTTP requests to HTTPS. Nginx serves the application on HTTPS only, with an additional allowlist for the gateway addresses. Public users can receive a redirect, but the firewall rejects the resulting HTTPS connection.

The HTTP-01 challenge itself must be served directly on port 80. Don't redirect it to the restricted HTTPS listener, and don't send public HTTP requests to PHP or another application backend. Otherwise, certificate renewal will fail, or the application will remain exposed over HTTP.

To try the test server, clone its repository and edit the sample configuration:

git clone https://gitlab.com/reckondigital/netbird-test.reckon.digital.git
cd netbird-test.reckon.digital
cp data.sample.json data.json

Set domain_name, certbot_email, gateway_ipv4, and gateway_ipv6, alongside the SSH and deployment values. Build and deploy it with:

make build
cd build
guix deploy \
    --load-path=. \
    --expression='(list (@ (machines) machine-netbird-test))'

Configure the server's static IPv6 address in the same two-stage way as the gateway, and point the domain's A and AAAA records at the test server, not at the gateway. Guix initially provides a self-signed certificate for bootstrapping. Once DNS is ready, run this as root on the test server to request the trusted certificate:

herd start renew-certbot-certificates

Subsequent renewals are scheduled automatically. The application server doesn't need a NetBird client at any point.

NetBird enrolment and access policies

Once deployed, the gateway needs to join the NetBird account. Create a one-off setup key with a short expiry, leaving the ephemeral-peer option disabled: this is a persistent gateway, not a disposable client.

On the gateway, as root, save the key in /root/netbird-setup-key with mode 0600, then enrol the machine:

netbird up \
    --setup-key-file /root/netbird-setup-key \
    --disable-dns \
    --disable-client-routes

After successful enrolment, remove the temporary key file and inspect the connection:

rm --force /root/netbird-setup-key
netbird status --detail

The flags preserve the gateway's existing DNS configuration and prevent it from consuming routes advertised by other routing peers. They don't stop it acting as a routing peer itself. User devices need client routes enabled, so don't copy --disable-client-routes to those devices.

In NetBird Cloud's Networks section:

  • Create a Network for the protected services and select the enrolled gateway as its routing peer, assigning a routing-peer group as required.
  • Add each application server as a resource using its IP address or domain name. Give the resources a group, such as internal-services.
  • Keep masquerading enabled. Without source NAT, the application firewalls won't see the gateway's public source address.
  • Add a policy from the group containing the authorised users' devices to the resource group, allowing TCP ports 80 and 443.

Use individual server addresses or domain names rather than a default route such as 0.0.0.0/0. Traffic to those resources goes through the gateway, while other internet traffic takes its usual path. Existing public DNS records can stay unchanged.

Finally, invite users and have them enrol their devices. Check group membership and remove any broad policies you don't intend to keep. A policy permitting access to a resource through the gateway is separate from one permitting access to the gateway itself.

Testing

With the VPN connected, verify that the application is reachable over HTTPS with a valid certificate. Disconnect the VPN and check that fresh HTTPS connections fail. Test IPv4 and IPv6 separately, where configured. Finally, confirm that HTTP-01 certificate renewal still works with the firewall restrictions in place.

Outro

What started as "I'll just put these services behind a VPN" turned into quite a bit of research. The aha! moment was realising that I don't need my application servers to be in the private network. For my use case, one routing gateway and a few firewall rules are enough to keep the application endpoints out of public reach.

There are new moving parts to take care of: the gateway, VPN users and devices, additional firewall rules. But I get to keep the existing domain names and certificates, and I don't need to install another agent on every server. That's a tradeoff I'm happy with.

As always, this is mostly a note-to-self, but I hope someone else finds it useful too. If you spot anything wrong or have taken a different approach to the same problem, do let me know. Until next!

Revision 3de5ae5.