Deploying Pi-hole v6 Offline on Proxmox VE with a Palo Alto Firewall

Deploying Pi-hole v6 Offline on Proxmox VE with a Palo Alto Firewall

An architectural summary and step-by-step rebuilding guide for the containerized offline deployment.

🏗️ Technical Architecture Overview

    • Hypervisor: Proxmox VE (PVE)
    • Parent Compute Layer: Privileged Linux Container (LXC) running a minimal Debian 12 base template.
    • Network Blueprint: Bound to vmbr1, isolated natively via VLAN 7 (172.16.7.2/24), routing through gateway 172.16.7.1.
    • Runtime Engine: Decoupled, standalone static binary installation of Docker Engine v27.3.1.
    • Application Tenant: Pi-hole v6 running in native Host Networking Mode (--net=host).
    • Upstream Security: Border perimeter managed by a Palo Alto Networks (PAN) Layer 7 firewall with strict application checking and URL filtering.


🛑 Phase 1: Perimeter Firewall Provisioning (Palo Alto)

Because the Pi-hole requires internet access to initialize its database but must remain highly secured, a tailored Layer 7 inspection ruleset was applied.
    1. Custom URL Category Object: Created an object named Pihole-Gravity-Sources populated with explicit domains to pull source databases:
        • *.github.com
        • *.githubusercontent.com
        • *.pi-hole.net
        • install.pi-hole.net

    2. Security Policy Mapping:
        • Source: VLAN 7 Zone (172.16.7.2)
        • Destination: WAN/External Zone (any)
        • Applications Permit List: ssl, web-browsing, dns, github-base (Crucial: required to pass deep inspection chunking packets during data stream ingestion).
        • Service Profile: Standard ports 80/http and 443/https.
        • URL Category Constraint: Enforced to match Pihole-Gravity-Sources.


💾 Phase 2: Staging Offline Files (Management PC)

To populate an offline PVE node, core files were staged using an online workstation browser:
    1. PVE Debian 12 Template:
      Index of /images/system/
    2. Docker Standalone Core:
      Index of linux/static/stable/x86_64/
    3. Pi-hole v6 Docker Image: Pulled down via an archive generator engine as a clean deployment container tarball (pihole-pihole-latest-linux-amd64.image.tar).


🛠️ Phase 3: Parent LXC Deployment & Pre-Staging

All instructions were passed natively via the Proxmox Host Shell CLI to build the environment and seed assets before booting the container.
    1. Create the Parent Host:
      pct create 105 /mnt/pve/mass-storage/template/cache/debian-12-standard_12.7-1_amd64.tar.zst \
        -cores 1 \
        -memory 1024 \
        -swap 512 \
        -ostype debian \
        -storage mass-storage \
        -rootfs mass-storage:8 \
        -unprivileged 0 \
        -features nesting=1 \
        -onboot 1 \
        -net0 name=eth0,bridge=vmbr1,tag=7,ip=172.16.7.2/24,gw=172.16.7.1 \
        -nameserver 1.1.1.1
      

    2. Mount the Raw Virtual Disk for Staging:
      pct mount 105
      

    3. Decompress and Inject Standalone Docker Binaries into System Paths:
      tar -xf /mnt/pve/mass-storage/template/cache/docker-27.3.1.tgz -C /var/lib/lxc/105/rootfs/usr/bin/ --strip-components=1
      

    4. Decompress and Inject Pi-hole Image Template:
      tar -xf /mnt/pve/mass-storage/template/cache/pihole-pihole-latest-linux-amd64.image.tar.gz -C /var/lib/lxc/105/rootfs/root/
      

    5. Close and Fire Up Compute Layer:
      pct unmount 105
      pct start 105
      


🐳 Phase 4: Initializing Engine Layers (Inside Container Shell)

Navigating straight inside the container environment using pct enter 105.
    1. Iptables isn’t natively available in the Debian Image so…
    2. Mitigate Missing Network Engine Elements (iptables Bypass JSON Config):
      Because the thin container lacked the legacy iptables stack, we commanded Docker to use a hypervisor-safe block driver storage matrix and turn off firewall manipulation:
      mkdir -p /etc/docker
      cat << 'EOF' > /etc/docker/daemon.json
      {
        "exec-opts": ["native.cgroupdriver=cgroupfs"],
        "storage-driver": "vfs",
        "iptables": false
       }
      EOF
      

    3. Start Core Engine Systems:
      dockerd > /var/log/dockerd.log 2>&1 &
      

    4. Ingest Web Registry Layouts:
      docker load -i /root/pihole-pihole-latest-linux-amd64.image.tar
      


🎯 Phase 5: Executing the Pi-hole v6 Application Tenant

Deploying with variables matching the newly restructured Pi-hole v6 engine specifications.
    1. The Live Run Sequence:
      docker run -d \
        --name=pihole \
        --net=host \
        -e TZ="America/Chicago" \
        -e FTLCONF_webserver_api_password="WebLoginPasswordHere" \
        -e FTLCONF_dns_listeningMode="ALL" \
        -e FTLCONF_dns_upstreams="1.1.1.1;9.9.9.9" \
        -v pihole_config:/etc/pihole \
        -v dnsmasq_config:/etc/dnsmasq.d \
        --restart=unless-stopped \
        pihole/pihole:latest
      

        • --net=host: Bypassed broken virtual bridge layers, mapping directly to VLAN 7 network cards.
        • FTLCONF_webserver_api_password: Resolved the deprecated WEBPASSWORD v5 variable problem.
        • FTLCONF_dns_listeningMode="ALL": Opened cross-subnet resolution rules, allowing external VLAN query targets.
        • FTLCONF_dns_upstreams: Seaded upstream parameters to prevent silent drops.


🔒 Phase 6: Core System Persistence (Automation on Boot)

Because raw binary files do not auto-load rules or daemons natively when an LXC starts, a custom boot initialization script was injected to force persistence across power outages.
cat << 'EOF' > /etc/init.d/docker
#!/bin/sh
### BEGIN INIT INFO
# Provides:          docker
# Required-Start:    $network $remote_fs
# Required-Stop:     $network $remote_fs
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Start Docker Daemon
### END INIT INFO

case "$1" in
  start)
    echo "Starting Docker Daemon..."
    # Carve runtime networks back into memory paths
    nft add table inet filter 2>/dev/null
    nft add chain inet filter input { type filter hook input priority 0 \; policy accept \; } 2>/dev/null
    nft add rule inet filter input udp dport 53 accept 2>/dev/null
    nft add rule inet filter input tcp dport 53 accept 2>/dev/null
    # Run core daemon engine
    /usr/bin/dockerd > /var/log/dockerd.log 2>&1 &
    ;;
  stop)
    echo "Stopping Docker Daemon..."
    killall dockerd
    ;;
  restart)
    $0 stop
    sleep 2
    $0 start
    ;;
  *)
    echo "Usage: $0 {start|stop|restart}"
    exit 1
    ;;
esac
exit 0
EOF

chmod +x /etc/init.d/docker
update-rc.d docker defaults
Holy Ballssacks bro, why does LXc containers have to be such a bitch.. all it should be is define the imagine you want to use, and the properties you want to define just like the docker deployment, i shouldn’t have to setup a LXC to run docker to run a proper container… fucking LXC.
Direct Comparison of Approaches
Feature 🛠️ The Video’s Approach (Native Script) 🟦 Our Approach (Docker inside LXC)
Architecture LXC ➡️ Pi-hole Script LXC ➡️ Docker ➡️ Pi-hole Container
How it works The script installs software packages (lighttpd, php, pihole-FTL) directly into the Debian template file system, just like a traditional VM or physical server. The Debian template is treated as a clean, blank slate. Docker manages the dependencies, keeping the Pi-hole completely sealed off in its own application layer.
Internet Needs Requires constant, wide-open internet during installation to pull dozens of Linux package dependencies via apt. Can be done 100% offline. We only needed a tiny firewall hole for the single pre-packaged image file.
Upgrades You must run pihole -up inside the console, which modifies the system packages and can break if dependencies drift. To upgrade, you simply delete the container and spin up a new image version. Your underlying configuration directories stay untouched.
Flexibility If you want to run another tool (like a dashboard or home automation), its packages mix with Pi-hole’s web server settings, often causing conflicts. You can spin up 5 more isolated Docker containers inside this same LXC host without them ever interfering with Pi-hole.
Why did the video creator do it that way?
The video creator used the native script because it is the easiest path for a standard home lab with wide-open internet access. You just paste one command (curl -sSL https://pi-hole.net | bash), click “Next” on a few menus, and it handles everything automatically. They treat the LXC exactly like a lightweight VM because they don’t have strict network constraints.
Why were we forced to use our approach?
Your specific environment made the video’s method nearly impossible for two massive reasons:
    1. Your PVE Host is Offline: The native Pi-hole installer script expects to talk to official Debian package repositories to download web servers and compilation tools. Because your host couldn’t reach the open web, that script would have crashed immediately on missing dependencies.
    2. Your Strict Palo Alto Firewall: Even if we got the script running, a standard Linux installation queries dozens of different repository mirrors across random IP addresses and domain spaces. Your Palo Alto would have blocked 90% of them. By using Docker, we only had to tell the firewall to trust one single destination (github.com) to get the container fully populated.
Our approach required a lot more upfront engineering, but you ended up with a far more resilient setup. Your core Debian system remains pristine, and your Pi-hole is perfectly contained.
But wait you must be asking, how do we manage it. Well, the web interface will be available for most administrative work.
Why it doesn’t just go to the dashboard login… unno… anyway.  “Nooo, like the container…” well you should have access to the PVE host, enter the LXC with “pct enter 105”, replacing 105 with the ID of your LXC container.  “nooo, the docker container”
ohh…
root@docker-host:~# docker exec -it pihole /bin/bash
failed to create runc console socket: stat /run/user/0: no such file or directory: unknown
root@docker-host:~# mkdir -p /run/user/0
root@docker-host:~# docker exec -it pihole /bin/bash
docker-host:/#
look we’re in… but why doesn’t it have its own hostname?
“The reason you are seeing docker-host inside the container is because your container is running on the host network mode (–net=host).In Docker, when a container uses the host network stack, it bypasses Docker’s standard network isolation. It shares the host’s network namespace, IP addresses, network interfaces, and hostname directly.”
What about updates? … Well, adjusted security rule to allow app: apt-get, and destination security.debian.org and deb.debian.org, run apt update and apt upgrade, good to go. Just make a backup first. Pi-hole updates? whent he latest container release is available, simple bring down the instance, pull the latest image, and bring the container back up.
I wish containers were a bit different on PVE, but I also understand their complexities over advanced deployments. but it would be nice if they had simple FOSS containers for simple stuff like Pi-Hole, let me click add containerized app in the PVE host menu, pick the app (in this case Pi-Hole) specify the options (IP[vlan], DNS, Hostname) click deploy and it just runs the container on the PVE host itself and be done with it, updates then would just be stop the container, pull latest image, bring the container up.. none of this having to manage an LXC with its own apps and libraries and have to run apt update on it… ehhh whatever, I’ll just move on at this point lol…
Man, but I can’t back it up using Veeam, you can only backup VMs. So, unless I want to manage to backup solutions Veeam + PVE Backup Server, I’d have to convert to a VM instead of an LXC anyway. The main advantage was RAM savings..
vs
well **** I might just end up throwing this in the trash and replacing it with a VM running docker instead… **** me, well.. guess you live and you learn. but it’s still a super tight setup if someone wanted to do PiHole on LXC, while being able to do a regular docker style pull instead of a raw dawg install,
Hope this helps someone.
*Update* I had an issue where pihole wasn’t starting on boot, so used crontab to resolve it.
  • Run crontab -e .
  • Arrow down to the very bottom line.
  • Paste the fallback rule:
    text
    @reboot dockerd > /var/log/dockerd.log 2>&1 &

ASUS calling Microsoft

Back Story

I’ll try to keep this post short as I’m behind on many other posts I have to finish. hahah :S

Anyway, I was thinking it’s time to update my pihole, when I checked the admin web interface to check for clients to see who’d still be using it for DNS, and then I’d make a list and be prepared to change them as required (any outside of DHCP of course, as I’d simply change the IP there). Now you might be wondering, why change the IP address? Which is a fair question, I could just update the one in question, but I had bigger plans to move it to another server, I didn’t want to give the other server multiple IPs, so I figured it be easier to spin up the new service on that server and simply change the DNS on the DHCP server/service. Anyway… where was I, oh right, checking the web admin I noticed the top client was my new ASUS RT-AX88U. I was hoping to get a model that supported Tomato like the old RT-N16 I had for so many years which I recently broke and so replaced it with this unit. It currently can’t run Tomato like I managed to do with the RT-N16. So, I just had configured it for AP mode. Figured it doesn’t need to do much else for now besides serve unreal good WiFi.

Yet it’s calling home to “dns.msftncsi.com”, when I looked up this domain it seems to be used mostly by windows machines to check to make sure they are online.

Fix This

Looking a bit further into it I managed to find this magical Reddit post (I really love reddit, I’ve found so many helpful posts there). Anyway let’s see if we can follow the steps on this router.

Step 1 – Enable Access

The source uses telnet, but I’m not a fan of transferring creds in cleartext, unless I know for certain it’s a completely isolated network. Since the router supports SSH, I enabled that instead and logged in. *note* I had to remove the fingerprint from the old RT-N16 I used to SSH into.

Step 2 – Gain Shell Access to your Router

login & password is the same as the web interface.

K, with that done, let’s see if we can edit the nvram, but let’s take a look as the OP suggests.

Step 3 – Look deep into NVRAM

nvram show | sort | less

I used the less command instead, as my old linux instructor once said “less is more” using less you can use the up and down arrow keys to scroll through the results, and look-e-here: (Press Q to exit less)

Step 4 – Finding the Droids

The droids I was after. Time to eliminate them.

Step 5 – Kill the Probe Content Droid

nvram set dns_probe_content=127.0.0.1

Step 6 – Kill the Probe Host Droid

nvram set dns_probe_host=""

Step 7 – Prevent Droid Resurrection

nvram commit

Step 8 – Fully Enforce Your New Empire

reboot

Verify:

Noice!

Adding a static host record to PiHole

Adding a record:

pihole -a hostrecord home.consto.com 192.168.1.10

Removing a record:

pihole -a hostrecord

*UPDATE* This only adds one record, and doing this command a second time removes the old record. For multiple records hosting on a PiHole, here’s the main deets as provided by llauren:

”

With a little configuration, you can use your pi-hole as the DNS server for your LAN, if, for example, your router isn’t doing a very good job serving local names. Here’s how:

Create a second dnsmasq configuration file:

% echo "addn-hosts=/etc/pihole/lan.list" | sudo tee /etc/dnsmasq.d/02-lan.conf

(that % is for whatever your system prompt is; don’t type it out :wink: )

After this, create a “hosts file” for your network /etc/pihole/lan.list with the format ipaddress fqdn hostname, eg

192.168.1.40     marvin.your.lan  marvin
192.168.1.41     eddie.your.lan   eddie
192.168.1.42     hactar.your.lan  hactar

…substituting “your.lan” for whatever you want your domain name to be.

On your DHCP server (most likely your router, though pi-hole indeed can be configured into one), you’ll also need to set your search domain to whatever “your.lan” corresponds to.

Finally, restart your name server:

% sudo pihole restartdns

Additional thoughts

  • If all this domain name stuff confuses you, you can leave it out and live a domain-less life on your LAN.
  • While you certainly can serve any name, also of hosts outside of your LAN, you probably can’t outsmart Netflix to play shows from outside your geographical area :slight_smile: . Drop that thought. It’s probably against their TOS and you might end up losing your Netflix account.
  • The dnsmasq manual page 765 suggests the configuration option hostsdir, but this didn’t work on my raspi. Possibly i was just incompetent.”