• Installing WireGuard on Debian 13 with Docker (The Manual Way)

    Why I Built It This Way (And Why You Might Want To)

    There are dozens of tutorials showing how to install WireGuard in less than five minutes.

    Most of them look something like this:

    Install Docker.

    Run a container.

    Scan a QR code.

    Done.

    And honestly…there’s nothing wrong with that.

    Projects like wg-easy have made deploying WireGuard incredibly simple, and for many home users that’s exactly the right solution.

    But I wasn’t looking for the fastest deployment.

    Like all my projects I go through, I wanted to understand how everything actually worked so I could replicate at whim.

    I wanted to know where the keys were stored, how peers authenticated each other, how packets travelled from my phone to my home network, why IP forwarding was necessary, what the firewall was really doing, and how Docker fit into the picture.

    In other words, I wanted to build it and understand itβ€”not just run it.

    That decision turned what should have been a one-hour installation into a weekend project full of experimentation, troubleshooting, and learning. Looking back, I wouldn’t change it.

    I now understand WireGuard far better than if I had simply copied a docker-compose.yml file from GitHub and hoped for the best.

    If you’re studying for networking certifications, building a homelab, working in IT, or simply enjoy understanding the technology you’re using, I think you’ll get much more out of the manual approach.


    What This Guide Covers

    By the end of this guide you’ll have:

    • A minimal Debian 13 server
    • Docker installed and configured
    • A manually configured WireGuard server
    • Secure VPN access from your phone or laptop
    • Access to your home LAN while away
    • The option to route all your Internet traffic through your home connection
    • A much better understanding of how WireGuard actually works

    Along the way we’ll also look at the networking concepts that many tutorials completely skip:

    • Public and private keys
    • Routing
    • NAT
    • IP forwarding
    • Docker host networking
    • Firewall rules
    • Port forwarding
    • Split tunnels versus full tunnels
    • Common troubleshooting techniques

    This isn’t just about getting a VPN working.

    It’s about understanding why it works.


    Why WireGuard?

    If you’ve worked in IT for any length of time, you’ve almost certainly encountered VPN technologies like:

    • OpenVPN
    • IPSec
    • L2TP
    • PPTP (hopefully only in legacy environments!)
    • SSL VPNs from vendors like Fortinet, Sophos or Palo Alto

    Most of them work well.

    Some have been around for decades.

    But WireGuard takes a very different approach.

    Instead of supporting dozens of encryption algorithms, configuration options and legacy compatibility modes, WireGuard keeps things intentionally small.

    Very small.

    The entire WireGuard codebase is only a fraction of the size of OpenVPN or IPSec implementations, making it significantly easier to audit and maintain.

    The result is a VPN that is:

    • Fast
    • Lightweight
    • Secure by default
    • Easy to configure once you understand the basics
    • Available on virtually every modern operating system

    It’s no surprise that many organisations are beginning to adopt WireGuard for remote access, site-to-site tunnels, cloud connectivity and even internal infrastructure.

    For a homelab, it’s hard to beat.


    Why Docker?

    Another question you might be asking is:

    “Why install WireGuard inside Docker instead of directly on Debian?”

    That’s a fair question.

    There’s absolutely nothing wrong with installing WireGuard directly using your package manager.

    In fact, if this were a dedicated VPN appliance, that’s probably the route I’d take.

    However, I wanted this machine to become more than just a VPN server.

    It would eventually host additional services such as:

    • AdGuard Home
    • Nginx Proxy Manager
    • Monitoring tools
    • Utility containers
    • Future self-hosted applications

    Docker makes that incredibly easy.

    Each application lives inside its own isolated container.

    Updating one service doesn’t affect another.

    Backing everything up becomes much simpler (see my post on How to Back Up a Docker Container Manually on Debian to rebuild in Minutes (Disaster Recovery Guide)).

    Rebuilding the server is largely a matter of restoring a few folders and starting the containers again.

    For homelabs, Docker has become one of those technologies that’s simply too useful to ignore.


    💡 Why this matters

    One of the biggest misconceptions about Docker is that it somehow replaces Linux networking.

    It doesn’t.

    Docker still relies on Linux networking underneath.

    That means concepts like routing, firewall rules, NAT and IP forwarding are still just as important.

    In fact, understanding them becomes even more valuable.

    As you’ll see later in this guide, several of the issues I encountered had nothing to do with WireGuard itselfβ€”they were Linux networking problems that happened to affect WireGuard.

    Understanding the layers involved makes troubleshooting dramatically easier.


    Why I Didn’t Use wg-easy

    This will probably be the most controversial decision in this article.

    There is an excellent Docker project called wg-easy.

    It provides:

    • A web interface
    • Automatic key generation
    • QR codes for phones
    • Peer management
    • Configuration downloads
    • Client statistics

    It’s fantastic.

    If your goal is simply to get a VPN working as quickly as possible, I’d happily recommend it.

    So why didn’t I use it?

    Because it hides all the interesting parts.

    When something goes wrong, I don’t want to wonder what the web interface generated.

    I want to open the configuration file and immediately understand what every single line is doing.

    I wanted to manually:

    • Generate every key
    • Write every configuration file
    • Build the Docker Compose file
    • Configure routing
    • Configure NAT
    • Configure firewall rules
    • Understand every command I was running

    That knowledge is worth far more than the few hours saved by using a management interface.

    Ironically, after completing the manual installation, I now understand exactly what tools like wg-easy are doing behind the scenes.


    ⚠️ Mistake I Made

    When you’re learning something new, it’s tempting to keep replacing components every time you hit a problem.

    Maybe it’s Docker.

    Maybe it’s Debian.

    Maybe it’s WireGuard.

    Maybe it’s the firewall.

    Maybe you should reinstall everything…

    Resist that temptation.

    Throughout this build I forced myself to solve each issue instead of starting over.

    That approach taught me far more than a clean installation ever would have.

    Many of the troubleshooting sections later in this guide exist because I deliberately followed problems to their root cause instead of wiping the machine and trying again.

    Those lessons have already proven useful in completely unrelated projects.


    What We’re Building

    At a high level, the design is surprisingly simple.

    Phone / Laptop
            β”‚
            β”‚ WireGuard VPN Tunnel
            β–Ό
    Internet
            β”‚
    ISP Router
            β”‚
    Firewall
            β”‚
    Debian 13
    Docker
    WireGuard Container
            β”‚
    Home Network

    When you’re away from home, your phone establishes an encrypted tunnel back to the WireGuard server.

    Once connected, it behaves almost as though it were physically plugged into your home network.

    Depending on how you configure the client, you can either:

    • Access only your home devices (split tunnel), or
    • Send all Internet traffic through your home connection (full tunnel).

    We’ll look at both options later.


    Before We Begin

    This guide assumes you’re comfortable with Linux and the command line.

    You don’t need to be an expert, but you should already know how to:

    • SSH into a server
    • Edit configuration files
    • Restart services
    • Read terminal output
    • Work with Docker Compose

    I’ll explain why we’re doing each step, but I won’t spend much time covering basic Linux administration.

    If you’re new to Docker or Debian, don’t worryβ€”you’ll still be able to follow along, and hopefully you’ll come away with a much better understanding of both.


    Coming Up Next

    Now that we’ve covered the reasoning behind the project, it’s time to start building.

    In the next part we’ll prepare Debian for its new role by:

    • Installing the required packages
    • Creating a proper administrative user
    • Configuring a static IP address
    • Installing Docker
    • Verifying everything is ready before we touch WireGuard itself

    As with the rest of this guide, we’ll also explain why each step mattersβ€”not just which commands to copy and paste.


    Preparing Debian

    With the planning out of the way, it’s time to start building.

    One thing I’ve learned over the years is that taking an extra ten minutes to prepare a server properly almost always saves hours of troubleshooting later.

    Rather than jumping straight into installing WireGuard, we’ll first make sure the operating system is configured correctly. Think of this as laying the foundations before building the house.

    In this section we’ll:

    • Create a proper administrative user
    • Install the packages we’ll need later
    • Configure a static IP address
    • Install Docker
    • Verify everything is working before adding WireGuard

    None of these steps are particularly difficult, but each one serves a purpose.


    Starting with a Minimal Debian Installation

    For this guide I’m using a fresh installation of Debian 13 (Trixie).

    A minimal installation is ideal because it contains very little beyond the operating system itself. There are fewer running services, fewer packages to update, and a much smaller attack surface.

    If you’re building a dedicated server, that’s exactly what you want.

    Once the installation is complete, connect to the machine either directly or via SSH using the account created during installation.

    Before doing anything else, it’s always worth updating the package lists and installing any available updates.

    apt update
    apt upgrade -y

    Keeping a system fully updated before adding software avoids chasing problems that have already been fixed upstream.


    Creating a Proper Administrative User

    If you’re logged in as root, now is a good time to stop using it.

    Although it’s tempting to perform everything as the root user, it’s considered best practice to use a normal account with sudo privileges instead.

    This provides an additional layer of protection against accidental mistakes and makes it obvious which commands require elevated privileges.

    If sudo isn’t already installed, install it first.

    apt install sudo

    Then add your existing user to the sudo group.

    usermod -aG sudo <username>

    Log out and back in again so the new permissions take effect.

    Finally, confirm everything is working correctly.

    sudo whoami

    If everything has been configured correctly, the command should return:

    root

    From this point onwards, I’ll assume commands requiring elevated privileges are prefixed with sudo.


    💡 Why this matters

    Running everything as root works… until it doesn’t.

    Accidentally deleting a directory or overwriting a configuration file as root can have immediate consequences.

    Using sudo adds a small safety net and aligns your server with standard Linux administration practices.


    Assigning a Static IP Address

    A VPN server should always have a predictable address on your network.

    If your router assigns a different IP address after a reboot, port forwarding, firewall rules and monitoring systems may all stop working.

    There are two common ways to achieve this:

    • Reserve a DHCP lease on your router or firewall.
    • Configure a static address directly on the server.

    Both approaches are perfectly valid.

    Personally, I prefer assigning important infrastructure devices fixed addresses so I always know where they live on the network.

    Before editing anything, identify the name of your network interface.

    ip addr
    or
    ip a

    On modern Debian systems you’ll usually see names similar to:

    • enp1s0
    • ens18
    • eno1

    rather than the old eth0 naming convention.

    Edit your network configuration using whichever networking method your installation uses.

    Assign:

    • A static IP address
    • The correct subnet mask
    • Your default gateway
    • At least one DNS server

    After applying the changes, verify that the new address has been assigned correctly.

    ip addr show <interface>

    Then test connectivity.

    ping 1.1.1.1

    and

    ping google.com

    The first confirms basic network connectivity.

    The second confirms DNS resolution is also working.

    Both tests are important.


    ⚠️ Mistake I Made

    At one point I assumed the network was broken because external websites couldn’t be reached.

    The server could successfully ping public IP addresses, but hostnames failed to resolve.

    The culprit wasn’t routing at allβ€”it was DNS.

    Always test both IP connectivity and DNS separately before assuming you have a networking problem.


    Installing Docker

    Although WireGuard can be installed directly on Debian, we’ll be running it inside a Docker container.

    Installing Docker from Docker’s own repositories ensures you’re using the latest supported version rather than an older package from the Debian repositories.

    First install the required dependencies.

    sudo apt install ca-certificates curl gnupg

    Then add Docker’s official repository and install Docker Engine along with Docker Compose.

    Follow the official Docker installation instructions for Debian, as these are kept up to date whenever packages or signing keys change.

    Once installation completes, verify everything is working.

    docker --version

    You should also confirm Docker Compose is available.

    docker compose version

    Both commands should return version information without errors.


    Allowing Your User to Run Docker

    By default, Docker commands require root privileges.

    Rather than prefixing every command with sudo, it’s common practice to add your account to the Docker group.

    sudo usermod -aG docker <username>

    Log out and back in again.

    Now try:

    docker ps

    If no permission errors appear, everything is configured correctly.


    💡 Why this matters

    Docker commands executed with sudo work perfectly well.

    Adding your user to the Docker group is largely a convenience.

    However, it’s worth remembering that Docker group membership effectively grants root-equivalent privileges on the machine.

    Only trusted administrators should belong to this group.


    Verifying the Environment

    Before installing WireGuard, spend a few minutes confirming the basics.

    Can the server:

    • Reach the Internet?
    • Resolve DNS names?
    • Pull Docker images?
    • Retain its static IP after a reboot?
    • Run containers successfully?

    A few simple checks now can save significant troubleshooting later.

    Try pulling a small image.

    docker run hello-world

    If Docker downloads the image and prints its success message, you’ve confirmed:

    • Internet connectivity
    • DNS resolution
    • Docker Engine
    • Container execution

    That’s a surprisingly useful health check.


    Building Good Habits Early

    It’s easy to get excited about deploying new services and skip straight to the interesting part.

    I’ve done it myself more times than I’d like to admit.

    But infrastructure has a habit of punishing shortcuts.

    Taking a little extra time to confirm the operating system is healthy before adding applications almost always pays dividends later.

    When something eventually goes wrongβ€”and at some point, something always doesβ€”you’ll already know the underlying operating system wasn’t the problem.

    That narrows your troubleshooting considerably.


    Up Next

    Now that Debian is configured and Docker is running, we can finally turn our attention to WireGuard itself.

    In the next section we’ll generate our cryptographic keys, build the project directory structure, write the WireGuard configuration manually, and look at what every line in wg0.conf actually does before we ever start the container.

    Building the WireGuard Server

    With Debian prepared and Docker up and running, it’s finally time to build the VPN server itself.

    Unlike many tutorials, we’re not going to deploy a container that automatically generates configuration files behind the scenes.

    Instead, we’ll build everything manually.

    That means generating our own key pairs, creating the configuration file from scratch, understanding every option inside it, and finally letting Docker do what Docker does bestβ€”run the application.

    It might take a little longer, but by the end of this section you’ll understand why WireGuard works, rather than simply knowing that it does.


    Understanding How WireGuard Authenticates Devices

    One of the first things you’ll notice about WireGuard is that there are no usernames or passwords.

    Instead, every device is identified using a pair of cryptographic keys.

    Each peer has:

    • A private key, which must remain secret.
    • A public key, which can safely be shared with other peers.

    If you’ve ever used SSH key authentication, the concept is almost identical.

    The private key proves your identity.

    The public key tells the server who is allowed to connect.

    No passwords are exchanged.

    No certificates are required.

    No certificate authority needs to be maintained.

    Each device simply trusts the public keys you’ve explicitly configured.

    It’s an elegant design, and one of the reasons WireGuard is so lightweight.


    💡 Why this matters

    A common misconception is that WireGuard authenticates users.

    It doesn’t.

    It authenticates devices.

    If you connect from your phone and your laptop, those are two separate peers, each with its own unique key pair.

    Never reuse keys between devices.

    If one device is ever lost or compromised, you can revoke that single peer without affecting any of the others.


    Creating the Project Structure

    Although Docker doesn’t care where you store your project files, keeping everything organised makes administration much easier.

    A simple directory structure might look something like this:

    wireguard/
    β”œβ”€β”€ docker-compose.yml
    └── config/
        └── wg_confs/
            └── wg0.conf

    As the project grows, you’ll also have directories containing keys, backups and any supporting configuration.

    Keeping everything together makes moving the project to another server as simple as copying one folder.


    Generating the Server Keys

    WireGuard includes a small utility that generates cryptographic keys.

    Creating a key pair takes only a few seconds.

    Generate the server’s private key.

    wg genkey | tee server_private.key

    Then derive the matching public key.

    cat server_private.key | wg pubkey > server_public.key

    Repeat exactly the same process for your first client device.

    For example:

    wg genkey | tee phone_private.key
    cat phone_private.key | wg pubkey > phone_public.key

    You should now have four files:

    • Server private key
    • Server public key
    • Client private key
    • Client public key

    Each has a specific purpose.

    The server keeps its private key secret.

    The client keeps its private key secret.

    The server stores the client’s public key.

    The client stores the server’s public key.

    That’s the entire trust relationship.


    Keeping Your Private Keys Safe

    It should go without saying, but your private keys should remain exactly thatβ€”private.

    Never:

    • Email them
    • Upload them to GitHub
    • Store them in public repositories
    • Paste them into forums
    • Share screenshots containing them

    Anyone with your private key effectively becomes that device.

    Treat it the same way you would treat an SSH private key or a password manager database.

    Public keys, on the other hand, are designed to be shared.

    They’re useless on their own.


    Writing the Configuration Manually

    This is where WireGuard starts to make sense.

    Open a new configuration file.

    nano wg0.conf

    A basic configuration consists of two sections:

    • [Interface]
    • [Peer]

    The Interface section describes the WireGuard server itself.

    The Peer section describes another trusted device.

    A minimal configuration looks something like this:

    [Interface]
    Address = 10.100.100.1/24
    ListenPort = 51820
    PrivateKey = <server private key>
    
    [Peer]
    PublicKey = <client public key>
    AllowedIPs = 10.100.100.2/32

    At first glance it doesn’t look like much.

    That’s one of WireGuard’s greatest strengths.

    Compared to many VPN technologies, the configuration is remarkably small.

    The trick is understanding what each line actually means.


    Breaking Down the Interface Section

    Let’s look at each option individually.

    Address

    Address = 10.100.100.1/24

    This is not your server’s LAN address.

    Instead, it’s the IP address WireGuard assigns to its virtual VPN interface.

    Think of it as creating an entirely new network that exists solely for VPN traffic.

    Your physical network might be:

    192.168.1.0/24

    while your VPN network becomes:

    10.100.100.0/24

    Keeping these separate avoids routing conflicts and makes troubleshooting much easier.


    ListenPort

    ListenPort = 51820

    This tells WireGuard which UDP port to listen on.

    Port 51820 has become the de facto standard for WireGuard deployments, although you’re free to use another port if required.

    Remember that your firewall and router must also allow traffic to whichever port you choose.


    PrivateKey

    PrivateKey = ...

    This is the server’s identity.

    It should never leave the server.

    If this key is compromised, generate a new one and redistribute the corresponding public key to every client.


    Understanding the Peer Section

    Every device connecting to your VPN gets its own Peer block.

    Initially, you’ll only have one.

    Later you might have:

    • Phone
    • Laptop
    • Tablet
    • Work PC
    • Another server

    Each receives its own unique configuration.


    PublicKey

    The public key identifies the remote device.

    Unlike the private key, this value is intended to be shared.

    The server uses it to verify that the connecting device is trusted.


    AllowedIPs

    This single option causes more confusion than almost anything else in WireGuard.

    At first glance it appears to be a firewall rule.

    It isn’t.

    Nor is it an access control list.

    Instead, it tells WireGuard which IP addresses belong to that peer.

    For a single phone you might use:

    AllowedIPs = 10.100.100.2/32

    That simply means:

    “Packets destined for 10.100.100.2 should be sent to this peer.”

    Nothing more.

    Nothing less.


    🔍 Under the Hood

    One of the clever aspects of WireGuard is that AllowedIPs serves two purposes simultaneously.

    It acts as both:

    • A routing table
    • A peer identification mechanism

    When a packet needs to reach a destination, WireGuard checks which peer “owns” that address and automatically encrypts the traffic for that device.

    That’s why overlapping AllowedIPs between peers is generally a bad ideaβ€”it creates ambiguity about where packets should be sent.


    Routing Traffic Beyond the Tunnel

    At this point, the server and client could establish an encrypted tunnel.

    But there would still be a problem.

    The VPN network would exist in isolation.

    Your phone could reach the WireGuard server itself…

    …but nothing beyond it.

    If you wanted to access your NAS, your printer, another server, or even browse the Internet through your home connection, Linux needs to forward packets between networks.

    That’s where IP forwarding and Network Address Translation (NAT) enter the picture.

    WireGuard itself doesn’t magically route packets.

    Linux does.

    WireGuard simply encrypts them.

    We’ll configure routing and NAT shortly, but it’s important to understand that they’re separate technologies working together.


    ⚠️ Mistake I Made

    One of the most misleading moments during my build happened when everything appeared to be working.

    The VPN connected.

    The handshake completed successfully.

    I could even ping the WireGuard server’s VPN address.

    I assumed the installation was finished.

    It wasn’t.

    Nothing on my LAN was reachable.

    Internet traffic didn’t work.

    The VPN tunnel itself was healthyβ€”the operating system simply wasn’t forwarding packets between the VPN network and the physical network.

    That distinction is incredibly important.

    A successful handshake only proves that two devices can establish an encrypted tunnel.

    It says nothing about whether traffic can actually travel anywhere afterwards.


    Up Next

    Now that we have a working WireGuard configuration, it’s time to containerise it.

    In the next section we’ll build the Docker Compose file, explain every directive it contains, discuss why network_mode: host makes sense for WireGuard, and finally launch the VPN server for the first time.

    Running WireGuard in Docker

    At this point we have everything needed to create a WireGuard server.

    We have:

    • A prepared Debian installation
    • Docker running correctly
    • Our cryptographic keys
    • A manually written WireGuard configuration

    The only thing left is to bring it all together.

    Although running WireGuard natively is perfectly valid, I wanted the VPN server to become just one part of a larger self-hosted environment. Docker makes that easy by isolating applications from one another while keeping deployments reproducible and easy to back up.

    Let’s build the container.


    Why Docker Makes Sense Here

    Docker isn’t just about making software easier to install.

    For infrastructure services like WireGuard it also provides several operational advantages.

    • Configuration is stored in one location.
    • Upgrades are usually as simple as pulling a newer image.
    • Backups become much easier. (again, how to back up docker containers)
    • Rebuilding the server takes minutes instead of hours.
    • Additional services can coexist without conflicting with one another.

    As my homelab grows, I know this server will eventually host several other services.

    Having each one live inside its own container keeps everything organised.


    💡 Why this matters

    Docker containers are not virtual machines.

    They all share the host’s Linux kernel.

    That means networking, routing, firewall rules and kernel modules still belong to Linux itself.

    WireGuard doesn’t stop needing Linux networking simply because it’s running inside a container.

    Understanding that distinction makes troubleshooting far easier later.


    Building the Docker Compose File

    Docker Compose allows us to describe an application in a simple YAML file.

    Instead of typing a long docker run command every time, we define everything once.

    Create a new file called:

    docker-compose.yml

    A basic configuration looks similar to this:

    services:
      wireguard:
        image: lscr.io/linuxserver/wireguard:latest
        container_name: wireguard
    
        cap_add:
          - NET_ADMIN
          - SYS_MODULE
    
        environment:
          - PUID=1000
          - PGID=1000
          - TZ=UTC
    
        volumes:
          - ./config:/config
          - /lib/modules:/lib/modules
    
        network_mode: host
    
        restart: unless-stopped

    If you’ve never looked closely at a Compose file before, some of those options probably seem a little mysterious.

    Let’s break them down.


    Choosing the Docker Image

    image: lscr.io/linuxserver/wireguard:latest

    The LinuxServer.io images are well maintained, thoroughly documented and widely used throughout the self-hosting community.

    Could you build your own image?

    Absolutely.

    For most deployments, though, there’s little reason to reinvent the wheel.

    Using a reputable image maintained by an active community strikes a good balance between convenience and transparency.


    Container Name

    container_name: wireguard

    This simply gives the container a friendly name.

    Without it, Docker generates random names that are much harder to remember.

    It makes administration a little cleaner.


    Linux Capabilities

    One of the most important sections is:

    cap_add:
      - NET_ADMIN
      - SYS_MODULE

    Containers normally operate with very limited permissions.

    That’s usually a good thing.

    WireGuard, however, needs permission to:

    • Create network interfaces
    • Manipulate routing tables
    • Configure firewall rules
    • Load kernel modules if required

    Those capabilities allow the container to perform exactly those tasks.

    Nothing more.


    🔍 Under the Hood

    Linux doesn’t simply have two permission levelsβ€”root and non-root.

    It actually divides privileged operations into smaller pieces called capabilities.

    Rather than granting every possible privilege, Docker can grant only the capabilities an application genuinely needs.

    That’s considerably safer than giving a container unrestricted access to the host.


    User and Group IDs

    PUID=1000
    PGID=1000

    These values determine which Linux user owns files created inside the mounted configuration directory.

    Using your normal user’s UID and GID prevents annoying permission problems later when editing configuration files directly from the host.

    If your user doesn’t happen to use ID 1000, simply substitute the correct values.

    You can check them with:

    id

    Persisting Configuration

    Containers are designed to be disposable.

    If you delete one, everything inside it disappears.

    That’s why Docker volumes are so important.

    volumes:
      - ./config:/config

    This tells Docker to store WireGuard’s configuration outside the container.

    If the container is ever recreated, your configuration remains untouched.

    The second volume:

    /lib/modules:/lib/modules

    gives the container access to the host’s kernel modules, which WireGuard may require depending on your kernel and deployment.


    Host Networking

    This line often surprises people.

    network_mode: host

    Normally Docker creates its own private virtual networks.

    Containers communicate through Docker’s internal bridge, and Docker performs Network Address Translation behind the scenes.

    For many applications that’s ideal.

    WireGuard is different.

    WireGuard’s job is routing network traffic.

    Adding another layer of Docker NAT simply complicates things.

    Using host networking allows the container to interact directly with the host’s network stack.

    From WireGuard’s perspective, it’s almost as though it were installed natively.

    That means:

    • Fewer moving parts
    • Simpler routing
    • Simpler firewall rules
    • Better performance
    • Easier troubleshooting

    For a VPN server, it’s generally the right choice.


    ⚠️ Mistake I Made

    One of the first rabbit holes I disappeared into involved Docker sysctls.

    Initially I tried enabling certain kernel networking options directly from inside the container.

    Docker refused.

    It turns out that containers using host networking cannot modify many host-level kernel parameters.

    The fix wasn’t inside Docker at all.

    Those settings belong on the Linux host itself.

    It was an important reminder that Docker doesn’t replace the operating systemβ€”it sits on top of it.


    Restart Policies

    The final line is small but important.

    restart: unless-stopped

    This tells Docker to automatically restart the container if:

    • The server reboots
    • Docker restarts
    • The container crashes unexpectedly

    Unless you explicitly stop it yourself, WireGuard should always come back online automatically.

    For infrastructure services, that’s exactly what you want.


    Starting the Container

    Once everything has been saved, starting WireGuard is refreshingly simple.

    docker compose up -d

    Docker will:

    • Download the image if necessary.
    • Create the container.
    • Mount the configuration directory.
    • Start WireGuard.

    Within a few moments, your VPN server should be running.


    Checking the Logs

    Before celebrating, always check the logs.

    docker logs wireguard

    Ideally you’ll see WireGuard initialise successfully without errors.

    If something has gone wrong, the logs are almost always the best place to begin troubleshooting.

    Learning to read application logs is one of the most valuable Linux administration skills you can develop.

    They usually tell you exactly what happened.

    You simply need to know where to look.


    💡 Geek.Click Tip

    Avoid the temptation to keep deleting and recreating containers every time something doesn’t work.

    Containers are disposable.

    Your configuration isn’t.

    If WireGuard refuses to start, investigate why before reaching for docker compose down.

    Nine times out of ten, the logs will point you towards the actual problem far faster than repeatedly rebuilding the container.


    Up Next

    At this point, WireGuard is running.

    But if you try connecting a client now, you’ll quickly discover that a VPN tunnel alone isn’t enough.

    In the next section we’ll configure Linux networking itself by enabling IP forwarding, adding the required NAT rules, and understanding how packets move between the VPN network, your LAN and the Internet.

    This is where everything finally comes together.

    Configuring Linux Routing

    If you’ve followed along so far, your WireGuard container should now be running happily.

    Unfortunately, that doesn’t mean your VPN is ready to use.

    This is one of the biggest misconceptions people have when deploying VPNs.

    A VPN tunnel simply creates an encrypted connection between two devices.

    It doesn’t automatically allow traffic to move between different networks.

    That’s the operating system’s job.

    Linux needs to know that it’s allowed to forward packets arriving from the VPN interface to your LAN, and vice versa.

    Without that, your phone might successfully connect to the VPN but be completely unable to reach anything else.


    Understanding Packet Flow

    Before changing any configuration, it’s worth understanding exactly what happens when you connect.

    Imagine you’re away from home and want to access your NAS.

    The packet follows a path similar to this:

    Phone
       β”‚
    Encrypted WireGuard tunnel
       β”‚
    Internet
       β”‚
    Router / Firewall
       β”‚
    WireGuard Server
       β”‚
    Linux decrypts packet
       β”‚
    Linux routes packet
       β”‚
    NAS

    Notice something important.

    WireGuard’s job ends after decrypting the packet.

    Linux then decides where that packet should go next.

    If Linux isn’t configured to forward traffic between interfaces, the packet simply stops there.


    💡 Why this matters

    This distinction explains why many VPNs appear to “connect” successfully while nothing actually works.

    The encrypted tunnel exists.

    Authentication succeeded.

    The handshake completed.

    But routing never happens.

    When troubleshooting, always remember:

    Encryption and routing are two completely separate problems.


    Enabling IP Forwarding

    By default, Linux behaves like an endpoint.

    It receives traffic destined for itself and ignores everything else.

    Routers work differently.

    They receive packets destined for other networks and forward them accordingly.

    Since we’re turning our Debian server into a router between the VPN network and our LAN, Linux needs to be told to enable forwarding.

    Create a dedicated sysctl configuration file.

    echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-wireguard.conf

    Apply the change immediately.

    sudo sysctl --system

    Then verify it.

    sysctl net.ipv4.ip_forward

    The output should read:

    net.ipv4.ip_forward = 1

    If it doesn’t, don’t continue until you’ve identified why.


    🔍 Under the Hood

    Think of IP forwarding as enabling a routing engine inside Linux.

    Without it, Linux behaves like your laptop.

    With it enabled, Linux starts behaving like a router.

    WireGuard depends on this because VPN clients are connecting to an entirely different subnet.


    Network Address Translation (NAT)

    There’s still one more piece missing.

    Imagine your phone connects with the VPN address:

    10.100.100.2

    Your NAS lives on:

    192.168.1.50

    When the NAS replies, it has no idea where the 10.100.100.0/24 network is.

    It only knows about the local LAN.

    That’s where NAT comes in.

    Instead of forwarding packets with their original VPN address, Linux temporarily rewrites them so they appear to originate from the WireGuard server itself.

    To every device on your LAN, the traffic simply appears to come from your Debian server.

    Replies naturally find their way back, and Linux translates everything back before sending it through the VPN tunnel.

    The process is completely transparent.


    Understanding PostUp and PostDown

    Earlier we created a WireGuard configuration.

    You may have noticed two options we haven’t discussed yet.

    PostUp
    PostDown

    These commands execute automatically whenever the WireGuard interface starts or stops.

    They’re commonly used to:

    • Add firewall rules
    • Enable NAT
    • Configure routing
    • Remove those rules again when the interface shuts down

    It’s an elegant way of ensuring the operating system always matches the VPN’s current state.

    Rather than manually configuring firewall rules every time WireGuard starts, the interface does it for you.


    ⚠️ Mistake I Made

    One of the strangest issues I encountered came from something incredibly simple.

    I accidentally split a long PostUp command over multiple lines.

    WireGuard didn’t interpret it the way I expected.

    The interface refused to start correctly, and the resulting error messages weren’t especially helpful.

    The fix was simply keeping the command on a single line.

    Sometimes the smallest formatting mistake causes the biggest headaches.


    Firewall Configuration

    Your VPN server now knows how to route traffic.

    Unfortunately, your firewall probably doesn’t.

    If your server sits behind a firewall, you’ll need to allow incoming UDP traffic to your chosen WireGuard port.

    For most installations that’s:

    UDP 51820

    How you accomplish this depends entirely on your firewall.

    Whether you’re using:

    • OPNsense
    • pfSense
    • FortiGate
    • UniFi
    • MikroTik
    • OpenWrt
    • A consumer ISP router

    the principle remains exactly the same.

    Incoming UDP traffic must be forwarded to your WireGuard server.


    Double NAT

    Many home users unknowingly have two routers.

    For example:

    Internet
         β”‚
    ISP Router
         β”‚
    Your Firewall, or a WiFi Router
         β”‚
    WireGuard Server

    This is known as double NAT.

    In that situation, forwarding the port on only one device isn’t enough.

    The ISP router must forward the traffic to your firewall.

    The firewall must then forward it to the WireGuard server.

    Miss either step and the VPN will never receive the packets.

    This catches a surprising number of people because everything inside the network appears to be configured correctly.

    The traffic simply never arrives.


    💡 Geek.Click Tip

    When debugging port forwarding problems, always work backwards.

    Ask yourself:

    • Does the server see the packet?
    • Does the firewall see the packet?
    • Does the ISP router see the packet?

    Eventually you’ll discover where the traffic disappears.

    Networking problems are often solved by following the packet rather than guessing.


    Configuring the Client

    Creating the client configuration is straightforward.

    Each client receives:

    • Its own private key
    • The server’s public key
    • A VPN IP address
    • The server endpoint
    • An AllowedIPs definition

    One option deserves special attention.

    AllowedIPs

    On the client, this setting means something completely different than it did on the server.

    It tells the client which traffic should enter the VPN.

    For example:

    0.0.0.0/0

    means:

    Send absolutely everything through the VPN.

    Whereas:

    192.168.1.0/24

    means:

    Only send traffic destined for my home network through the tunnel.

    Everything else uses the phone’s normal Internet connection.


    Split Tunnel vs Full Tunnel

    Neither approach is universally better.

    It depends entirely on your goals.

    A split tunnel is ideal if you simply want to reach your home devices.

    Internet browsing continues using your local Wi-Fi or mobile connection.

    A full tunnel routes all traffic through your home network.

    This is useful when:

    • Using public Wi-Fi
    • Accessing geo-restricted services
    • Protecting traffic on untrusted networks
    • Appearing online from your home IP address

    Personally, I like having both configurations available depending on where I’m connecting from.


    Testing the Installation

    One of the biggest lessons from this project was learning not to jump straight to:

    “Can I browse the Internet?”

    Instead, test each layer individually.

    1. Does the WireGuard handshake complete?
    2. Can you reach the VPN interface?
    3. Can you reach another LAN device?
    4. Can you reach the Internet?
    5. Does your public IP appear correctly?

    Each successful step eliminates an entire category of possible problems.

    Good troubleshooting is really just a process of elimination.


    Lessons Learned

    Every project teaches you something.

    This one taught me quite a lot.

    Some of the biggest lessons were:

    • A successful handshake doesn’t mean routing works.
    • Docker doesn’t replace Linux networking.
    • IP forwarding is essential.
    • Understanding packet flow makes troubleshooting dramatically easier.
    • DNS issues often masquerade as networking problems.
    • Following packets is far more effective than randomly changing settings.
    • Reading logs usually saves more time than reinstalling software.

    Perhaps the biggest lesson of all was that spending time understanding why something works is never wasted.

    Even if I eventually decide to deploy a management interface like wg-easy, I’ll know exactly what it’s doing behind the scenes.

    That knowledge is far more valuable than memorising a list of commands.


    Where to Go From Here

    A working WireGuard server opens the door to a surprising number of possibilities.

    Some ideas for future projects include:

    • Adding multiple peers for family members or colleagues.
    • Using Dynamic DNS so changing public IP addresses are handled automatically.
    • Integrating AdGuard Home for network-wide DNS filtering, even while travelling.
    • Running WireGuard behind OPNsense or another dedicated firewall.
    • Creating site-to-site tunnels between multiple locations.
    • Connecting cloud servers securely back to your home network.
    • Building a complete self-hosted infrastructure around Docker.

    Many of these are projects I’ll be covering here on Geek.Click in future articles.


    Final Thoughts

    When I started this project, my goal was simply to build a VPN server.

    What I ended up building was a much deeper understanding of Linux networking.

    WireGuard itself turned out to be refreshingly simple.

    The real learning came from understanding everything around itβ€”routing, NAT, Docker networking, firewalls, packet flow and troubleshooting.

    That’s one of the reasons I deliberately chose the manual route instead of hiding everything behind a web interface.

    The deployment took longer.

    I made mistakes.

    I spent hours chasing problems that turned out to have surprisingly simple solutions.

    But those lessons have already carried over into other projects, and they’ve made me far more confident when working with networking in general.

    If you’re building a homelab or looking to deepen your understanding of modern VPNs, I encourage you to take the same approach.

    Don’t just aim to get it working.

    Take the time to understand why it works.

    Your future self will thank you

  • Create Consistent Content with AI: Turn Questions into Blog Posts

    Creating websites was never the hard part for me.

    The technical side was interesting. Learning SEO, building WordPress sites, setting up servers, experimenting with tools, and researching new ideas was exciting.

    But there was one problem I kept running into:

    I struggled to consistently create content.

    And without content, even the best website idea eventually becomes just another unfinished project.

    My Biggest Problem Wasn’t SEO

    When people talk about building niche websites, they usually focus on:

    • Finding profitable keywords
    • Writing articles
    • Building backlinks
    • Ranking on Google
    • Optimising pages for SEO

    I assumed those would be my biggest challenges.

    They weren’t.

    My biggest obstacle was much simpler:

    Creating content consistently.

    Why Traditional Content Creation Felt Difficult

    Writing a blog post from a blank page felt overwhelming.

    Recording podcast episodes felt unnatural.

    Taking photos for social media felt forced.

    Even replying to forum posts made me overthink every sentence.

    Every new project would start with excitement:

    “This is going to be great.”

    Then eventually the same thing happened.

    The ideas stayed. The motivation disappeared. The project stalled.

    The Realisation: I Wasn’t Running Out of Ideas

    After thinking about why I struggled, I noticed something important.

    I actually had no shortage of ideas.

    I was constantly curious.

    I was always asking questions.

    • Should I use subdomains or subdirectories?
    • How do 301 redirects actually work?
    • How do I find profitable niches?
    • How should I structure a homelab?
    • When should I use Nginx?
    • How do I properly secure WireGuard?

    I Naturally Learn Through Questions

    I realised something about myself:

    I don’t naturally sit down and think:

    “Today I am going to write a 2,500-word article.”

    That approach feels forced.

    Instead, I learn by asking questions.

    I explore a problem. I research solutions. I test ideas. I make mistakes. I improve.

    And then I realised:

    Maybe the conversations themselves are the content.

    Conversations Already Contain the Structure of a Blog Post

    A useful article usually contains:

    • A problem
    • A beginner’s perspective
    • Research and explanation
    • Common mistakes
    • Lessons learned
    • A final solution

    A good conversation already has those elements.

    Instead of staring at a blank document trying to create an article from nothing, I can start with genuine curiosity.

    I can ask questions, learn something valuable, and then transform that discussion into a useful resource for others.

    But Is AI Content Good Enough for SEO?

    This was my biggest concern.

    Would search engines reject content created with AI?

    The real question is:

    Does the content actually help people?

    There is a huge difference between:

    “Write me a generic article about VPNs.”

    and:

    “I am trying to secure my own WireGuard setup. Explain what I am doing wrong and help me understand the problem.”

    AI Isn’t the Author β€” It’s the Editor

    The way I see it now:

    I provide:

    • The curiosity
    • The questions
    • The goals
    • The decisions
    • The real-world experience

    AI helps with:

    • Structure
    • Clarity
    • Formatting
    • Editing
    • Readability

    AI is not replacing the thinking.

    It is helping communicate the thinking.

    The Best Content Comes From Real Problems

    The biggest advantage comes after actually doing something.

    Once I build, test, or experiment with something, I can add details that generic AI content cannot provide.

    • Mistakes I made
    • Solutions that failed
    • Commands that caused problems
    • Screenshots from my own setup
    • Performance results
    • Lessons learned

    These details transform an average article into something genuinely useful.

    My New Content Creation Workflow

    Instead of trying to become a professional writer, I am going to focus on something simpler:

    Learning in public.

    1. Ask genuine questions.
      Start with real problems I actually want to solve.
    2. Learn and experiment.
      Research solutions and test them.
    3. Turn the conversation into an article.
      Organise the discussion into something useful.
    4. Add personal experience.
      Include screenshots, mistakes, results, and lessons.
    5. Publish.
      Share the knowledge instead of keeping it hidden.

    Final Thoughts

    The thing stopping me from building websites was never SEO.

    It wasn’t keyword research.

    It wasn’t backlinks.

    It wasn’t even technical knowledge.

    The biggest obstacle was believing every article had to start as a perfectly written piece of content.

    It doesn’t.

    Sometimes the best content starts with a simple question.

    A question someone else might be searching for too.

    Every genuine problem I solve today could become the article that helps someone else tomorrow.

  • Setting Up Nginx Proxy Manager on a Proxmox and OPNsense Homelab

    Nginx Proxy Manager (NPM) is going to act as the reverse proxy for the websites in my homelab.

    The goal is to eventually have traffic flow like this:

    Internet
       ↓
    Cloudflare DNS
       ↓
    Public IP
       ↓
    OPNsense
       ↓
    Nginx Proxy Manager
       ↓
    Correct website/server
    

    This guide covers the initial network preparation, creation of the NPM container, installation of Docker, and deployment of Nginx Proxy Manager.

    The configuration is deliberately kept fairly simple at this stage. More advanced firewall restrictions, Cloudflare configuration, SSL certificates and website proxy hosts will be covered later.


    Homelab Network Design

    The lab is running on Proxmox with OPNsense providing routing, firewalling and VLAN management.

    Two VLANs are being used for the web-hosting environment.

    VLAN 10 β€” Management / Infrastructure

    Network: 192.168.10.0/24
    Gateway: 192.168.10.1
    

    This VLAN is intended for infrastructure and management services, including:

    • Nginx Proxy Manager
    • WireGuard
    • Monitoring
    • Other management services

    VLAN 20 β€” Websites

    Network: 192.168.20.0/24
    Gateway: 192.168.20.1
    

    This VLAN will contain the actual website workloads, including:

    • Manual LEMP installation
    • CloudPanel
    • Coolify
    • WordPress sites

    Keeping the reverse proxy and website workloads separated gives us a cleaner foundation for firewall rules and segmentation.


    Preparing OPNsense

    The Proxmox internal bridge is configured as a VLAN-aware bridge.

    The OPNsense VM has a virtual network adapter connected to this bridge and is configured as a VLAN trunk.

    The trunk currently carries:

    VLAN 10
    VLAN 20
    

    Inside OPNsense, the VLANs were created on the internal interface.

    VLAN 10

    Parent: vtnet1
    Tag: 10
    Description: MGMT
    

    VLAN 20

    Parent: vtnet1
    Tag: 20
    Description: WEB
    

    The VLAN interfaces were then assigned in OPNsense.

    MGMT

    192.168.10.1/24
    

    WEB

    192.168.20.1/24
    

    DHCP was configured for the web VLAN using Dnsmasq:

    192.168.20.100 - 192.168.20.199
    

    Static infrastructure addresses are kept outside this DHCP range.


    Firewall Considerations

    An important lesson during this setup was that firewall rules can sometimes behave differently from what initially appears obvious.

    A management rule was created to allow the MGMT network to access the Internet while preventing access to RFC1918 private networks.

    The intended logic was:

    MGMT β†’ Internet       ALLOW
    MGMT β†’ Private LANs   BLOCK
    

    However, the rule used an inverted RFC1918 alias.

    Because:

    192.168.10.1
    

    is itself an RFC1918 address, the rule also prevented the NPM container from reaching its own VLAN gateway.

    This initially looked like a VLAN or Proxmox networking problem.

    After checking the VLAN configuration, Proxmox bridge, virtual interfaces and OPNsense packet capture, the firewall rule was identified as the actual cause.

    This was a useful reminder:

    When troubleshooting VLAN connectivity, don’t automatically assume the VLAN is broken. Check the firewall rules as well.

    Once the rule was corrected, VLAN 10 connectivity worked as expected.


    Creating the Nginx Proxy Manager Container

    A Debian 13 LXC was created in Proxmox for Nginx Proxy Manager.

    The container was configured with:

    CT ID: 103
    Hostname: npm01
    Operating System: Debian 13
    Unprivileged: Yes
    CPU: 1 vCPU
    RAM: 1 GB
    Swap: 512 MB
    Disk: 8 GB
    Nesting: Enabled
    

    The container was connected to VLAN 10.

    Its network configuration was:

    IP address: 192.168.10.10/24
    Gateway: 192.168.10.1
    VLAN: 10
    

    The address is outside the DHCP range and is therefore being used as a static infrastructure address.


    Installing Docker

    Nginx Proxy Manager will run as a Docker container.

    First, the Docker repository signing key was downloaded:

    curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
    

    The key permissions were then corrected:

    chmod a+r /etc/apt/keyrings/docker.asc
    

    The official Docker repository was added:

    echo \
      "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
      $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
      tee /etc/apt/sources.list.d/docker.list > /dev/null
    

    The package lists were updated:

    apt update
    

    Docker and the required components were installed:

    apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    

    In this particular installation, the packages were already at their newest versions.


    Verifying Docker

    Docker was checked with:

    systemctl status docker --no-pager
    

    The important result was:

    Active: active (running)
    

    Docker was also enabled to start automatically with the system.

    Docker Compose was then checked:

    docker compose version
    

    The installed version was:

    Docker Compose 5.5.1
    

    At this point Docker and Docker Compose were ready.


    Creating the NPM Directory

    A dedicated directory was created for Nginx Proxy Manager:

    mkdir -p /opt/npm
    cd /opt/npm
    

    This keeps the NPM configuration and persistent data together rather than scattering files around the system.


    Creating the Docker Compose File

    The Docker Compose file was created:

    nano /opt/npm/docker-compose.yml
    

    The following configuration was used:

    services:
      app:
        image: 'jc21/nginx-proxy-manager:latest'
        container_name: nginx-proxy-manager
        restart: unless-stopped
        ports:
          - '80:80'
          - '81:81'
          - '443:443'
        volumes:
          - ./data:/data
          - ./letsencrypt:/etc/letsencrypt
    

    The important ports are:

    80   HTTP
    81   NPM administration interface
    443  HTTPS
    

    The two volume mappings ensure that NPM’s application data and Let’s Encrypt certificates are stored outside the container filesystem.

    This means the container can be recreated without automatically losing the persistent NPM data.


    Starting Nginx Proxy Manager

    From /opt/npm, NPM was started with:

    cd /opt/npm
    docker compose up -d
    

    Docker downloaded the Nginx Proxy Manager image and created the Docker network and container.

    The result included:

    Image jc21/nginx-proxy-manager:latest Pulled
    Network npm_default Created
    Container nginx-proxy-manager Started
    

    Checking the Container

    The running containers were checked with:

    docker ps
    

    The NPM container appeared as:

    nginx-proxy-manager
    

    and showed:

    Up
    

    The published ports were:

    0.0.0.0:80-81
    0.0.0.0:443
    

    This confirms that the container is listening for connections on the expected ports.


    Checking the NPM Logs

    Finally, the container logs were checked:

    docker logs nginx-proxy-manager --tail 30
    

    The output showed the initial database migrations completing, default settings being created, SSL renewal being initialized and the backend starting successfully.

    The important line was:

    Backend PID ... listening on port 3000
    

    No startup errors were reported.


    Current Status

    At this point the basic Nginx Proxy Manager installation is complete.

    The current layout is:

                        Internet
                           β”‚
                           β–Ό
                        OPNsense
                           β”‚
                      VLAN 10 / MGMT
                           β”‚
                           β–Ό
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚      npm01       β”‚
                 β”‚                  β”‚
                 β”‚ Nginx Proxy      β”‚
                 β”‚ Manager          β”‚
                 β”‚                  β”‚
                 β”‚ 192.168.10.10   β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
                           β”‚
                      Later:
                           β”‚
                           β–Ό
                     VLAN 20 / WEB
                           β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β–Ό            β–Ό            β–Ό
            LEMP       CloudPanel     Coolify
    

    Nginx Proxy Manager is running successfully in its Debian 13 LXC using Docker Compose.

    The next stage will be to access the NPM administration interface, perform the initial configuration and then begin configuring it as the reverse proxy for the first website.


    What We Have Learned

    This stage provided a useful practical exercise in several areas:

    • Proxmox VLAN-aware bridges
    • VLAN trunking
    • OPNsense VLAN interfaces
    • OPNsense firewall behaviour
    • RFC1918 network restrictions
    • Debian 13 administration
    • Docker installation
    • Docker Compose
    • Persistent Docker volumes
    • Nginx Proxy Manager deployment
    • Basic container troubleshooting

    One of the most useful lessons was that a connectivity problem doesn’t necessarily mean the VLAN or virtual networking is wrong. In this case, the underlying VLAN configuration was working; the firewall rule was preventing the expected traffic.

    That is exactly the sort of problem that makes a homelab useful for learning.

  • Getting a WordPress Site Live Quickly with a Minimal LEMP Stack

    Getting a WordPress Site Live Quickly with a Minimal LEMP Stack

    Introduction

    The larger Geek.click hosting project is being built as a proper segmented hosting environment using OPNsense, VLANs, reverse proxies and multiple hosting platforms.

    That takes time.

    On this occasion, however, I needed to get a single WordPress site online immediately. There was no point delaying the site while building the complete architecture that would eventually host it.

    The decision was therefore to build a small, temporary LEMP server using the existing Proxmox and OPNsense environment.

    The intention was simple:

    Get one WordPress site online, using as much of the eventual LEMP stack as possible, without redesigning the entire infrastructure around it.

    This setup is temporary. Once the main Geek.click hosting project is completed, the site will eventually be migrated into the proper architecture and this temporary installation will be removed.

    The Temporary Architecture

    The existing server already had:

    • Proxmox
    • OPNsense
    • A public IPv4 address on Proxmox
    • A separate public IPv4 address assigned to OPNsense WAN
    • An internal 192.168.1.0/24 network

    The public Proxmox address was already in use, so it was not possible to simply give the same address to another virtual machine.

    Instead, the new WordPress server was placed behind OPNsense.

    The resulting temporary layout was:

    Internet
       |
       v
    Public IP
       |
       v
    OPNsense
       |
       | NAT :80 / :443
       |
       v
    Debian LXC
    192.168.1.28
       |
       +-- Nginx
       +-- PHP-FPM
       +-- MariaDB
       +-- WordPress
    

    The actual addresses have been replaced here with generic examples.

    Why an LXC?

    There was no requirement for a complicated deployment platform.

    A small Debian LXC was sufficient and was quick to create.

    The container was configured with approximately:

    • Debian 13
    • 2 CPU cores
    • 2 GB RAM
    • 16 GB storage
    • 512 MB swap
    • Internal IP address
    • Gateway pointing to OPNsense

    The container was created as an unprivileged LXC.

    The important point was that the container did not need a public IP of its own. OPNsense would handle the public-facing NAT.

    Installing the LEMP Stack

    Once the Debian container was running, the system was updated:

    apt update && apt upgrade -y
    

    The required software was then installed:

    apt install -y nginx mariadb-server php-fpm php-mysql php-curl php-gd php-mbstring php-xml php-zip php-intl unzip curl
    

    This installed the basic LEMP components.

    Nginx

    Nginx is the web server.

    It receives HTTP/HTTPS requests and serves the WordPress site.

    MariaDB

    MariaDB is the database server.

    WordPress stores its posts, pages, users, settings and other data in the database.

    PHP-FPM

    PHP-FPM executes PHP code for Nginx.

    WordPress is written in PHP, so Nginx needs PHP-FPM to actually execute the WordPress application.

    PHP Extensions

    Several PHP extensions were installed for WordPress functionality, including database connectivity, image handling, XML, ZIP support and HTTP requests.

    OPcache

    PHP’s OPcache was also installed as part of the PHP package setup.

    It caches compiled PHP code and can improve PHP performance.

    Checking the Installation

    PHP was checked with:

    php -v
    

    The installation was running PHP 8.4.

    Nginx and MariaDB were also checked:

    systemctl status nginx mariadb --no-pager
    

    Both services were running successfully.

    At this point the basic server stack was operational.

    Creating the WordPress Database

    A dedicated MariaDB database and user were created for WordPress.

    The database was created with UTF-8 support:

    CREATE DATABASE wordpress CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
    

    A dedicated database user was then created:

    CREATE USER 'wpuser'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD';
    

    The user was granted access only to the WordPress database:

    GRANT ALL PRIVILEGES ON wordpress.* TO 'wpuser'@'localhost';
    FLUSH PRIVILEGES;
    

    The password was subsequently changed after initially entering an incorrect value.

    The important lesson here was simple: test the credentials before getting too far into the installation.

    The database connection was tested directly:

    mysql -u wpuser -p wordpress
    

    The login worked successfully.

    Connecting Nginx to PHP-FPM

    The first PHP test exposed a configuration issue.

    A simple PHP information page was created:

    echo '<?php phpinfo();' > /var/www/html/info.php
    

    The test was then performed:

    curl http://127.0.0.1/info.php
    

    Instead of executing PHP, Nginx returned the PHP source code:

    <?php phpinfo();
    

    This immediately showed that PHP-FPM was installed but Nginx was not yet passing PHP requests to it.

    The PHP-FPM socket was confirmed:

    ls /run/php/
    

    which showed:

    php-fpm.sock
    php8.4-fpm.pid
    php8.4-fpm.sock
    

    The Nginx default site configuration was then updated.

    The important part was:

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
    }
    

    The Nginx configuration was tested:

    nginx -t
    

    and then reloaded:

    systemctl reload nginx
    

    The PHP test was performed again.

    This time PHP was actually executed and the PHP information page was returned.

    The temporary test file was then removed:

    rm /var/www/html/info.php
    

    Installing WordPress

    WordPress was downloaded directly into the Nginx web root.

    cd /var/www/html
    

    Then:

    curl -O https://wordpress.org/latest.tar.gz
    

    The archive was extracted:

    tar -xzf latest.tar.gz --strip-components=1
    

    The archive was removed:

    rm latest.tar.gz
    

    Finally, ownership was changed:

    chown -R www-data:www-data /var/www/html
    

    This allows the web server/PHP process to work with the WordPress files.

    Testing WordPress Before Going Public

    Before configuring DNS or opening the site to the Internet, WordPress was tested locally.

    curl -I http://127.0.0.1/
    

    WordPress returned:

    HTTP/1.1 302 Found
    

    and redirected to:

    /wp-admin/setup-config.php
    

    That was exactly what should happen at this stage.

    The WordPress installer was therefore functioning.

    OPNsense NAT

    The WordPress container had an internal address and was not directly exposed to the Internet.

    OPNsense was used to forward the public traffic.

    A destination NAT rule was created for HTTP:

    WAN :80
        |
        v
    WordPress LXC :80
    

    A second rule was created for HTTPS:

    WAN :443
        |
        v
    WordPress LXC :443
    

    The associated firewall rules were created automatically using the Pass option.

    One small lesson here: creating the NAT rule is not necessarily the final step. The changes also need to be applied in OPNsense.

    Initially the HTTP request appeared to be reaching OPNsense rather than the WordPress container because the new NAT configuration had not yet been applied.

    Once applied, the traffic reached the container correctly.

    DNS

    The domain’s DNS was configured to point to the OPNsense public address.

    The resulting flow was:

    wowforeverhub.com
            |
            v
    Public IP
            |
            v
    OPNsense
            |
            v
    192.168.1.28
    

    Cloudflare was initially set to DNS-only while testing the direct connection.

    DNS was verified from the server:

    dig +short A example.com
    

    The correct public IPv4 address was returned.

    Running the WordPress Installer

    With the networking working, the WordPress setup page became accessible.

    The database settings were:

    Database Name:     wordpress
    Username:          wpuser
    Password:          [the password created earlier]
    Database Host:     localhost
    Table Prefix:      wp_
    

    WordPress successfully connected to MariaDB and completed its installation.

    At this point the site was already functioning over HTTP.

    Adding HTTPS

    The next step was HTTPS.

    Certbot and its Nginx integration were installed:

    apt install -y certbot python3-certbot-nginx
    

    Certbot was then used to obtain a Let’s Encrypt certificate:

    certbot --nginx -d example.com -d www.example.com
    

    Certbot successfully obtained a certificate for the domain and configured Nginx to use it.

    The resulting Nginx configuration included:

    listen 443 ssl;
    
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    

    The certificate was verified with:

    certbot certificates
    

    The certificate was valid and had an expiry date approximately 90 days in the future, as expected for a Let’s Encrypt certificate.

    A Strange HTTPS Test

    There was one interesting final problem.

    Testing the public domain from inside the WordPress container produced:

    SSL certificate problem: self-signed certificate
    

    At first this looked like a certificate problem.

    However, the browser showed the site as completely secure and displayed the correct Let’s Encrypt certificate.

    A direct HTTPS test against the container’s internal address confirmed that Nginx was serving the correct site:

    curl -I https://192.168.1.28 -k
    

    which returned:

    HTTP/1.1 200 OK
    Server: nginx
    

    The problem was therefore not the WordPress server or its certificate.

    The container was attempting to reach its own public address from inside the LAN, passing through the OPNsense routing/NAT path. OPNsense was responding with its own certificate instead of the certificate being served by Nginx.

    The important test was the real one:

    Open the website from the Internet.

    The browser showed a valid HTTPS connection.

    Therefore nothing needed to be changed.

    Final Result

    The temporary hosting environment ended up looking like this:

                             INTERNET
                                |
                                v
                           Cloudflare DNS
                                |
                                v
                           Public IPv4
                                |
                                v
                            OPNsense
                         /             \
                    NAT :80          NAT :443
                       |                 |
                       +--------+--------+
                                |
                                v
                        Debian 13 LXC
                         192.168.1.28
                                |
                 +--------------+--------------+
                 |              |              |
               Nginx         PHP-FPM        MariaDB
                 |              |
                 +------ WordPress
    

    The final checks confirmed:

    • Debian LXC working
    • Network connectivity working
    • Nginx working
    • PHP-FPM working
    • MariaDB working
    • WordPress working
    • OPNsense NAT working
    • DNS working
    • Let’s Encrypt certificate working
    • Public HTTPS working

    The WordPress site was therefore live.

    What I Deliberately Didn’t Build Yet

    This was intentionally not the final Geek.click hosting architecture.

    I did not stop to implement:

    • VLAN separation
    • Nginx Proxy Manager
    • a dedicated management network
    • a dedicated website network
    • WireGuard management access
    • WAF configuration
    • IDS/IPS
    • elaborate firewall policies
    • container orchestration
    • production monitoring
    • the final backup architecture
    • multiple hosting platforms

    Those are all part of the larger project.

    The purpose of this exercise was simply to get one WordPress site online quickly while still using the underlying technologies that I actually want to understand.

    What This Will Become Later

    The temporary server is useful because almost everything here can eventually be reused conceptually.

    The final Geek.click hosting architecture will be more structured:

    Internet
        |
        v
    OPNsense
        |
        v
    Management / Proxy / Website VLANs
        |
        v
    Nginx Proxy Manager
        |
        +---- Manual LEMP
        |
        +---- CloudPanel
        |
        +---- Coolify
    

    The temporary WordPress installation can then be migrated into that architecture when the larger project is complete.

    For now, however, the important objective was achieved:

    one WordPress site, running on a real LEMP stack, publicly accessible over HTTPS β€” without waiting for the entire hosting laboratory to be built.

  • WordPress Breadcrumb Problems (and how to fix them)

    Have you ever had a blog post where the breadcrumb trail just… vanished? Or worse, it showed up in a weird spot while every other post on your site looked fine?

    I ran into this exact issue recently and spent way too long digging through theme files, checking hooks, and comparing templates. The fix turned out to be surprisingly simple.

    The Symptoms

    • I had temporarily enabled breadcrumbs to check how I could apply them to the site
    • After they were disabled, or rather, the code was removed from the header and also from the single posts page in Theme editor / Templates, there was one specific post that still had the breadcrumbs appearingΒ closer to the headerΒ than usual
    • Other posts and pages were completely unaffected, no breadcrumbs to be found

    The Culprit

    It all came down to categories.

    Breadcrumb plugins like Yoast SEO, Rank Math, and SEOPress work by displaying the first category assigned to a post in the breadcrumb trail. Here’s what the path typically looks like:

    text

    Home > Category Name > Post Title

    When I removed the category from that one problematic post, the entire breadcrumb line disappeared. That’s because the plugin had nothing to display in that middle spot.

    I then reapplied the same category and the breadcrumb never came back.

    Why This Happens

    Breadcrumb plugins handle categories in a specific way:

    • They pick theΒ first categoryΒ based on alphabetical order or the order they were added
    • If a post hasΒ no category, most plugins will hide the breadcrumb entirely
    • Child categoriesΒ (subcategories) can sometimes display differently than parent categories

    In my case, that one post was assigned to a different category than all my other posts. The plugin tried to display it, but something about that category’s setup (maybe it was a child category or had different settings) made the breadcrumb render in an unexpected position.

    How to Fix It

    You have a few other options depending on what you want to achieve:

    Option 1: Reassign to the Correct Category

    Simply edit the post and assign it to the same category your other blog posts use. The breadcrumb should reappear and match the rest of your site.

    Option 2: Set a Primary Category (Yoast/Rank Math)

    If you’re using Yoast SEO or Rank Math, you can manually choose which category appears in the breadcrumb:

    1. Edit the post
    2. Scroll to the SEO meta box
    3. Look forΒ “Primary Category”
    4. Select which category you want to display

    This overrides the default alphabetical/first-added behavior.

    Option 3: Leave It Uncategorized

    If you don’t want a breadcrumb on this particular post, leaving it without a category works. Just be aware that WordPress will automatically assign “Uncategorized” to any post without a category unless you’ve deleted or renamed that default category.

    Option 4: Hide Breadcrumbs with CSS (For One Post Only)

    If you want to keep the category but hide the breadcrumb just on this post:

    css

    .postid-123 .breadcrumb-class {
        display: none;
    }

    Replace 123 with your actual post ID and .breadcrumb-class with your theme’s breadcrumb container class.

    The Takeaway

    If you ever notice a breadcrumb acting strangely on just one post, check its categories first. Nine times out of ten, that’s where the issue lives.

    It’s not a theme bug, it’s not a plugin conflictβ€”it’s just how breadcrumb plugins handle categories by default. And once you know that, it’s a five-second fix.


    Have you run into any other weird WordPress breadcrumb issues? Drop a comment belowβ€”I’d love to hear what worked for you!


  • How to Back Up a Docker Container Manually on Debian to rebuild in Minutes (Disaster Recovery Guide)

    After getting my WireGuard server working, I realised something.

    If the SSD died tomorrow, or I wanted to reinstall Debian from scratch, would I remember every change I made?

    Probably not.

    While the installation itself is documented, I wanted a quick way to recover a working WireGuard server without having to generate new keys or reconfigure every client.

    Fortunately, WireGuard stores very little state. A proper backup only needs a handful of files.


    What actually needs backing up?

    Most of the important information lives in your WireGuard project directory.

    This contains:

    • Docker Compose configuration
    • WireGuard configuration
    • Server private/public keys
    • Client keys (if you chose to keep them)
    • Peer definitions
    • IP addressing
    • Firewall rules contained within the configuration

    If these files are preserved, your clients can reconnect immediately after a rebuild because the server keeps the same identity.

    In addition to the WireGuard directory itself, I also backed up the system configuration that enables IP forwarding and my Debian network configuration.

    For my setup, that meant backing up:

    • ~/wireguard
    • /etc/sysctl.d/99-wireguard.conf
    • /etc/network/interfaces

    Creating a simple backup

    Rather than remembering every file individually, I created a small backup script.

    #!/bin/bash
    
    mkdir -p ~/backups
    
    tar czf ~/backups/wireguard-$(date +%F).tar.gz \
        ~/wireguard \
        /etc/sysctl.d/99-wireguard.conf \
        /etc/network/interfaces
    

    Running the script creates a compressed archive similar to:

    wireguard-2026-07-19.tar.gz
    

    Everything needed to rebuild the server is now stored in a single archive.


    Copying the backup off the server

    A backup isn’t much use if it lives on the same disk that could fail.

    Since I was working from Windows, I simply copied the archive using SCP.

    scp [email protected]:~/backups/wireguard-2026-07-19.tar.gz .
    

    This downloads the archive into the current directory on the Windows machine.

    From there it can be copied to another PC, NAS, cloud storage, or wherever you keep your backups.


    Why preserving the keys matters

    One of the most important files in the backup is the server’s private key.

    If you rebuild the server using the same key, every existing client (phone, laptop, tablet, etc.) will reconnect without needing to be reconfigured.

    If you generate a brand new server key instead, every client must be updated with the new public key before it can connect again.

    Keeping the original keys saves a surprising amount of work.


    Documentation is part of the backup

    A backup is only half of the recovery process.

    The other half is documentation.

    A few months from now it’s easy to forget which files were modified, where WireGuard was installed, or which configuration changes were required.

    For that reason I’m documenting every service I build.

    Eventually I want each service in my homelab to have its own recovery guide.

    For example:

    • Debian base installation
    • Docker installation
    • WireGuard recovery
    • AdGuard Home recovery
    • Nginx Proxy Manager recovery
    • Homepage recovery
    • Grafana recovery
    • Zabbix recovery

    If a machine ever fails, I should be able to reinstall Debian and have everything running again simply by following my own documentation.

    That’s one of the long-term goals of this homelab: making infrastructure reproducible instead of relying on memory.


    What I actually used

    This is the exact solution I ended up using.

    Backup script

    #!/bin/bash
    
    mkdir -p ~/backups
    
    tar czf ~/backups/wireguard-$(date +%F).tar.gz \
        ~/wireguard \
        /etc/sysctl.d/99-wireguard.conf \
        /etc/network/interfaces
    

    Copying the backup to Windows

    scp [email protected]:~/backups/wireguard-2026-07-19.tar.gz .
    

    Simple, quick, and enough for me to rebuild the WireGuard server without starting from scratch.

    I understand that there must be easier and faster ways to do all of the above, but for now this is how I’ll proceed until I come across those methods.

  • Running OPNsense as a Virtual Firewall on Proxmox Dedicated Server

    A common use case for OVH dedicated servers is running multiple virtual machines while keeping the environment securely separated behind a firewall.

    Proxmox VE is an excellent hypervisor for this, but when you want proper network segmentation, DHCP, NAT, VPN access, and firewall rules, running a dedicated firewall appliance such as OPNsense inside Proxmox makes a lot of sense.

    This guide explains how to build the following setup:

    • OVH dedicated server
    • Proxmox VE as the hypervisor
    • OPNsense running as a virtual machine
    • OVH additional/failover IP assigned to OPNsense WAN
    • Private LAN network for virtual machines
    • NAT and DHCP handled by OPNsense
    • Proxmox host remaining accessible for management

    One important OVH-specific issue is covered at the end: the “Far Gateway” problem that prevents OPNsense from reaching the OVH gateway when using routed failover IPs.


    Network Design Overview

    The final topology will look like this:

                        Internet
                           |
                      OVH Network
                           |
                  OVH Dedicated Server
                           |
            +------------------------------+
            |          Proxmox VE          |
            |                              |
            | Primary IP: a.a.a.222        |
            | vmbr0 β†’ eno1                 |
            |                              |
            |                              |
            |       OPNsense VM            |
            |                              |
            | WAN: b.b.b.59                |
            | LAN: 10.0.0.1                |
            |                              |
            +-------------+----------------+
                          |
                        vmbr1
                          |
                  Internal VM Network
    
                  VM1 10.0.0.x
                  VM2 10.0.0.x
                  VM3 10.0.0.x
    

    The idea:

    • OVH’s original IP stays on the Proxmox host.
    • The additional OVH IP is assigned directly to the OPNsense WAN interface.
    • OPNsense becomes the router/firewall for internal VMs.
    • Internal machines never directly touch the OVH network.

    OVH Network Requirements

    You need:

    Primary OVH IP

    Example:

    IP:
    a.a.a.222
    
    Gateway:
    a.a.a.254
    
    Subnet:
    255.255.255.0 (/24)
    

    This IP belongs to the physical Proxmox host.


    Additional OVH IP

    Example:

    IP:
    b.b.b.59
    

    OVH will provide:

    • Additional/failover IP
    • Virtual MAC address

    The virtual MAC is extremely important.

    OVH uses it to identify which virtual machine should receive traffic for that IP.


    Configure Proxmox Networking

    The Proxmox host keeps the main OVH IP.

    Example:

    /etc/network/interfaces

    auto lo
    iface lo inet loopback
    
    
    auto eno1
    iface eno1 inet manual
    
    
    auto vmbr0
    iface vmbr0 inet static
        address a.a.a.222/24
        gateway a.a.a.254
        bridge-ports eno1
        bridge-stp off
        bridge-fd 0
    
    
    auto vmbr1
    iface vmbr1 inet manual
        bridge-ports none
        bridge-stp off
        bridge-fd 0
    

    Explanation:

    vmbr0

    This is the external bridge.

    vmbr0
     |
    eno1
     |
    OVH network
    

    Used for:

    • Proxmox management
    • OPNsense WAN interface

    vmbr1

    This is an internal-only bridge.

    No physical NIC is attached.

    Used for:

    • Private VM traffic
    • OPNsense LAN interface

    You can also configure this from:

    Proxmox GUI
    β†’ System
    β†’ Network
    

    Changes are written to:

    /etc/network/interfaces
    

    Important: Do Not Configure the Failover IP on Proxmox

    The additional OVH IP:

    b.b.b.59
    

    should NOT be added to:

    • eno1
    • vmbr0
    • Proxmox host networking

    It belongs inside the OPNsense VM.

    The OVH virtual MAC will be attached to the OPNsense WAN NIC.


    Create the OPNsense Virtual Machine

    Download the OPNsense DVD ISO.

    Upload it:

    Proxmox
    β†’ local storage
    β†’ ISO Images
    β†’ Upload
    

    Create a new VM.

    Example settings (which I used this time):

    BIOS

    SeaBIOS
    

    CPU

    2 cores
    

    RAM

    4096 MB
    

    Disk

    8GB
    

    Machine

    Default i440fx
    

    Controller

    SCSI
    VirtIO SCSI single
    

    Add Network Interfaces

    The VM needs two network adapters.

    WAN Interface

    Attach to:

    vmbr0
    

    Change MAC address to the OVH virtual MAC.

    Example:

    AA:BB:CC:DD:EE:FF
    

    This is the MAC OVH assigned to:

    b.b.b.59
    

    LAN Interface

    Attach to:

    vmbr1
    

    Normal Proxmox-generated MAC is fine.


    The final VM should have:

    Net0
     |
    vmbr0
     |
    WAN
     |
    b.b.b.59
    
    
    Net1
     |
    vmbr1
     |
    LAN
     |
    10.0.0.1
    

    Install OPNsense

    Boot the VM from the ISO.

    Important:

    At the prompt do not accidentally just run the live environment using root (user) and opnsense (password).

    Instead you need to login as

    installer (and opnsense for password)
    

    Otherwise:

    root/opnsense
    

    live mode starts from the CD, and after reboot, all changes disappear.


    For filesystem:

    UFS
    

    works fine.

    ZFS is possible but usually unnecessary for a small firewall VM.

    ZFS:

    • requires more RAM
    • provides benefits mostly with larger storage setups

    Assign OPNsense Interfaces

    During first boot:

    Assign:

    WAN β†’ vmbr0 NIC
    
    LAN β†’ vmbr1 NIC
    

    After installation:

    Connect a VM to vmbr1 (I installed a Debian gui inside Proxmox temporarily) and access:

    https://10.0.0.1
    

    Login:

    root
    

    This is a good oppurtunity to reset the password if you didn’t already do so during the initial installation.

    Lobby
    β†’ Password

    Once everything is completed it is good to come back around and add another user to handle standard day to day activities.


    Configure the OPNsense WAN Interface

    Go to:

    Interfaces
    β†’ WAN
    

    Set:

    IPv4 Configuration Type:
    
    Static IPv4
    

    Enter:

    IPv4 Address:
    
    b.b.b.59
    

    Subnet:

    /32
    

    Example:

    b.b.b.59/32
    

    Why /32?

    OVH failover IPs are routed addresses.

    The IP itself does not belong to the same subnet as the gateway.

    The server gateway:

    a.a.a.254
    

    is outside:

    b.b.b.59/32
    

    This is normal.


    Configure the OVH Gateway

    Go to:

    System
    β†’ Gateways
    β†’ Single
    

    Create a gateway.

    Interface:

    WAN
    

    Gateway:

    a.a.a.254
    

    Enable:

    Upstream Gateway
    

    Most importantly:

    Enable:

    Far Gateway
    

    The OVH + OPNsense “Far Gateway” Problem

    This is the most common issue.

    Everything looks correct:

    • VM networking works
    • Virtual MAC is correct
    • WAN IP is correct
    • Gateway is correct

    But:

    OPNsense cannot ping OVH gateway
    

    Why?

    Because OPNsense sees:

    WAN IP:
    b.b.b.59/32
    

    Gateway:

    a.a.a.254
    

    The gateway is outside the subnet.

    Normally routers expect the gateway to be directly reachable.

    OVH’s routed setup works differently.

    The Far Gateway option tells OPNsense:

    “Yes, this gateway is outside this interface subnet. This is expected.”

    After enabling:

    Far Gateway
    

    save and apply.

    The WAN should immediately become functional.


    Configure the LAN Network

    Example:

    LAN IP:
    
    10.0.0.1/24
    

    Go to:

    Interfaces
    β†’ LAN
    

    Set:

    IPv4:
    
    10.0.0.1/24
    

    Enable DHCP

    Go to:

    Services
    β†’ DHCPv4
    β†’ LAN
    

    Enable DHCP.

    Example range:

    10.0.0.50
    -
    10.0.0.200
    

    Now VMs connected to vmbr1 will automatically receive:

    IP address
    Gateway
    DNS
    

    from OPNsense.


    Configure Firewall Rules

    By default, LAN traffic may be blocked.

    Create a rule:

    Firewall
    β†’ Rules
    β†’ LAN
    

    Add:

    Action:
    Pass
    
    Direction:
    Out
    
    Protocol:
    Any
    
    Source:
    LAN net
    
    Destination:
    WAN net
    

    This allows:

    LAN β†’ Internet
    

    To Check – Configure NAT

    Go to:

    Firewall
    β†’ NAT
    β†’ Outbound
    

    Use:

    Automatic outbound NAT
    

    OPNsense will automatically translate:

    10.0.0.x
    

    into:

    b.b.b.59
    

    when accessing the internet.


    To Check – Proxmox Firewall Considerations

    Proxmox also has its own firewall.

    For an OPNsense VM acting as the main firewall:

    Usually disable Proxmox firewall on the OPNsense NICs.

    VM settings:

    Hardware
    β†’ Network Device
    β†’ Firewall
    
    Disable
    

    Why?

    Because you now have:

    Internet
        |
    OPNsense firewall
        |
    VM network
    

    Adding another firewall layer can create unexpected blocking.

    A Proxmox firewall rule could prevent OPNsense from reaching the OVH gateway.


    Testing the Setup

    Test Proxmox

    SSH into Proxmox:

    ping a.a.a.254
    

    Then:

    ping 8.8.8.8
    

    Confirm the host works.


    Test OPNsense WAN

    In OPNsense:

    Interfaces / Diagnostics
    β†’ Ping
    

    Test:

    a.a.a.254
    

    Then:

    8.8.8.8
    

    Test Internal VM

    Within the test VM:

    Network:

    vmbr1
    

    It should have already received:

    10.0.0.x
    

    Check:

    ipconfig
    

    or:

    ip addr
    

    Test:

    ping 10.0.0.1
    

    Then:

    ping 8.8.8.8
    

    Future Remote Access: WireGuard Backdoor

    A good final design is:

    Internet
     |
    Proxmox public IP
     |
    WireGuard
     |
    Private management network
     |
    OPNsense
     |
    VMs
    

    Keep the Proxmox IP:

    a.a.a.222
    

    for emergency access.

    Later:

    • restrict web access
    • disable unnecessary exposed ports
    • use WireGuard VPN for administration

    This provides a secure recovery path if OPNsense rules are accidentally misconfigured.


    Final Notes

    Running OPNsense inside Proxmox on OVH works very well, but there are a few OVH-specific details that are easy to miss:

    • Use OVH virtual MAC on the OPNsense WAN NIC
    • Keep the OVH primary IP on Proxmox
    • Use /32 for failover IPs
    • Use an internal bridge for your VM network
    • Disable Proxmox firewall on OPNsense interfaces initially
    • Enable Far Gateway in OPNsense

    That last setting is the one that usually causes hours of troubleshooting:

    OVH routed IP + OPNsense = Far Gateway required.

    Once configured correctly, OPNsense becomes a full virtual edge firewall for your Proxmox environment.