Pi-hole Secure Setup Guide: nftables + Automated Certificate Renewal
Tags: #pihole #security #nftables #certbot #ssl #certificates raspberrypi
Table of Contents
- Introduction
- Requirements & Assumptions
- Preparation
- Best practise advise
- nftables Setup
- Certbot and Let's Encrypt Setup
- Automated Certificate Renewal
- Certificate Deployment Script
- Verification
- Troubleshooting
Introduction
This guide shows how to setup nftables
and certbot
for automated certificates on Raspbian GNU/Linux 12 (bookworm) for a Pi-hole installation.
I wrote this for my own documentation and because I could not find any recent that explains along the way, I decided to make this into a generic guide for the benefit of the community.
Important: This guide is only for "direct to OS" setups of Pi-hole on supported operating systems; it will not work if you use Docker.
After completing this guide, you'll have:
- A secure
nftables
firewall with a basic ruleset (ports 22, 80, 443, 53, ping replies, and allowing all outgoing traffic) - Certbot with DNS-01 challenge for secure certificate issuance
- Automatic certificate renewals via systemd timer
- A post-hook script to prepare certificates for Pi-hole and restart
pihole-FTL.service
A note about ACME challenges:
This guide requires your DNS provider to support Certbot
with DNS-01
challenges. This guide is NOT for HTTP challenges, which would require exposing your Pi-hole web interface to the internet (a significant security risk), You should never do this! This guide is for users who run Pi-hole for what it is designed for: Be a secure DNS sinkhole/forwarding and caching resolver.
Requirements and Assumptions
- A Raspberry Pi or other supported device running a supported OS
- Default Pi-hole installation to the default location and its working without issues
- You have made no changes that interfere with the webserver or certificates
- A domain with a provider that has API support for
Certbot
withDNS-01
challenge - Ample beverage of choice to get through this guide. I suggest strong black coffee
- Basic knowledge of Linux, bash, certbot/acme, and [ip|nf]tables
- LLM's like Claude, ChatGTP, or Perplexity might provide decent help for when you get stuck, but you need to have some knowledge of these things to make them work to your advantage by asking the right questions correctly.
- You have a brain and you are somewhat proficient at using it
Best practise advise
The best practise for internal domains is to use a subdomain under a valid public domain that you own. By owning, I mean that you pay for its registration in order for the domain to be under your control.
No one can actually own a domain. Not even Microsoft owns microsoft.com
in that absolute sense. Even they have to maintain proper registration and pay 2 dollars a year to keep control over their domains.
When you own your own domain name, it is best practise and follows industry standards to use a subdomain that you will only use on that particular part of the internal network. This creates a logical separation from all the hosts that are globally accessible and resolvable via public DNS. Your website could be www.mydomain.com
, or blog.mydomain.com
, while the web GUI's like Pi-holes
' would become pihole.local.mydomain.com
. My advise therefore, is that you do not use pihole.yourdomain.com
, even if you have no other globally accessible hosts, but always use a subdomain below your public domain for each of your local, internal networks.
So, what constitutes a local, internal domain
or internal network
? It's basically a network that is not meant to be globally accessible. It would typically run on private address space, or on a segmented (IPv6) range from a privately owned larger (IPv6) subnet. These networks typically live behind routers and firewalls, and in some corporations live airgapped (meaning completely isolated, sometimes even physically disconnected from the outside world).
You should really think about this and what you want use for your internal subdomain. For a typical home setup, this could simply be lan
, local
, internal
, or just int
. It would then become local.mydomain.com
(or anything you like, really). And if you run vlans
for homelabs or whatever, you would want to create a subdomain for each one of them. So, if you hadn't thought about this before, now would be a good moment to do so.
Preparation
Make sure you are fully up to date. Check the output of each command and determine whether its safe to resume, and whether it requires a reboot.
Update and upgrade Raspian:
sudo apt update
sudo apt upgrade
sudo reboot
Once more with a dist-upgrade. This might have some impact if you haven't updated in a while. make sure you check the output and know what you are doing:
sudo apt update
sudo apt dist-upgrade
sudo reboot
Clean up old stuff. Again, check whether reboot is required, may not be the case:
sudo apt autoremove --purge
sudo reboot
Finally, make sure Pi-hole is up to date as well:
sudo pihole -up
Install the required packages. You need to change the python3-certbot-dns-ovh
package for your specific provider (unless of course you actually use OVH as well):
sudo apt install nftables certbot python3-certbot-dns-ovh
nftables Setup
CRITICAL WARNING: PLEASE READ THIS TWICE AND MAKE SURE YOU UNDERSTAND IT BEFORE YOU PROCEED
Nftables
is a local firewall that might prevent you from:
- logging in remotely via SSH
- accessing the web interface
- using
Pi-hole
as your DNS server - or it might prevent
Pi-hole
from accessing the internet and its configured upstream DNS servers
If you do not have a local keyboard and monitor physically connected to the hardware running Pi-hole
(the Raspberry Pi in this guide), then you might want to skip this part unless you know how to recover.
Proceed only if you are comfortable with Linux, firewalls in general and nftables in particular, and you are confident that you can recover without assistance when things go sideways.
Having said all that mandatory stuff, I tested it, use it myself so if you haven't done anything weird on your setup, not much can go wrong (famous last words haha :P).
1. Create nftables Configuration
sudo vim /etc/nftables.conf
Add the following content:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
# Allow established/related connections
ct state established,related accept
# Allow loopback
iifname lo accept
# Allow ICMP and ICMPv6
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# Allow SSH (port 22)
tcp dport 22 accept
# Allow HTTP/HTTPS (ports 80, 443)
tcp dport { 80, 443 } accept
# Allow DNS (port 53 UDP and TCP)
udp dport 53 accept
tcp dport 53 accept
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
So what this does when activated, is:
- Create a single table for both IPv4 and IPv6
- Set the default policy to DROP for incoming connections
- Allow established and related connections
- Allow loopback interface traffic
- Allow ICMP and ICMPv6 traffic (for ping)
- Allow the specific ports you requested (22, 80, 443 TCP and 53 UDP)
- Drop all other incoming traffic
- Allow all outgoing traffic
Basically, this provides a basic but secure setup. Best advise I can give you if you need to change it, is to start with this and make your customisations to nftables later.
In case you need DHCPv4 from Pi-hole as well, you can use the following config:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
# Allow established/related connections
ct state established,related accept
# Allow loopback
iifname lo accept
# Allow ICMP and ICMPv6
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# Allow DHCP (ports 67, 68)
udp dport 67 accept
udp dport 68 accept
udp sport 67 accept
udp sport 68 accept
# Allow SSH (port 22)
tcp dport 22 accept
# Allow HTTP/HTTPS (ports 80, 443)
tcp dport { 80, 443 } accept
# Allow DNS (port 53 UDP and TCP)
udp dport 53 accept
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
2. Test the Configuration
sudo nft -c -f /etc/nftables.conf; echo $?
This will test the file and print 0
if there was no problem. It will print other stuff if there was a problem and the number will not be 0
. Fix it before you continue.
Do NOT enable and start nftables
just yet.
3. Apply Rules with Failsafe Method
Let's try and not accidentally lock ourselves out. This approach applies the rules but automatically reverts after 4 minutes if you get locked out.
Let me first explain how we will do this and how it works. We will construct a oneliner that will:
- Apply our settings
- Use
;
as command separator. If it fails, we want the oneliner to continue just the same - Sleep for 240 seconds (4 minutes)
- Use
&&
that will validate the return code of the previous command and continues with the next part only when that previous command succeeded (rc == 0). If it failed, the oneliner aborts and does not proceed with any of the following parts nft flush ruleset
will drop (flush) all rules and leave the system effectively without a firewall. If nftables wasn't installed and active before the guide, this will have no effect at all- Use
&&
again and functions the same the previous one nft -f /etc/nftables.backup)
will read a backup file from the default location and activate it. This will only exist if nftables was previously used and active, has an config and made a backup. If the file doesn't exist the command will simply fail and you will still be left with the flushed state (no active rules and no firewall)- Use brackets
()
to enclose the oneliner and to ensure the next bullet applies to the entire line and not just the last part - use
&
at the end, this will fork the entire command chain to the background and return the prompt. Mind you its still running in the background, (asleep for 4 minutes) and will execute the commands after the 4 minutes have passed backup_pid=$!
will set a variable calledbackup_pid
which contains the PID of the previous background task. You can verify this withecho $backup_pid
. Its important this is executed immmediately after the oneliner. Don't runps
,ls
or something else.
Now, for the oneliner:
(nft -f /etc/nftables.conf; sleep 240 && nft flush ruleset && nft -f /etc/nftables.backup) &
This is important:
If it doesn't hang (it really shouldn't), enter the following before you do anything else. Really, don't do anything else and run this first:
`backup_pid=$!
Thank you.
If your terminal hangs and you can't enter the previous command, it means
nftables
killed your connection. I am not a psychic so I don't know why that happened. Wait 4 minutes and it should automatically restore and allow you to login again. If, for whatever weird reason it also doesn't restore properly, you can try to reboot your device by pressing ctrl-alt-del
on the keyboard that is connected to the Pi. You could try a power cycle only as a last resort. You should be able to get back in after that. If not, I am sorry! Good luck getting it fixed ;-).
Oke, that wasn't nice. Some more help then:
- Login on the physical terminal (that is, the keyboard and monitor connected to the Pi)
- Type:
sudo nft flush ruleset
- Check
sudo journalctl
- Restart SSH:
sudo systemctl restart sshd
If all fails, just reboot it. I hope it comes back. Ask help if it doesn't (again, sorry!).
4. Test Connectivity
Assuming all went well, then from here on forward, you will have 4 minutes minus the time you took to read up until now, to check wether your Pi-hole is still works. If you haven't ran it yet run it now. If it timed out already you need to read faster :P, simply run it again.
From a different terminal or device, test:
- SSH access
- Web interface (HTTP/HTTPS)
- DNS resolution from the rPI to upstream DNS servers
- DNS resolution from the network to Pi-hole
If everything works, stop the failsafe:
kill $backup_pid
If it timed out before you could finish the tests, run the oneliner again and finish the tests. If it timed out after you finished the tests but were just too late killing it, you can simply run the first command from the oneliner:
nft -f /etc/nftables.conf
Logoff root, hit ctrl-d
or type exit
.
5. Making nftables Permanent
Enable and start nftables
service:
sudo systemctl enable --now nftables
Check the rules are in place and active:
sudo nft list ruleset
Finally, let's finish up before continuing with Certbot
:
sudo nft -s list ruleset > /etc/nftables.conf
sudo chmod +x /etc/nftables.conf
Consider rebooting to ensure nftables
starts correctly at boot time.
Certbot and Let's Encrypt Setup
The pihole
user does not (at least not on my install) have a homedir. We will create it and use it to run certbot unprivileged and let it store its stuff there. If your installation has the homedir created, be careful with the commands so as not to inadvertently change permissions to existing files and directories.
1. Create Required Directories
sudo mkdir -p /home/pihole/{.secrets,.config/letsencrypt}
sudo chown -R pihole:pihole /home/pihole
sudo chmod -R 0700 /home/pihole
If the pihole home directory already exists, use something along the lines of:
sudo -u pihole bash
cd # short for cd ~
mkdir -p {.secrets,.config/letsencrypt}
chmod -R 0700 {.secrets,.config/letsencrypt} # be careful if some of these exist
2. Create API Credentials File
Switch to the pihole user, if not done so already:
sudo -u pihole bash
cd
Create the credentials file (this example uses OVH - You really shoudn't copy paste this and adapt for your provider):
vim ~/.secrets/ovh.ini
Example content:
# Certbot OVH API config
dns_ovh_endpoint = ovh-us
dns_ovh_application_key = YOUR_APPLICATION_KEY
dns_ovh_application_secret = YOUR_APPLICATION_SECRET
dns_ovh_consumer_key = YOUR_CONSUMER_KEY
Set secure permissions:
chmod 0600 ~/.secrets/ovh.ini
3. Request Initial Certificate
Again, don't just copy/paste and adapt the -d domain.stuff
and the --email
.
certbot certonly \
--dns-ovh \
--dns-ovh-credentials /home/pihole/.secrets/ovh.ini \
-d pihole.your_internal_domain.org \
--config-dir /home/pihole/.config/letsencrypt \
--work-dir /home/pihole/.config/letsencrypt \
--logs-dir /home/pihole/.config/letsencrypt \
--email your_email@example.com \
--agree-tos \
--no-eff-email
4. Disable Default Certbot Services
The section below might not be entirely accurate. I accidentally ran certbot
as root. If you run into issues please reply and we'll fix it. I don't really know what certbot would do when run unprivileged and at this point, I spend most of this day on this and now I just want to get it done .
The default certbot timer and service should be disabled since we'll create our own:
exit # Exit from pihole user if you're still in that shell
sudo systemctl disable --now certbot.timer
sudo systemctl disable --now certbot.service
It may not have created that since we ran certbot
as pihole
, unprivileged. It may have created a user instance of the service and timer. Let's check and remove those.
If they exist we need to delete them as user services only run when a user logs on an interactive session. They don't start when the pihole-FTL
service starts (even as the user pihole), as that doesn't login like you would, as a user.
sudo -u pihole bash
systemctl --user status certbot.timer
systemctl --user status certbot.service
Look for the line that shows the file ending in .timer
and .service
and delete them from the pihole homedir. Then run:
Exit the pihole
user session.
sudo systemctl daemon-reload
To be clear, only delete the user service files, not the system service files.
Automated Certificate Renewal
Note: These files need to have the same base filenames. They are a pair. Make sure this is the case or the timer
won't work.
1. Create Systemd Service
I'm not a systemd fan or anything, but one of the nice things about systemd services is that whatever a script or command outputs, ends up in journal logging under the unit service name. Contrary to cron jobs where you need to script your loging and provide locations and permissions yourself. After setting this up, you can use commands like:
sudo systemctl status certbot-pihole
sudo journalctl -u certbot-pihole
You would do well to read up about those commands if you don't know how they work.
sudo vim /etc/systemd/system/certbot-pihole.service
Content:
[Unit]
Description=Certbot renewal for pihole user
Documentation=https://certbot.eff.org/docs
[Service]
Type=oneshot
User=pihole
ExecStart=/usr/bin/certbot -q renew --dns-ovh --dns-ovh-credentials /home/pihole/.secrets/ovh.ini --config-dir /home/pihole/.config/letsencrypt --work-dir /home/pihole/.config/letsencrypt --logs-dir /home/pihole/.config/letsencrypt --agree-tos --no-eff-email --post-hook "/home/pihole/scripts/copy-certs.sh"
PrivateTmp=true
Remember to modify the DNS provider options to match your setup.
2. Create Systemd Timer
sudo vim /etc/systemd/system/certbot-pihole.timer
Content:
[Unit]
Description=Run certbot renewal for pihole user twice daily
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=43200
Persistent=true
[Install]
WantedBy=timers.target
The OnCalendar
is arbitrary. This is what Certbot uses by default. I suppose running at midnight only should suffice. But yeah, you decide for yourself and change it to suit your needs.
Certificate Deployment Script
Before we activate, we need to create the posthook script and we need to grant the user pihole
the permissions to restart pihole-FTL.service
. We will do this first with a sudoers rule.
1. Grant Permissions to Restart Pi-hole
sudo vim /etc/sudoers.d/pihole
Content:
pihole ALL=(ALL) NOPASSWD: /bin/systemctl restart pihole-FTL.service
2. Create Certificate Deployment Script
sudo -u pihole bash
mkdir -p ~/scripts
vim ~/scripts/copy-certs.sh
Content (adjust the CERT_SOURCE
path to match your domain):
#!/bin/bash
# This script will run after Certbot has successfully renewed Let's Encrypt certificates.
# It will copy the cert and private key to /etc/pihole directory
# It will fix permissions
# Set variables
CERT_SOURCE="/home/pihole/.config/letsencrypt/live/pihole.your_internal_domain.org"
CERT_DEST="/etc/pihole"
TARGET_FILE="tls.pem"
# Ensure source certificates exist
if [ ! -f "$CERT_SOURCE/cert.pem" ] || [ ! -f "$CERT_SOURCE/privkey.pem" ]; then
echo "Error: Source certificates were not found in $CERT_SOURCE"
exit 1
fi
# Backup the original file if it exists and a backup not already exists
if [ -f "$CERT_DEST/$TARGET_FILE" ] && [ ! -f "$CERT_DEST/${TARGET_FILE}-org" ]; then
echo "Backing up original certificate..."
mv "$CERT_DEST/$TARGET_FILE" "$CERT_DEST/${TARGET_FILE}-org"
fi
# Create the combined certificate just as Pi-hole likes it
echo "Creating combined certificate..."
cp "$CERT_SOURCE/cert.pem" "$CERT_DEST/$TARGET_FILE"
cat "$CERT_SOURCE/privkey.pem" >> "$CERT_DEST/$TARGET_FILE"
# Set proper owner & permissions
echo "Setting permissions..."
chown pihole:pihole "$CERT_DEST/$TARGET_FILE"
chmod 0700 "$CERT_DEST/$TARGET_FILE"
# Restart Pi-hole service
echo "Restarting Pi-hole service..."
sudo systemctl restart pihole-FTL.service
echo "Certificate update completed successfully"
exit 0
Make it executable:
chmod +x ~/scripts/copy-certs.sh
exit
3. Enable the Timer
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-pihole.timer
Verification
1. Test the Certificate Deployment Script
sudo -u pihole /home/pihole/scripts/copy-certs.sh
2. Test Certificate Renewal (Dry Run)
This should be the same as line as in the service file, but with --dry-run
added to it:
sudo -u pihole certbot renew --dry-run \
--dns-ovh --dns-ovh-credentials /home/pihole/.secrets/ovh.ini \
--config-dir /home/pihole/.config/letsencrypt \
--work-dir /home/pihole/.config/letsencrypt \
--logs-dir /home/pihole/.config/letsencrypt
3. Verify Certificate in Pi-hole Admin Console
- Access your Pi-hole admin console via HTTPS
- Check that the certificate is valid (no browser warnings)
- Verify the certificate details by clicking the lock icon in your browser
4. Verify Timer is Active
systemctl list-timers | grep certbot-pihole
Troubleshooting
Certificate Issues
- Check certbot logs:
sudo -u pihole cat /home/pihole/.config/letsencrypt/letsencrypt.log
- Verify API credentials are correct
- Ensure DNS provider supports DNS-01 challenges
- Verify certificate exists and has correct permissions:
ls -la /etc/pihole/tls.pem
- Check the contents of the cert file:
sudo -u pihole cat /etc/pihole/tls.pem
. This file should a combination of the.config/letsencrypt/live/pihole.your_internal_domain.org/cert.pem
and.config/letsencrypt/live/pihole.your_internal_domain.org/privkey.pem
. - Check service:
sudo systemctl status certbot-pihole
- Check service logging:
sudo journalctl -u certbot-pihole
nftables Issues
- If you've lost access: connect a keyboard/monitor and run
sudo nft flush ruleset
- Check rules:
sudo nft list ruleset
- Disable if you can't make it work:
sudo systemctl disable --now nftables.service
and ask for help.
Pi-hole Web Interface Issues
- Check Pi-hole logs:
sudo journalctl -u pihole-FTL.service
- Check Pi-hole webservice log:
sudo -u pihole cat /var/log/pihole/webserver.log
I hope this guide helps you secure your Pi-hole installation. If you have questions or suggestions for improvement or want to say thanks, please leave a message!