NextDNS Profile Union
Free & open source GPL-3.0 No API keys in the repo

Sync up to 20
NextDNS profiles
— automatically.

Add a domain to one profile's allowlist or denylist and every other profile gets it on the next run. Allowlist + denylist only, add-only, no duplicates — powered by a GitHub Action and configured entirely with GitHub Variables.

View source on GitHub

Free · open source · GPL-3.0

Profiles
20
Runs
12 h
Writes lost
0
live example every 12 h
PROFILE 1 ads.example ∪ PROFILE 2 ✓ added PROFILE 3 ✓ added PROFILE 20 ✓ added 1 domain · 1 write each
allowlist union 22 · added 0
denylist union 125 · added 1
Why it is safe to leave running

Six things that never go wrong

The tool is deliberately narrow: two lists, one direction, no deletions. That is what makes an unattended sync trustworthy.

True union

A domain counts as active when it is active in any profile. Everything else is added switched-off instead of being dropped.

Add-only

Nothing is ever deleted and no toggle is flipped off. Even a bulk write resends the profile's existing entries.

No duplicates, ever

sync-state.json remembers what was synced where, so the same domain is never written twice.

Respects your deletes

Delete a domain by hand and it stays deleted — the state DB treats its absence as "the user removed it".

Bulk instead of spam

20+ missing items go out in a single PUT: 125 domains take ~2 seconds instead of 21 minutes. Small batches get a 10 s cooldown.

No credentials in the repo

Keys, profile ids and the bot token live in GitHub Variables. That is why this repository can be public.

Under the hood

How it works

One run is a straight line: read everything, build the union, fill in the gaps, remember what happened.

  1. 1 · Trigger

    cron 0 */12 * * *

    GitHub Actions runs twice a day, or on demand from the Actions tab with dry_run / full inputs.

  2. 2 · Read

    GET

    Both lists are fetched from every configured profile, following pagination cursors, and lowercased / de-duplicated.

  3. 3 · Union

    allow ≠ deny

    Domains are combined per list. A domain in the allowlist never leaks into the denylist (and vice versa) — a test enforces it.

  4. 4 · Diff

    state aware

    For each profile: todo = union − has − state. Anything the state DB already knows about is skipped, which is how manual deletes survive.

  5. 5 · Write

    PUT ≥ 20 · POST + 10 s

    Big batches are a single verified PUT; small batches are individual POSTs with a cooldown. Failures fall back to POST one by one.

  6. 6 · Remember & report

    📩 Telegram

    The state DB is committed back to the repo and one Telegram message summarises what happened — including a crash, if there was one.

What is synced

Synced

  • ✓ allowlist
  • ✓ denylist

Never touched

  • × settings, security, privacy
  • × blocklists, parental control

Syncing settings between profiles causes surprises (one wants logging, another must not). Restricting to two domain lists is what makes an unattended run safe.

Setup

Running in about 10 minutes

Seven steps. Nothing is installed locally and there are no secrets to paste into code.

1

Create a repository

Public if you are publishing the template, private for your own copy (the state DB holds your domains).

2

Collect the profile id and API key

my.nextdns.io/ab12cd/settings ← profile id
my.nextdns.io/account ← API key (bottom of page)
3

Add the variables

Settings → Secrets and variables → Actions → Variables

NEXTDNS_API_KEY1 = 0123456789abcdef0123456789abcdef01234567 NEXTDNS_PROFILE_ID1 = ab12cd NEXTDNS_LABEL1 = Home # optional

Use the Variables tab, not Secrets — that is what the workflow reads.

4

Add the files

union.py sync-state.json {"version":1,"profiles":{}} .github/workflows/union-sync.yml ⚠ hidden folder tests/test_union.py # optional

GitHub only reads workflows from .github/workflows/ — the folder name starts with a dot, which many tools hide. See the install guide →

5

Allow the workflow to commit

Settings → Actions → General → Workflow permissions → Read and write.

Without it sync-state.json is never committed and every run re-adds the same domains.

6

Dry-run first

Actions → NextDNS profile union → Run workflow with dry_run = true. Nothing is written; you only see what would happen.

7

Apply

Repeat with dry_run = false. Done — it now runs itself every 12 hours.

Configuration

Variables

Everything is configured in the repository settings — the code contains no credentials.

Variable Required Example
NEXTDNS_API_KEY1…20 yes 0123456789abcdef…
NEXTDNS_PROFILE_ID1…20 yes ab12cd
NEXTDNS_LABEL1…20 no Home
TG_TOKEN no 1234567890:AAH…
TG_CHAT no 123456789
TG_ON no true

Tuning (optional)

SYNC_DELAY=10 SYNC_READ_DELAY=0.5 SYNC_BACKFILL=true SYNC_BULK_THRESHOLD=20 SYNC_RETRIES=4 SYNC_TIMEOUT=30 SYNC_APPLY=true SYNC_STATE_PATH=sync-state.json

The defaults are fine — you never have to set these.

Example: four profiles

NEXTDNS_API_KEY1 = 0123456789abcdef… NEXTDNS_PROFILE_ID1 = ab12cd NEXTDNS_LABEL1 = Home NEXTDNS_API_KEY2 = 89abcdef012345… NEXTDNS_PROFILE_ID2 = ef34gh NEXTDNS_LABEL2 = Phone

Numbers may skip: 1, 2, 7 works.

Local use

Commands

Optional — Actions does everything. Handy for checking variables or debugging.

read-only
terminalwrites nothing
$ python3 union.py --check # validate variables $ python3 union.py --list # prove the keys work $ python3 union.py --dry-run # preview the changes $ python3 tests/test_union.py # 25 offline tests
writes
terminalchanges your profiles
$ python3 union.py # apply the union $ python3 union.py --full # emergency: ignore state $ python3 union.py --no-notify # skip Telegram

--full re-adds domains you deleted on purpose. Preview it with --dry-run --full first.

Sample output

>>> python3 union.py --dry-run nextdns profile union - 4/20 profiles - lists=allowlist,denylist - DRY-RUN - delay=10s === allowlist === read Home 22 entries read Phone 12 entries union: 22 unique domains Home up to date Phone +10 to add: catbox.moe, playmods.net, s1-filecr.xyz ... === denylist === union: 125 unique domains Phone +1 to add: x.com === summary === allowlist union=22 added=0 denylist union=125 added=1 DRY-RUN finished (nothing written)
Questions

FAQ

The short answers. The full 32-question version is in the repo.

Can the repository be public?

Yes — no credentials are committed. But sync-state.json lists your domains, so make your own fork private.

Are settings, security or blocklists synced?

No — deliberately. Only the allowlist and the denylist are touched.

If I delete a domain, is it removed everywhere?

No. This is a union, not a mirror — nothing is ever deleted. Your manual deletes are remembered and never re-added.

When should I use full = true?

Only to bring back a domain you deleted on purpose, or after the state DB broke. Preview with dry_run = true + full = true first.

Why does a run take a few minutes?

Each POST waits 10 seconds so NextDNS never rate-limits you. Batches of 20+ use one bulk PUT and finish in seconds.

Do I need the NextDNS app or a DoH proxy?

No. The tool talks to the NextDNS REST API only — standard library Python, nothing to install.

Documentation

Everything, A to Z

Fourteen focused pages instead of one giant one — so you can find the exact thing you need.

Feedback

Tell me what broke

A question, a bug, or a profile setup that refuses to work — send it here. The form is protected by Cloudflare Turnstile, so no account and no email address is required.

  • ✓ No email needed — the message is delivered straight to Telegram
  • ✓ Never include your API key in a message
  • ✓ For code changes, a GitHub issue is better
Never send credentials. API keys, profile ids and bot tokens are not needed to describe a problem, and they should never leave your own repository settings.

0/1200

Protected by Cloudflare Turnstile. The token is verified server-side by a Cloudflare Pages Function; the secret key never reaches the browser.