SyncBlock: Centrally Managing Pi-hole Blocklists Across Multiple Instances

Overview

If you run more than one Pi-hole on your network, you’ve probably faced the same frustration: keeping blocklists in sync across all of them is tedious, error-prone, and easy to forget. Each Pi-hole ends up with a slightly different set of lists, maintained separately, with no version history and no easy way to roll back changes.

SyncBlock solves this by treating your blocklist configuration the same way a developer treats code — with version control, a central repository, and automated deployment.

The idea is simple: one authoritative list of blocklist sources lives in a private Git repository on Codeberg. Each Pi-hole runs a script on a daily cron schedule that fetches that list and synchronises its own Pi-hole database accordingly. Change the list once, and all your Pi-holes pick it up automatically.


The Architecture

The setup consists of two private Codeberg repositories and a sync script deployed to each Pi-hole.

pihole-lists is the repository containing youShallNotPass.txt — a plain text file with one blocklist URL per line. This is the single source of truth for what gets blocked across your entire network. You manage it from your Windows PC using TortoiseGit, committing changes and pushing them to Codeberg.

SyncBlock is the repository containing the tooling — the sync script, installer, and configuration template. You clone this onto each Pi-hole and run the installer once.

The flow looks like this:

  1. You edit youShallNotPass.txt on your PC and push to Codeberg
  2. Each Pi-hole’s cron job runs sync-adlists.sh at 04:17 AM
  3. The script fetches youShallNotPass.txt using a Personal Access Token
  4. It updates Pi-hole’s gravity.db database with the new set of adlists
  5. It runs pihole -g to rebuild gravity
  6. If anything goes wrong, it automatically restores from a backup

Key Design Decisions

Private repositories. Both repos are kept private. Knowing what a system blocks — and what it doesn’t — is useful information for an attacker. There’s no good reason to make that public.

Token-based authentication. The sync script authenticates to Codeberg using a Personal Access Token stored in /etc/syncblock/codeberg.token, readable only by root. The token is never stored in the repository itself — it’s created manually on each Pi-hole and installed securely by the installer.

Non-destructive database updates. Rather than wiping Pi-hole’s adlist table and starting fresh, SyncBlock only manages entries it created — identified by the comment tag managed by repo. Manually added adlists are left completely untouched.

Transactional SQL. All database changes happen inside a single transaction. If anything fails mid-way, the database is left in its original state. A full backup is also taken before every sync, with automatic restore on failure.

Atomic file installation. Every file the installer places on the system is written to a temporary file first and then atomically renamed into place. There is no window where a partial or corrupted file could exist at the target path.


The Sync Script

The heart of SyncBlock is sync-adlists.sh. Here’s what it does in order:

1. Security checks. Before doing anything, it verifies that the config file and token file are owned by root, not writable by others, and not symlinks. This prevents privilege escalation attacks.

2. Fetch the adlist file. It downloads youShallNotPass.txt from your private Codeberg repo using curl with your token:

bash

curl --fail --silent --show-error \
    --proto '=https' \
    --tlsv1.2 \
    --header "Authorization: token ${CODEBERG_TOKEN}" \
    --output "$tmp_raw" \
    "$ADLISTS_URL"

Note the --proto '=https' --tlsv1.2 flags — this enforces HTTPS and a minimum TLS version, preventing the script from being tricked into fetching over an insecure connection.

3. Validate every URL. Each line is checked to be a valid http:// or https:// URL before it gets anywhere near the database. Blank lines and comments are ignored.

4. Build and execute a SQL plan. Rather than inserting URLs one by one, SyncBlock builds a complete SQL transaction using a temporary table:

sql

BEGIN IMMEDIATE TRANSACTION;
CREATE TEMP TABLE desired_adlists(address TEXT PRIMARY KEY);
-- insert desired URLs into temp table
DELETE FROM adlist WHERE comment = 'managed by repo'
    AND address NOT IN (SELECT address FROM desired_adlists);
INSERT INTO adlist(address, enabled, comment)
    SELECT address, 1, 'managed by repo' FROM desired_adlists
    WHERE NOT EXISTS (SELECT 1 FROM adlist WHERE address = desired_adlists.address);
COMMIT;
```

This means only genuinely new URLs are inserted, only removed URLs are deleted, and everything else is left alone — including any adlists you've added manually outside of SyncBlock.

**5. Run `pihole -g`.** After the database is updated, Pi-hole's gravity rebuild is triggered to fetch the actual blocklists and rebuild the DNS blocking database.

**6. Log a summary.** Every run logs a line like:
```
Summary: before=11 after=12 added=1 removed=0 unchanged=11

This makes it easy to see at a glance what changed and when.


The Installer

install.sh is run once per Pi-hole. It:

  • Resolves all binary paths dynamically using command -v — avoiding hardcoded paths that differ between Raspberry Pi OS versions
  • Fixes source file permissions before copying — files created on Windows often arrive with group-write bits set, which the security checks would correctly reject
  • Installs the sync script to /usr/local/bin/ with mode 700
  • Creates /etc/syncblock/ and installs config and token with appropriate permissions
  • Runs a dry-run before installing the cron job — if the dry-run fails, the cron job is not installed and the reason is clearly reported
  • Installs the cron job to /etc/cron.d/pihole-adlist-sync at 04:17 AM daily

A --force-config flag allows replacing an existing config and token on reinstall, while the default behaviour preserves them.


Installation Walkthrough

Prerequisites

On each Pi-hole, ensure the following are available:

bash

sudo apt install curl sqlite3 git

Set up an SSH key

Each Pi-hole needs its own SSH key to clone from Codeberg:

bash

ssh-keygen -t ed25519 -C "pihole-hostname" -f ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub

Add the public key to Codeberg → Settings → SSH / GPG Keys.

Then configure SSH to always use it:

bash

vi ~/.ssh/config
```
```
Host codeberg.org
    IdentityFile ~/.ssh/id_ed25519
    User git

Test:

bash

ssh -T git@codeberg.org

Create a Personal Access Token

In Codeberg → Settings → Applications → Access Tokens, create a token with repository read permission only. Copy it — you’ll need it in a moment.

Clone and install

bash

cd /tmp
git clone ssh://git@codeberg.org/<yourname>/SyncBlock.git
cd SyncBlock
echo 'your-token-here' > codeberg.token
chmod 400 codeberg.token
vi sync-adlists.conf   # replace <yourname> with your Codeberg username
sudo ./install.sh

Run a live sync

bash

sudo /usr/local/bin/sync-adlists.sh

Clean up

bash

cd /tmp && rm -rf SyncBlock

Repeat on each Pi-hole. The token is the same for all of them.


Managing Your Blocklists

All blocklist management happens in youShallNotPass.txt in your pihole-lists repo. Edit it on your Windows PC using TortoiseGit, commit, and push. Your Pi-holes will pick up the changes at their next scheduled sync, or immediately if you trigger a manual run.

A few things to keep in mind:

  • All URLs must point to raw file content, not browser view pages. On Codeberg the raw URL format is https://codeberg.org/<user>/<repo>/raw/branch/main/<filepath> — note raw not src.
  • Pi-hole fetches each blocklist URL directly during pihole -g and has no way to authenticate to private repositories. All blocklist sources in youShallNotPass.txt must therefore be publicly accessible.
  • You can add comments to youShallNotPass.txt by starting a line with #. These are ignored by the sync script.

Logs and Troubleshooting

All sync activity is logged to /var/log/sync-adlists.log:

bash

tail -f /var/log/sync-adlists.log

Logs are rotated weekly, keeping 4 weeks of history — managed automatically by a logrotate config that the sync script installs and maintains itself.

If a sync fails, Pi-hole’s gravity.db is automatically restored from the most recent backup in /var/backups/pihole-adlist-sync/. Backups are pruned automatically, keeping the 10 most recent by default.

For a non-destructive preview of what a sync would change:

bash

sudo /usr/local/bin/sync-adlists.sh --dry-run

The Repository

The full source code for SyncBlock — including sync-adlists.sh, install.sh, and sync-adlists.conf — is available on Codeberg. The repository is currently private, but feel free to reach out if you have questions or want to discuss the implementation.


This article is part of my homelab documentation series.