Tag: Proxmox

  • 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.