Pi-hole configuration as code.
ConfigHole is a small tool for managing one or more Pi-hole instances from a single YAML file. It is aimed at homelabs and self-hosted setups where you want your DNS configuration to be repeatable, reviewable, and easy to keep in sync.
It is built on top of another project of mine, pihole-lib, a Python library that talks to the Pi-hole API.
You can use this tool as a one-off to bootstrap an initial configuration, or run it as a daemon that periodically checks for changes in your local config.
It’s one of those tools where the time spent building it far exceeds the time you’ll ever spend manually tweaking settings - see https://xkcd.com/1319.
- Disclaimer
- Supported Pi-hole Versions
- Features
- Installation
- Usage
- Daemon Mode
- Configuration
- Development
- Contributing
- Author
- License
This project is not affiliated with, endorsed by, or supported by Pi-hole LLC. Pi-hole is a trademark of Pi-hole LLC.
ConfigHole was written for my own homelab to keep Pi-hole configuration under version control and in sync (I use Flux to manage my Kubernetes clusters). It is a personal tool that escaped into a public repository, and it is built for homelabs rather than production environments.
It works for me, but it may not work for you. There are no warranties, no guarantees, and no promises that it will not break your DNS at an inconvenient moment.
Read the code, test your changes, and use dry-run mode before trusting it with anything important.
Designed and tested against Pi-hole v6.0 and newer.
- Manage Pi-hole configuration declaratively using YAML
- Keep multiple Pi-hole instances in sync
- Manage blocklists and allowlists
- Manage domains (exact and regex, allow and deny)
- Manage groups
- Manage clients
- Optional automatic gravity update when lists change
- See exactly what will change before applying it
- Dry-run mode so you can test without touching anything
- Optional daemon mode for periodic reconciliation
Note
Example docker-compose file can be found at examples/docker-compose.yaml:
# One-time sync
$ docker run --rm \
-v $(pwd)/config:/config:ro \
-e PIHOLE_PASSWORD="$PIHOLE_PASSWORD" \
ghcr.io/dsgnr/confighole:latest -c config/config.yaml --sync
# Run daemon
$ docker run -d --name confighole-daemon \
--restart unless-stopped \
-v $(pwd)/config:/config:ro \
-e CONFIGHOLE_DAEMON_MODE=true \
-e CONFIGHOLE_CONFIG_PATH=/config/config.yaml \
-e CONFIGHOLE_DAEMON_INTERVAL=300 \
-e PIHOLE_PASSWORD="$PIHOLE_PASSWORD" \
ghcr.io/dsgnr/confighole:latestNote
Uses Python 3.13. The Python environment uses Poetry for package management. This must be installed.
$ git clone https://github.com/dsgnr/confighole.git
$ cd confighole
$ poetry install
$ poetry run confighole --helpCreate a config.yaml - An example can be seen at examples/config.yaml.
# Global settings applied to all instances
global:
timeout: 30
verify_ssl: false
verbosity: 1 # Log verbosity: 0=WARNING, 1=INFO, 2=DEBUG
dry_run: false # Enable dry-run
# Daemon mode settings
daemon_mode: false # Enable daemon mode by default
daemon_interval: 300 # Sync interval in seconds (5 minutes)
instances:
- name: home
base_url: http://192.168.1.100
password: "${PIHOLE_PASSWORD}"
update_gravity: true
config:
dns:
upstreams: ["1.1.1.1", "1.0.0.1"]
queryLogging: true
dnssec: true
hosts:
- ip: 192.168.1.1
host: router.lan
- ip: 192.168.1.10
host: nas.lan
cnameRecords:
- name: plex.lan
target: nas.lanTip
YAML anchors work with the config, so you can define a list of hosts/cnames once, and reference them for all instances. For example:
hosts: &hosts
- ip: 192.168.1.1
host: gateway.lab
- ip: 192.168.1.10
host: nas.lab
lists: &lists
- address: https://github.com/@raw/StevenBlack/hosts/master/hosts
type: block
comment: StevenBlack's Unified Hosts List
groups: [0]
enabled: true
domains: &domains
- domain: ads.example.com
type: deny
kind: exact
comment: Block ads domain
groups: [0]
enabled: true
- domain: ".*\\.tracking\\..*"
type: deny
kind: regex
comment: Block tracking subdomains
groups: [0]
enabled: true
instances:
- name: home
base_url: http://192.168.1.100
password: "${PIHOLE_PASSWORD}"
update_gravity: false
config:
dns:
hosts: *hosts
lists: *lists
domains: *domainsSet your password:
$ export PIHOLE_PASSWORD="your-admin-password"or use the --dump argument to grab an existing config. You'll need to at least have the instance defined with name, base_url and password in your local config in order to connect:
$ docker run --rm \
-v $(pwd)/config:/config:ro \
ghcr.io/dsgnr/confighole:latest -c /config/config.yaml -i homelab --dump
- name: homelab
base_url: http://10.50.1.10
config:
dns:
upstreams:
- 127.0.0.1#5053
CNAMEdeepInspect: true
blockESNI: true
EDNS0ECS: true
ignoreLocalhost: false
hosts:
- ip: 10.50.1.1
host: rtr0
[...]Run commands:
# Dump current Pi-hole state
$ confighole -c config.yaml --dump
# Show what would change
$ confighole -c config.yaml --diff
# Sync with dry-run
$ confighole -c config.yaml --sync --dry-run
# Apply changes
$ confighole -c config.yaml --sync
# Run continuously (every 5 minutes by default)
$ confighole -c config.yaml --daemon0: the operation completed, or there was nothing to change.1: the configuration could not be loaded, the--instancename does not exist, an instance has nobase_urlor password, or--syncfailed for one or more instances. The names of failed instances are logged.
--dump and --diff do not report connection failures through the exit code. A missing base_url or password is reported for every operation, but only for instances that need to connect. An instance with no local configuration is skipped by --diff and --sync. In daemon mode, a failed sync is logged and the daemon keeps running.
Daemon mode is useful if you want your Pi-hole instances to drift as little as possible. It periodically compares the live state with your config and applies any differences.
The config file is read again on every sync cycle. If it is invalid at startup, the daemon exits with code 1. If it becomes invalid while running, the error is logged, that cycle is skipped and the daemon keeps running.
# Default interval (5 minutes)
$ confighole -c config.yaml --daemon
# Custom interval
$ confighole -c config.yaml --daemon --interval 600
# Dry-run daemon (monitor only)
$ confighole -c config.yaml --daemon --dry-run
# Target a single instance
$ confighole -c config.yaml --daemon --instance home --interval 180Configuration Precedence:
- CLI arguments take highest precedence
- Global config settings are used if CLI arguments are not specified
- Default values are used as fallback
Example: If your config sets daemon_interval: 600 but you run with --interval 300, the CLI value wins.
The interval must be at least 1 second. A smaller value is rejected at startup with exit code 1.
Configure daemon mode using environment variables (useful for Docker):
| Variable | Description | Default |
|---|---|---|
CONFIGHOLE_DAEMON_MODE |
Enable daemon mode | false |
CONFIGHOLE_CONFIG_PATH |
Path to config file | Required |
CONFIGHOLE_DAEMON_INTERVAL |
Sync interval in seconds | 300 |
CONFIGHOLE_INSTANCE |
Target instance | All |
CONFIGHOLE_DRY_RUN |
Enable dry-run mode | false |
CONFIGHOLE_VERBOSE |
Log verbosity (0-2) | 1 |
An empty list (for example lists: []) removes every remote entry of that resource. An omitted key leaves the resource unchanged.
Entries in lists, domains, groups and clients must include their identifying keys: address and type for lists, domain, type and kind for domains, name for groups and client for clients. During --sync, a resource with a missing key is reported as an error and that resource is not written. Resources are synchronised one at a time, so other resources can still be applied.
Apply to all instances unless overridden:
timeout- Request timeout in secondsverify_ssl- To enable or disable TLS verificationpassword/password_env- For default authentication
Daemon mode settings:
daemon_mode- Enable daemon mode by default (true/false)daemon_interval- Sync interval in seconds (default:300)verbosity- Log verbosity level (0=WARNING,1=INFO,2=DEBUG)dry_run- Enable dry-run mode by default (true/false)
Per-instance configuration:
name- Instance identifierbase_url- Pi-hole web interface URLpassword- Admin password (supports${ENV_VAR})update_gravity- Automatically update gravity when lists change (true/false, default:false)config- Pi-hole configuration to managelists- Pi-hole lists to managedomains- Pi-hole domains to manage (exact/regex, allow/deny)groups- Pi-hole groups to manageclients- Pi-hole clients to manage
The subscribed allowlist or blocklists:
lists:
- address: https://github.com/@raw/StevenBlack/hosts/master/hosts
type: block
comment: StevenBlack's Unified Hosts List
groups: [0]
enabled: trueDomains support both exact matches and regex patterns, for both allow and deny lists:
domains:
# Exact domain to block
- domain: ads.example.com
type: deny # deny or allow
kind: exact # exact or regex
comment: "Block ads"
groups: [0]
enabled: true
# Regex pattern to allow
- domain: ".*\\.trusted\\.com"
type: allow
kind: regex
comment: "Allow trusted subdomains"
groups: [0]
enabled: trueGroups allow you to organise clients and apply different filtering rules:
groups:
- name: family
comment: "Family devices"
enabled: true
- name: iot
comment: "IoT devices with strict filtering"
enabled: trueThe default group (id 0) is never removed by a sync, even when it is missing from groups.
Clients can be identified by IP address, MAC address, hostname, subnet (CIDR), or interface:
clients:
- client: "192.168.1.50"
comment: "John's laptop"
groups: [0, 1]
- client: "12:34:56:78:9A:BC"
comment: "Smart TV"
groups: [0]
- client: "192.168.2.0/24"
comment: "Guest network"
groups: [2]# Setup
$ poetry install
# Code quality
$ make lint # ruff linting
$ make format # ruff formatting
$ make type-check # mypy type checking
# All checks
$ make checkConfigHole includes a comprehensive test suite with both unit and integration tests:
# Run all tests
$ make test
# Run only unit tests (fast, no Pi-hole required)
$ make test-unit
# Run only integration tests (requires Pi-hole container)
$ make test-integration
# Run tests with coverage report
$ make test-coverage
# Start Pi-hole test container
$ make test-docker
# Stop Pi-hole test container
$ make test-docker-down
# Stop and clean up Pi-hole test container (removes volumes)
$ make test-docker-cleanIntegration tests spin up a real Pi-hole container from pi-hole/docker-pi-hole to verify functionality. The test suite automatically manages the container lifecycle and waits for Pi-hole to be ready before running tests.
The test suite automatically cleans up the Pi-hole container after tests complete. If tests are interrupted, you can manually clean up with make test-docker-clean.
I'm thrilled that you’re interested in contributing to this project! Here’s how you can get involved:
-
Submit Issues:
- If you encounter any bugs or have suggestions for improvements, please submit an issue on our GitHub Issues page.
- Provide as much detail as possible, including steps to reproduce and screenshots if applicable.
-
Propose Features:
- Have a great idea for a new feature? Open a feature request issue in the same GitHub Issues page.
- Describe the feature in detail and explain how it will benefit the project.
-
Submit Pull Requests:
- Fork the repository and create a new branch for your changes.
- Make your modifications and test thoroughly.
- Open a pull request against the
develbranch of the original repository. Include a clear description of your changes and any relevant context.
- Website: https://danielhand.io
- Github: @dsgnr
See the LICENSE file for more details on terms and conditions.