DEV Community

ahmet gedik
ahmet gedik

Posted on

Configuring NextDNS at Router Level for Ad-Free Multi-Region Video Discovery

The Problem That Pushed Me to Router-Level DNS

I run TopVideoHub, an Asia-Pacific video aggregator that pulls trending content from nine regions — US, GB, JP, KR, TW, SG, VN, TH, HK — every four hours. Our backend is straightforward: PHP 8.4 on LiteSpeed, SQLite with FTS5 and a CJK tokenizer for Japanese, Korean, and Chinese search, Cloudflare in front. What is not straightforward is the network behavior on the client side.

A user in Taipei opens our home page on hotel Wi-Fi. The HTML loads in 180 ms thanks to Cloudflare. Then the YouTube embed thumbnails start resolving. Then the embed iframe pulls in its tracking script, which fans out to fifteen ad and analytics domains. Then the user's ISP injects a tracking pixel via a transparent proxy. Total time to interactive: 4.8 seconds. Worse, the user's mobile data counter ticks up because of pre-roll ad payloads that load even before they click play.

This is not a problem I can fully solve from the server. The third-party junk is downstream of my Cache-Control headers. But I can solve it on my own routers — at home, at the office, and on the colocation rack where I test region-specific behavior using residential IPs in Hong Kong and Tokyo. The way I do it now is NextDNS configured at the router level, with custom profiles per region.

This article walks through exactly how I set that up, what the trade-offs are, and how I tied it back into the aggregator's monitoring so I can detect when an upstream ad domain starts poisoning my analytics.

Why DNS Is the Right Layer

Ad blocking at the browser is easy. uBlock Origin on a single Firefox install does the job. But for a video aggregator developer, that is not enough.

  • I run nine regional test devices (Raspberry Pis, old phones, a Mac mini) that simulate users in different Asia-Pacific markets. I cannot install browser extensions on every one of them, and I do not want to.
  • Smart TVs and streaming sticks pulling thumbnails from our endpoints have no extension support at all.
  • IoT devices on the same Wi-Fi leak DNS queries to ad networks even when nothing is playing, and those queries pollute my logs when I am profiling traffic shapes.
  • I want a single source of truth for what is allowed, applied uniformly across every device on the network.

DNS-level blocking gives me all of this in one place. The router resolves every query, NextDNS decides whether to answer truthfully or with NXDOMAIN, and the result is silent and universal. No client cooperation required. And because NextDNS supports DNS-over-HTTPS and DNS-over-TLS, my ISP can no longer snoop on or rewrite the queries on the wire.

The specific thing NextDNS does better than a self-hosted Pi-hole for my workflow is profiles per location. I keep separate profiles for each test region so I can see, in NextDNS analytics, which tracking domains are most active in Vietnam versus Hong Kong. That data feeds back into how I build the aggregator's referrer policy and Content-Security-Policy headers.

NextDNS Architecture in 60 Seconds

NextDNS is an anycast DNS resolver with profile-based configuration. You sign up, you get a configuration ID like abc123, and you point your DNS clients at https://dns.nextdns.io/abc123 for DoH or abc123.dns.nextdns.io for DoT. Inside the profile, you toggle threat intelligence feeds, ad and tracker blocklists, parental controls, and custom denylists or allowlists.

For video aggregator work, the relevant feeds are:

  • AdGuard's annoyances list (ad-related, not malicious, wide net)
  • Peter Lowe's blocklist (trackers, classic)
  • Disconnect.me's tracking protection
  • A custom denylist where I add specific domains I have caught injecting on Asian mobile carriers

I also use the allowlist heavily. The aggregator pulls preview images from i.ytimg.com and several CDN endpoints. Those must never get blocked, and the default lists occasionally false-positive them when they are aggressive about flagging Google-owned hosts.

Configuring on the Router (OpenWrt Example)

Most consumer routers have a DNS field in their DHCP or LAN settings that takes either plain DNS IPs or, on more capable firmware, a DoH or DoT endpoint. I run OpenWrt on a GL.iNet MT3000 as my main test router because it gives me scripting access. Here is the exact UCI configuration I use.

#!/bin/sh
# /usr/local/sbin/setup-nextdns.sh
# Wire NextDNS DoH into OpenWrt via the official nextdns package.

set -e

PROFILE_ID="${1:?profile id required (e.g. abc123)}"
LOG_LEVEL="${2:-info}"

opkg update
opkg install nextdns ca-bundle

# Stop dnsmasq from doing its own upstream resolution
uci set dhcp.@dnsmasq[0].noresolv='1'
uci -q delete dhcp.@dnsmasq[0].server
uci add_list dhcp.@dnsmasq[0].server='127.0.0.1#5353'
uci commit dhcp

# Configure nextdns to listen on 5353 and forward to our profile
nextdns config set -profile "${PROFILE_ID}" \
    -listen 127.0.0.1:5353 \
    -log-queries=true \
    -hardened-privacy=true \
    -cache-size=10MB \
    -bogus-priv=true \
    -use-hosts=false \
    -report-client-info=true

nextdns install
service nextdns restart
service dnsmasq restart

logger -t nextdns "NextDNS configured for profile ${PROFILE_ID}, log=${LOG_LEVEL}"
echo "NextDNS active. Verify with: nextdns status"
Enter fullscreen mode Exit fullscreen mode

The trick that took me a couple of hours to figure out the first time: dnsmasq must be told noresolv=1 and forced to forward to the local NextDNS listener, not to upstream directly. If you leave dnsmasq with its default servers from DHCP, half your queries leak to your ISP and the blocking is silently inconsistent.

report-client-info=true is important if you want NextDNS to attribute queries to specific devices in the analytics dashboard. It sends the device's hostname along with each query over TLS, which lets me see in the dashboard that my Hong Kong test Pi is generating three times more googleadservices.com queries than the Tokyo one. That is useful signal when the Hong Kong embed code paths and the Tokyo ones are supposed to be the same.

The Allowlist That Matters for Video Aggregators

Default ad blocklists are tuned for general browsing. They occasionally swat down domains that an embed-heavy site genuinely needs. I keep a curated allowlist as part of the profile, edited via the NextDNS API.

#!/usr/bin/env python3
# tools/nextdns_sync.py
# Sync the aggregator's required allowlist into a NextDNS profile.

import os
import sys
import json
from urllib.request import Request, urlopen

API_BASE = "https://api.nextdns.io"
API_KEY = os.environ["NEXTDNS_API_KEY"]
PROFILE = os.environ["NEXTDNS_PROFILE"]

ALLOWLIST = [
    # YouTube thumbnail and embed CDNs
    "i.ytimg.com",
    "i9.ytimg.com",
    "yt3.ggpht.com",
    "yt4.ggpht.com",
    # Our own infra
    "topvideohub.com",
    "cdn.topvideohub.com",
    # Cloudflare insights (we genuinely use it for RUM)
    "static.cloudflareinsights.com",
    # Required for Search Console verification
    "google.com",
    "googletagmanager.com",
]


def api(method, path, body=None):
    req = Request(
        f"{API_BASE}/profiles/{PROFILE}{path}",
        method=method,
        headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
        data=json.dumps(body).encode() if body else None,
    )
    with urlopen(req) as resp:
        return json.loads(resp.read() or b"{}")


def main():
    current = {row["id"]: row for row in api("GET", "/allowlist").get("data", [])}
    desired = set(ALLOWLIST)

    to_add = desired - current.keys()
    to_remove = current.keys() - desired

    for domain in sorted(to_add):
        api("POST", "/allowlist", {"id": domain, "active": True})
        print(f"+ allowlist {domain}")

    for domain in sorted(to_remove):
        api("DELETE", f"/allowlist/{domain}")
        print(f"- allowlist {domain}")

    if not (to_add or to_remove):
        print("allowlist already in sync")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

I run this as a cron job once a day. The script is idempotent — it diffs desired state against the API and only POSTs deltas. If I add a new CDN to my aggregator and forget to push it through, the next run picks it up.

One gotcha: NextDNS rate-limits the API at around 100 requests per minute per key. If you have hundreds of allowlist entries, batch your changes and add a sleep between deletes. For my list of about 30 entries this is irrelevant, but I have been bitten on a different profile that had a 400-entry blocklist for niche tracking domains.

Validating From the Aggregator Side

A DNS rule is useless if I cannot verify it is actually applied. I have a Python script that runs from each of my regional test boxes and asserts that the right domains resolve to NXDOMAIN, that the required ones still resolve cleanly, and that the resolver in use is actually NextDNS.

#!/usr/bin/env python3
# tools/nextdns_verify.py
# Sanity-check DNS posture from a probe machine.

import socket
import sys
import time
from dataclasses import dataclass

BLOCK_EXPECTED = [
    "doubleclick.net",
    "google-analytics.com",
    "scorecardresearch.com",
    "googleadservices.com",
    "track.adform.net",
]

RESOLVE_EXPECTED = [
    "topvideohub.com",
    "i.ytimg.com",
    "cdn.cloudflare.com",
]


@dataclass
class Result:
    domain: str
    resolved: bool
    addr: str
    elapsed_ms: float


def probe(domain):
    t0 = time.monotonic()
    try:
        addr = socket.gethostbyname(domain)
        return Result(domain, True, addr, (time.monotonic() - t0) * 1000)
    except socket.gaierror:
        return Result(domain, False, "", (time.monotonic() - t0) * 1000)


def resolver_in_use():
    try:
        with open("/etc/resolv.conf") as fh:
            for line in fh:
                if line.startswith("nameserver"):
                    return line.split()[1]
    except FileNotFoundError:
        pass
    return "unknown"


def main():
    print(f"resolver: {resolver_in_use()}")
    failures = 0

    print("\n--- should be blocked ---")
    for d in BLOCK_EXPECTED:
        r = probe(d)
        blocked = (not r.resolved) or r.addr.startswith(("0.0.0.0", "::"))
        status = "OK" if blocked else "LEAK"
        print(f"  {status:5} {d:32} -> {r.addr or 'NXDOMAIN':16} ({r.elapsed_ms:.1f} ms)")
        if status == "LEAK":
            failures += 1

    print("\n--- should resolve ---")
    for d in RESOLVE_EXPECTED:
        r = probe(d)
        status = "OK" if r.resolved else "FAIL"
        print(f"  {status:5} {d:32} -> {r.addr or 'NXDOMAIN':16} ({r.elapsed_ms:.1f} ms)")
        if status == "FAIL":
            failures += 1

    return 0 if failures == 0 else 1


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

I run this from each regional probe every hour via cron. If it ever returns non-zero, I get a Telegram alert. The most common failure mode is that an ISP-provided DNS server has crept back into resolv.conf after a DHCP lease renewal, usually because of a captive portal or a misbehaving network manager. Catching that within an hour is the difference between consistent blocking and an embarrassing gap in my analytics where a Tokyo box queried the ISP for a day and a half.

Wiring NextDNS Analytics Into Aggregator Monitoring

The interesting payoff for me as the aggregator operator is the second-order data. NextDNS has an analytics API that exposes per-profile query stats, including blocked counts grouped by tracker family. I pull that into the aggregator's admin dashboard so I can correlate spikes in third-party tracker activity with traffic patterns on my site.

<?php
// app/Helpers/NextdnsAnalytics.php
declare(strict_types=1);

namespace App\Helpers;

final class NextdnsAnalytics
{
    private const API_BASE = 'https://api.nextdns.io';
    private const CACHE_TTL = 900; // 15 minutes

    public function __construct(
        private readonly string $apiKey,
        private readonly string $profileId,
        private readonly \PDO $db,
    ) {}

    public function topBlockedToday(int $limit = 20): array
    {
        $cacheKey = "nextdns:blocked:{$this->profileId}:{$limit}";
        if ($cached = $this->readCache($cacheKey)) {
            return $cached;
        }

        $url = sprintf(
            '%s/profiles/%s/analytics/destinations?status=blocked&limit=%d&from=-24h',
            self::API_BASE,
            urlencode($this->profileId),
            $limit
        );

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 6,
            CURLOPT_HTTPHEADER     => [
                "X-Api-Key: {$this->apiKey}",
                'Accept: application/json',
            ],
        ]);

        $body = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        if ($code !== 200 || $body === false) {
            return [];
        }

        $payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        $rows = $payload['data'] ?? [];

        $this->writeCache($cacheKey, $rows);
        return $rows;
    }

    private function readCache(string $key): ?array
    {
        $stmt = $this->db->prepare(
            'SELECT payload FROM kv_cache WHERE k = :k AND expires_at > :now'
        );
        $stmt->execute([':k' => $key, ':now' => time()]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        return $row ? json_decode($row['payload'], true) : null;
    }

    private function writeCache(string $key, array $rows): void
    {
        $stmt = $this->db->prepare(
            'INSERT INTO kv_cache (k, payload, expires_at) VALUES (:k, :p, :e)
             ON CONFLICT(k) DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at'
        );
        $stmt->execute([
            ':k' => $key,
            ':p' => json_encode($rows, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
            ':e' => time() + self::CACHE_TTL,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The admin panel renders the top blocked destinations alongside the aggregator's own analytics — which categories are trending, which region is sending the most traffic, which YouTube channels are gaining subscribers. Seeing all of that on one page has caught at least two interesting issues:

  • A new tracking domain attached to a Vietnamese ad network started showing up after we added a regional language pack. The embed code on a partner site we were importing from had picked up a new pixel without telling anyone.
  • One of our IndexNow submission endpoints was occasionally treated as a tracker by an overly aggressive blocklist. The NextDNS dashboard flagged it within an hour of the rule update and I allowlisted it from the admin panel without touching the router.

Performance Numbers From Real Asia-Pacific Probes

The skeptical question I always get is whether pushing DNS to an anycast resolver thousands of kilometers away hurts latency. My measurements from probes in Hong Kong, Tokyo, Singapore, and Bangkok say no, and often the opposite.

Median DNS resolution time on my probes, measured over a week of normal browsing traffic:

  • Hong Kong probe on ISP DNS: 34 ms
  • Hong Kong probe on NextDNS DoH (anycast PoP in Hong Kong): 18 ms
  • Tokyo probe on ISP DNS: 22 ms
  • Tokyo probe on NextDNS DoH: 14 ms
  • Bangkok probe on ISP DNS: 48 ms (with sporadic spikes to 800 ms)
  • Bangkok probe on NextDNS DoH: 24 ms

The ISP DNS Bangkok number is the most interesting. The ISP I tested on has a habit of slow-loris-ing certain queries (presumably for traffic-shaping or injection purposes), and the spikes were severe enough to be felt during navigation. NextDNS removed them entirely.

The other measurable improvement: page weight on the aggregator's home page dropped by 18 percent on probe machines once router-level blocking was in place, because preview thumbnails no longer triggered cascading requests to ad domains. That is the kind of number I show to junior engineers who think DNS is a boring layer to optimize.

What I Would Skip If I Started Over

A few decisions I would not make again:

  • I initially used nextdns-cli as a per-device daemon on every test machine. It works fine but it is a maintenance burden. Pushing the DNS at the router level eliminated nine separate systemd units to keep updated. Use router-level config from day one if you control the router.
  • I configured separate NextDNS profiles per device. That is overkill. One profile per location (Hong Kong, Tokyo, Bangkok) covers 95 percent of my analytical needs, and managing thirty profiles in the dashboard is a chore.
  • I tried using Pi-hole for a month for more control. It is great software, but for an aggregator developer who already has too many self-hosted things to keep alive, a managed DoH resolver is the right trade-off.

Conclusion

Router-level DNS is one of those infrastructure decisions that pays off quietly. The aggregator runs the same as before. The PHP, the SQLite FTS5 search with the CJK tokenizer, the LiteSpeed cache, the Cloudflare edge — none of that changes. What changes is the noise floor of every request that leaves my network. The data I see in TopVideoHub admin dashboards is cleaner, the latency numbers from regional probes are more stable, and the third-party junk that used to show up as background radiation in my logs is mostly gone.

If you operate a media aggregator and you have not pushed your test network onto a managed DoH resolver yet, do it this week. The OpenWrt setup above takes under an hour, and the NextDNS free tier covers 300k queries per month, which is well over what a serious test fleet generates. Once the analytics start coming in, you will find at least three pixel trackers hammering your network that you did not know existed, and you will wonder how you tolerated them for so long.

Top comments (0)