Zero-Trust Private DNS — Complete Implementation Guide technochat.in · Saugata Datta

Technical Implementation Guide  ·  2026

Zero-Trust Private
DNS Architecture

A complete step-by-step guide to building a private, encrypted DNS resolver for every device — at home and on the go. Covers deployment, per-device SNI aliases, automated certificate renewal, and Portainer API integration.

AdGuard Home on Docker1.6M filter rules · per-device query logs
🔒
DNS-over-TLS · Strict SNIUnknown clients refused at TLS layer
Cloudflare GatewayPrivate tenant upstream · not public 1.1.1.1
🤖
Portainer API AutomationCert renewal · container restart · no SSH needed
🏠
Network-wide lockdownSmart TVs · IoT · hardcoded DNS all blocked
📱
Works globally on mobileSame protection on 4G/5G and public Wi-Fi
AdGuard Home · DNS-over-TLS · Cloudflare Gateway · Docker · Portainer API · Certbot · Per-Device SNI · PowerShell
Saugata Datta DevOps Manager · Cloud Infrastructure Specialist · technochat.in
April 2026 Version 1.0

Contents

  1. Introduction
  2. Architecture Overview
  3. Prerequisites
  4. Step 1 — Cloudflare Gateway
  5. Step 2 — Deploy AdGuard Home
  6. Step 3 — SSL Certificate (Wildcard)
  7. Step 4 — Strict SNI Enforcement
  8. Step 5 — Upstream DNS Configuration
  9. Step 6 — Clients & Access Control
  10. Step 7 — Filter Lists
  11. Step 8 — Block Port 53 at Firewall
  12. Step 9 — Home Router Firewall
  13. Script A — DoT Client Verification (PowerShell)
  14. Script B — Certificate Renewal + AdGuard API Update + Portainer Restart
  15. Script C — Log Monitor Summary
  16. Script D — Enable Strict SNI via Bash
  17. Security Audit
  18. Privacy Analysis
  19. Security Scorecard

Introduction

One Private DNS Server — Every Device, Zero Ads, Zero Tracking — home and mobile devices all protected
One self-hosted DNS server protects every device on your network and every phone on the go — no VPN, no app to install, no monthly fee.

Every DNS query leaving your home network travels in plain text by default. Your ISP sees every domain every device resolves — phones, laptops, smart TVs, IoT devices, gaming consoles — a complete record of your household's internet activity, unencrypted, on port 53.

This guide builds a production-grade private DNS resolver that encrypts all DNS traffic, enforces per-device access control using TLS SNI aliases, applies 1.6 million filter rules across your entire network simultaneously, and makes it impossible for any device to bypass your resolver — including those with hardcoded DNS servers in their firmware.

Beyond the core setup, this guide documents four production scripts covering: PowerShell DoT verification against both nodes, automated certificate renewal that pushes updated certificates directly to the AdGuard API and restarts containers via the Portainer API, log analysis, and SNI enforcement automation.

Production verified: This architecture ran through a 12-hour live security audit. Zero unauthorised DNS queries were recorded despite continuous probing from internet scanners across multiple countries.

Architecture Overview

Zero-Trust Private DNS Architecture
Full three-tier architecture: Cloudflare Gateway (upstream) → AdGuard Home servers (middle) → home devices (bottom). All connections encrypted over DNS-over-TLS.

Layer 1 — Cloudflare Gateway

Your private upstream DNS resolver — an isolated tenant in Cloudflare's infrastructure, completely separate from the public 1.1.1.1 service. AdGuard forwards validated queries here over encrypted DoT. Your filtering policies, query logs, and threat intelligence live exclusively in your account.

Layer 2 — AdGuard Home Cluster

One or two AdGuard Home instances running in Docker on cloud VPS servers you control. They accept only DoT connections from registered device aliases, enforce strict SNI checking at the TLS layer before any DNS processing, apply 1.6 million filter rules, and forward legitimate queries to Cloudflare Gateway. A secondary node provides automatic failover — if the primary goes down, your network continues resolving with no manual intervention required.

Layer 3 — Home Network Firewall

Your home router runs in Strict DoT mode. Your gateway router enforces firewall rules that allow DoT traffic only to your server IPs and silently drop all other DNS and DoT traffic. Devices with hardcoded DNS servers have nowhere to go except your resolver.

What the ISP Sees

An encrypted TLS connection to your server IP on port 853. No domain names, no query timing, no browsing patterns.

Prerequisites

Confirm all of the following before starting. Having everything ready makes the setup process straightforward.

1. A Domain Name (Required)

You must own a domain name. This guide uses yourdomain.com as a placeholder — replace it throughout. Any registrar works. The domain must be managed by Cloudflare DNS.

2. Cloudflare Account — Free Tier (Required)

3. Cloudflare API Token (Required)

  1. Cloudflare dashboard → My Profile → API Tokens → Create Token
  2. Template: Edit zone DNS
  3. Scope: your specific domain only
  4. Copy and save immediately — shown only once

4. Cloud Servers (At least one required)

Any Linux VPS with a public IP. Minimum: 1 vCPU, 1 GB RAM, Ubuntu 22.04 LTS or Debian 12. Ports 53, 443, and 853 must be openable.

Secondary server: Optional but strongly recommended. If your only server goes offline, every device on your network loses internet access. A secondary node handles failover automatically with no action required from you.

5. Docker and Docker Compose (Required on each server)

Install Docker
curl -fsSL https://get.docker.com | sh
docker --version && docker compose version

6. Portainer (Optional but used by automation scripts)

Portainer provides a web UI for container management and exposes a REST API that the certificate renewal script uses to restart the AdGuard container after a certificate update. Install on each node:

Deploy Portainer
docker volume create portainer_data

docker run -d \
  --name portainer \
  --restart unless-stopped \
  -p 9443:9443 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v portainer_data:/data \
  portainer/portainer-ce:latest

After deployment, access at https://your-server-ip:9443, create an admin account, and generate an API key under Account → Access tokens → Add access token. You will need this key for the certificate renewal script.

7. DoT-Capable Home Router (Required for network-wide lockdown)

TP-Link Archer (newer models), ASUS with Merlin firmware, Gl.iNet, any router running OpenWrt, OPNsense, or pfSense.

8. DNS Records in Cloudflare (Create before starting)

Both records must have the Cloudflare proxy OFF (grey cloud, DNS only). DoT goes directly to your server — it cannot route through Cloudflare's proxy.
Required Cloudflare DNS Records
# Primary server
dns.yourdomain.com          A      YOUR_PRIMARY_SERVER_IP    Proxy: OFF
*.dns.yourdomain.com        CNAME  dns.yourdomain.com        Proxy: OFF

# Per-node admin access (used by scripts to identify each node)
node1.dns.yourdomain.com    A      YOUR_PRIMARY_SERVER_IP    Proxy: OFF (or via Tunnel)
node2.dns.yourdomain.com    A      YOUR_SECONDARY_SERVER_IP  Proxy: OFF (or via Tunnel)

# Secondary server (optional)
dns2.yourdomain.com         A      YOUR_SECONDARY_SERVER_IP  Proxy: OFF
*.dns2.yourdomain.com       CNAME  dns2.yourdomain.com       Proxy: OFF

Prerequisites Checklist

Step-by-Step Implementation

Step 1

Set Up Cloudflare Gateway

  1. Log in to one.cloudflare.com → Gateway → DNS Locations → Add a location
  2. Name it (e.g. Home-Primary)
  3. Note your assigned DoT hostname — format: <unique-id>.cloudflare-gateway.com
  4. Gateway → Policies → DNS → Create a policy:
    • Security: Malware, Phishing, Command and Control — enable all
    • Content categories as desired
    • Logging: Log all queries

Save your Gateway DoT hostname — you will use it in Step 5 as the upstream for AdGuard.

Step 2

Deploy AdGuard Home with Docker

docker-compose.yml
version: "3.8"

services:
  adguardhome:
    image: adguard/adguardhome:latest
    container_name: adguardhome
    restart: unless-stopped
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "443:443/tcp"
      - "443:443/udp"
      - "853:853/tcp"
      - "853:853/udp"
      - "80:80/tcp"
    volumes:
      - ./conf:/opt/adguardhome/conf
      - ./work:/opt/adguardhome/work
      - /etc/letsencrypt:/etc/letsencrypt:ro  # cert access
    networks:
      adguard_net:
        ipv4_address: 172.34.0.100

networks:
  adguard_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.34.0.0/16
Deploy and verify
docker compose up -d
docker logs adguardhome --tail 20

Access the setup wizard at http://your-server-ip:3000. Set a strong admin password. Repeat identically on your secondary server.

Step 3

Obtain a Wildcard SSL Certificate

AdGuard Home requires a valid TLS certificate for DoT. You need a wildcard covering *.dns.yourdomain.com — the wildcard only, not the bare domain. The bare domain is intentionally not a valid client endpoint.

Install Certbot and obtain certificate
apt install certbot python3-certbot-dns-cloudflare -y

mkdir -p ~/.secrets
cat > ~/.secrets/cloudflare.ini << EOF
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN_HERE
EOF
chmod 600 ~/.secrets/cloudflare.ini

# Wildcard only — do NOT add the bare domain
certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
  -d "*.dns.yourdomain.com"

Certificate files will be at:

In AdGuard Home → Settings → Encryption Settings:

Why mount /etc/letsencrypt read-only into the container: The Docker compose above mounts the host's Let's Encrypt directory into the container. This means when the host certificate is renewed, AdGuard can read the updated files on the next restart — no need to copy files manually. The :ro flag ensures the container cannot modify the certificate store.
Step 4

Enable Strict SNI Enforcement

This is the most critical security setting. Without it, any client that can reach port 853 can make DNS queries. With it, unknown clients are refused at the TLS handshake before any DNS processing occurs.

Method A — Edit AdGuardHome.yaml directly

conf/AdGuardHome.yaml — TLS section
tls:
  enabled: true
  server_name: dns.yourdomain.com
  strict_sni_check: true
  force_https: true
  port_https: 443
  port_dns_over_tls: 853
  port_dns_over_quic: 0
Restart to apply
docker restart adguardhome

Method B — Push via AdGuard TLS API Endpoint

AdGuard Home exposes a REST API endpoint at /control/tls/configure that lets you push a full TLS configuration — including the certificate, private key, and strict_sni_check — without accessing the file system directly. This is how the automated certificate renewal script works.

Push TLS config via AdGuard API (curl)
# Read and base64-encode the certificate and key
CERT_B64=$(base64 -w 0 /etc/letsencrypt/live/dns.yourdomain.com/fullchain.pem)
KEY_B64=$(base64 -w 0 /etc/letsencrypt/live/dns.yourdomain.com/privkey.pem)

# Build the JSON payload
PAYLOAD=$(jq -n \
  --arg sname "dns.yourdomain.com" \
  --arg cert "$CERT_B64" \
  --arg key "$KEY_B64" \
  '{
    "enabled": true,
    "server_name": $sname,
    "force_https": true,
    "port_https": 443,
    "port_dns_over_tls": 853,
    "certificate_chain": $cert,
    "private_key": $key,
    "strict_sni_check": true
  }')

# Send to AdGuard — replace admin:password and hostname
curl -s -u "admin:YOUR_ADGUARD_PASSWORD" \
  -X POST "https://node1.dns.yourdomain.com/control/tls/configure" \
  -H "Content-Type: application/json" \
  -d "$PAYLOAD" \
  -w "\nHTTP Status: %{http_code}\n"

A successful response returns HTTP 200. The configuration is applied immediately — you do not need to restart the container for the TLS settings to take effect, but a restart is recommended to ensure the new certificate is loaded cleanly.

Method C — Restart via Portainer API

Instead of running docker restart on the server directly, the Portainer API lets you trigger a container restart remotely over HTTPS using an API key. This is useful when you need to restart AdGuard on a remote node without SSH access, or as part of an automated script.

Restart AdGuard container via Portainer API (curl)
# Variables
PORTAINER_URL="https://node1.dns.yourdomain.com:9443"   # or your Portainer URL
PORTAINER_TOKEN="YOUR_PORTAINER_API_TOKEN"
PORTAINER_ENV_ID=1          # Environment ID — check in Portainer UI
CONTAINER_NAME="adguardhome"

# Step 1: Look up the container ID by name
CONTAINER_ID=$(curl -s \
  -H "X-API-Key: $PORTAINER_TOKEN" \
  "$PORTAINER_URL/api/endpoints/$PORTAINER_ENV_ID/docker/containers/json?all=true" \
  | jq -r ".[] | select(.Names[] | contains(\"$CONTAINER_NAME\")) | .Id")

echo "Container ID: ${CONTAINER_ID:0:12}"

# Step 2: Restart the container
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
  -X POST \
  -H "X-API-Key: $PORTAINER_TOKEN" \
  "$PORTAINER_URL/api/endpoints/$PORTAINER_ENV_ID/docker/containers/$CONTAINER_ID/restart")

echo "Restart HTTP status: $HTTP_STATUS"
# 204 = success
Finding your Portainer Environment ID: Log in to Portainer → Environments. Each environment (local Docker, remote Docker) has a numeric ID visible in the URL when you click it: /#!/endpoints/2/docker/... — the number after endpoints/ is the environment ID. Use this value for PORTAINER_ENV_ID.
Strict SNI Enforcement Flowchart
Every DoT connection is evaluated through this flow before any DNS query is processed. Unknown clients are refused at the TLS layer.
Incoming SNIResultReason
phone.dns.yourdomain.comAllowedRegistered client alias
dns.yourdomain.com (bare)RefusedNot a valid client alias
unknown.dns.yourdomain.comRefusedNot in client list
anydomain.comRefusedWrong server name entirely
Empty / missing SNIRefusedStrict mode requires SNI
Step 5

Configure Upstream DNS

conf/AdGuardHome.yaml — DNS upstream
dns:
  upstream_dns:
    - tls://your-gateway-id.cloudflare-gateway.com
    - https://your-gateway-id.cloudflare-gateway.com/dns-query
  bootstrap_dns:
    - 172.64.36.1    # Cloudflare anycast infrastructure — not the public 1.1.1.1
    - 172.64.36.2
  fallback_dns:
    - 172.64.36.1
    - 172.64.36.2
  upstream_mode: load_balance
  cache_enabled: true
  cache_size: 4194304
  enable_dnssec: true
  refuse_any: true
  ratelimit: 100
Step 6

Configure Clients and Access Control

conf/AdGuardHome.yaml — Access control
dns:
  allowed_clients:
    - 127.0.0.1
    - 172.16.0.0/11       # All Docker bridge networks (172.16.x.x–172.47.x.x)
    - device1             # Named client aliases matching your client entries
    - device2
    - device3
  disallowed_clients: []

In AdGuard Home → Settings → Client Settings → Add a client. Each client must have a name matching an entry in allowed_clients, and its identifier set to the full SNI alias:

Client NameIdentifier (SNI Alias)Notes
phonephone.dns.yourdomain.comSmartphone — stricter rules if desired
laptoplaptop.dns.yourdomain.comWork or personal laptop
tvtv.dns.yourdomain.comSmart TV — blocks streaming ad domains
tablettablet.dns.yourdomain.comFamily tablet
kidskids.dns.yourdomain.comApply additional content filtering here
Step 7

Set Up Filter Lists

AdGuard Home → Filters → DNS Blocklists → Add blocklist. Update interval: 12 hours.

Filter ListRulesPurpose
AdGuard DNS Filter~50,000Core ads and trackers
OISD Big~250,000Comprehensive blocklist
Steven Black Unified~160,000Ads, malware, fake news
HaGeZi Multi Pro~330,000Aggressive multi-category
1Hosts Pro~165,000Privacy-focused
URLhaus~25,000Active malware URLs
AdAway Default~8,000Mobile ad networks
Total~1,600,000+Combined protection
Step 8

Block Port 53 at Infrastructure Level

Add inbound deny rules at your cloud provider's firewall — independent of AdGuard. Port 53 becomes unreachable from the internet even if AdGuard has a misconfiguration.

Step 9

Configure Home Router Firewall

DNS Traffic Firewall Rules at home gateway
Gateway firewall rules forcing every device — including IoT and smart TVs — through your private resolver.

Wi-Fi Router — Strict DoT Mode

Strict mode refuses all unencrypted DNS queries. Opportunistic mode falls back to plain DNS on failure — providing no privacy guarantee.

Gateway/ISP Router — Firewall Rules (order matters)

ServiceDestinationAction
DNS UDP 53Primary server IPALLOW
DNS TCP 53Primary server IPALLOW
DoT TCP 853Primary server IPALLOW
DNS UDP 53Secondary server IP (optional)ALLOW
DoT TCP 853Secondary server IP (optional)ALLOW
DNS UDP 53Router LAN IPALLOW
DNS UDP 53ANYBLOCK
DNS TCP 53ANYBLOCK
DoT TCP 853ANYBLOCK
DoT UDP 853ANYBLOCK

Script A — DoT Client Verification (PowerShell)

Purpose Tests every registered device alias against your AdGuard nodes using a raw TLS+DNS connection. Verifies that allowed clients can resolve DNS, blocked clients are refused, and reports results with colour-coded PASS/FAIL output. Run from any Windows machine with network access to port 853 on your server.

Requirements PowerShell 5.1 or later. No external modules needed.
Usage .\Test-AdGuard.ps1 — edit the $clients array and server IPs at the bottom before running.
Test-AdGuard.ps1 — Full Script
function Test-AdGuardDoT {
    param(
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][string]$SNI,
        [string]$Query   = "google.com",
        [int]$Port       = 853,
        [int]$Timeout    = 5000
    )

    function Build-DnsQuery {
        param([string]$Domain)
        $labels = $Domain.Split('.')
        $qname  = @()
        foreach ($label in $labels) {
            $bytes  = [System.Text.Encoding]::ASCII.GetBytes($label)
            $qname += [byte]$bytes.Length
            $qname += $bytes
        }
        $qname += [byte]0x00
        $header = [byte[]]@(0xAB,0xCD, 0x01,0x00, 0x00,0x01, 0x00,0x00, 0x00,0x00, 0x00,0x00)
        return $header + $qname + [byte[]]@(0x00,0x01) + [byte[]]@(0x00,0x01)
    }

    $tcp    = $null
    $ssl    = $null
    $result = [PSCustomObject]@{
        Server   = $Server
        Port     = $Port
        SNI      = $SNI
        Query    = $Query
        Status   = "BLOCKED"
        RCODE    = -1
        Answers  = 0
        ErrorMsg = $null
    }

    try {
        $tcp = [System.Net.Sockets.TcpClient]::new($Server, $Port)
        $tcp.ReceiveTimeout = $Timeout
        $tcp.SendTimeout    = $Timeout

        # Accept any certificate — we are testing DoT SNI enforcement,
        # not validating the certificate chain here
        $ssl = [System.Net.Security.SslStream]::new($tcp.GetStream(), $false, { $true })
        $ssl.AuthenticateAsClient($SNI)

        $dnsQuery       = Build-DnsQuery -Domain $Query
        [int]$length    = $dnsQuery.Length
        [byte]$highByte = [math]::Floor($length / 256)
        [byte]$lowByte  = $length % 256
        $fullQuery      = [byte[]]@($highByte, $lowByte) + $dnsQuery

        $ssl.Write($fullQuery, 0, $fullQuery.Length)
        $ssl.Flush()

        $lenBuf = [byte[]]::new(2)
        $ssl.Read($lenBuf, 0, 2) | Out-Null
        [int]$responseLen = $lenBuf[0] * 256 + $lenBuf[1]

        $response = [byte[]]::new($responseLen)
        $ssl.Read($response, 0, $responseLen) | Out-Null

        # Extract RCODE (lower 4 bits of byte 3) and answer count
        [int]$rcode   = $response[3] % 16
        [int]$answers = $response[6] * 256 + $response[7]

        $result.RCODE   = $rcode
        $result.Answers = $answers
        # RCODE 5 = REFUSED — AdGuard's response for unauthorized clients
        $result.Status  = if ($rcode -eq 5) { "BLOCKED" } else { "ALLOWED" }

    } catch {
        $raw             = $_.Exception.Message -replace "`r","" -replace "`n"," " -replace "\s+"," "
        $result.ErrorMsg = if ($raw.Length -gt 30) { $raw.Substring(0,30).TrimEnd() + "..." } else { $raw }
        # TLS failure = blocked at handshake (strict SNI enforcement working)
        $result.Status   = "BLOCKED"
    } finally {
        if ($ssl) { try { $ssl.Close() } catch {} }
        if ($tcp) { try { $tcp.Close() } catch {} }
    }

    return $result
}

function Write-TableRow {
    param(
        [string]$Name, [string]$SNI, [string]$Expected,
        [string]$Got, [string]$Badge, [string]$Detail,
        [string]$BadgeColor, [string]$ExpColor, [string]$GotColor
    )
    Write-Host ("{0,-14} " -f $Name)          -NoNewline -ForegroundColor White
    Write-Host ("{0,-42} " -f $SNI)           -NoNewline -ForegroundColor DarkGray
    Write-Host ("Exp: {0,-7} " -f $Expected)  -NoNewline -ForegroundColor $ExpColor
    Write-Host ("Got: {0,-7} " -f $Got)       -NoNewline -ForegroundColor $GotColor
    Write-Host (" [{0}] "    -f $Badge)       -NoNewline -ForegroundColor $BadgeColor
    Write-Host $Detail                                    -ForegroundColor DarkGray
}

function Invoke-AdGuardTest {
    param(
        [Parameter(Mandatory)][string]$Server,
        [int]$Port     = 853,
        [string]$Query = "google.com",
        [array]$Clients
    )

    $divider = "-" * 100

    Write-Host ""
    Write-Host "  AdGuard Home - DoT Client Access Test" -ForegroundColor Cyan
    Write-Host "  Server  :  $Server : $Port"           -ForegroundColor DarkCyan
    Write-Host "  Query   :  $Query"                    -ForegroundColor DarkCyan
    Write-Host ""
    Write-Host $divider -ForegroundColor DarkGray
    Write-Host ("{0,-14} {1,-42} {2,-12} {3,-12} {4,-8} {5}" -f `
        "  Client", "SNI", "Expected", "Got", "Result", "Detail") -ForegroundColor DarkCyan
    Write-Host $divider -ForegroundColor DarkGray

    $passed = 0; $failed = 0

    foreach ($client in $Clients) {
        $res   = Test-AdGuardDoT -Server $Server -Port $Port -SNI $client.SNI -Query $Query
        $match = $res.Status -eq $client.Expected

        $badge      = if ($match) { "PASS" } else { "FAIL" }
        $badgeColor = if ($match) { "Green" } else { "Red" }
        $expColor   = if ($client.Expected -eq "ALLOWED") { "Green" } else { "Red" }
        $gotColor   = if ($res.Status      -eq "ALLOWED") { "Green" } else { "Red" }
        $detail     = if ($res.ErrorMsg)       { "TLS-ERR: $($res.ErrorMsg)" } `
                      elseif ($res.RCODE -ge 0) { "rcode=$($res.RCODE)  ans=$($res.Answers)" } `
                      else { "" }

        if ($match) { $passed++ } else { $failed++ }

        Write-TableRow -Name $client.Name -SNI $client.SNI `
            -Expected $client.Expected -Got $res.Status `
            -Badge $badge -Detail $detail `
            -BadgeColor $badgeColor -ExpColor $expColor -GotColor $gotColor
    }

    Write-Host $divider -ForegroundColor DarkGray
    Write-Host ""
    Write-Host "  Results :  " -NoNewline -ForegroundColor White
    Write-Host "$passed passed" -NoNewline -ForegroundColor Green
    Write-Host "  |  "         -NoNewline -ForegroundColor DarkGray
    Write-Host "$failed failed" -ForegroundColor $(if ($failed -gt 0) { "Red" } else { "Green" })
    Write-Host ""
}

# ── CONFIGURE YOUR CLIENTS BELOW ─────────────────────────────────────────────
# Add one entry per registered device. Set Expected to ALLOWED or BLOCKED.
# For blocked tests: use the bare domain, an unregistered alias, or a fake domain.

$clients = @(
    [PSCustomObject]@{ Name = "phone";      SNI = "phone.dns.yourdomain.com";   Expected = "ALLOWED" },
    [PSCustomObject]@{ Name = "laptop";     SNI = "laptop.dns.yourdomain.com";  Expected = "ALLOWED" },
    [PSCustomObject]@{ Name = "tv";         SNI = "tv.dns.yourdomain.com";       Expected = "ALLOWED" },
    [PSCustomObject]@{ Name = "tablet";     SNI = "tablet.dns.yourdomain.com";   Expected = "ALLOWED" },
    [PSCustomObject]@{ Name = "kids";       SNI = "kids.dns.yourdomain.com";     Expected = "ALLOWED" },
    [PSCustomObject]@{ Name = "bare domain";SNI = "dns.yourdomain.com";          Expected = "BLOCKED" },
    [PSCustomObject]@{ Name = "unknown";    SNI = "unknown.dns.yourdomain.com";  Expected = "BLOCKED" },
    [PSCustomObject]@{ Name = "fake";       SNI = "fake.com";                    Expected = "BLOCKED" }
)

# ── RUN AGAINST YOUR NODES — replace with your actual server IPs ──────────────
Invoke-AdGuardTest -Server "YOUR_PRIMARY_SERVER_IP"   -Port 853 -Query "google.com" -Clients $clients
Invoke-AdGuardTest -Server "YOUR_SECONDARY_SERVER_IP" -Port 853 -Query "google.com" -Clients $clients

Understanding the output

Script B — Certificate Renewal + AdGuard API Update + Portainer Restart

Purpose — Three automated steps per node This script runs a complete three-phase update cycle for every registered AdGuard node without any manual intervention:

Phase 1 — Check expiry: connects to each node on port 443 using OpenSSL and reads the live certificate expiry date directly from the server. If expiry is within DAYS_THRESHOLD days (default: 7) or --force is passed, the update proceeds.

Phase 2 — Push via AdGuard API: base64-encodes the local fullchain.pem and privkey.pem, builds a JSON payload, and POSTs it to the AdGuard /control/tls/configure endpoint. This pushes the new certificate, private key, and enforces strict_sni_check: true in one atomic API call. HTTP 200 = success.

Phase 3 — Restart via Portainer API: queries the Portainer Docker proxy API to look up the AdGuard container ID by name, then sends a POST to the container restart endpoint. HTTP 204 = success. The container restarts and loads the updated certificate cleanly from the mounted /etc/letsencrypt volume.

Requirements jq, curl, openssl, root privileges. Portainer must be running on each node with a valid API token. The /etc/letsencrypt directory must be mounted read-only into the AdGuard container (see Step 2 docker-compose.yml).

Credentials file The script reads tokens from cred.base64 in the same directory. Each Portainer token is stored base64-encoded. AGH_CREDS is plain admin:password used for HTTP Basic Auth against the AdGuard API. Format: NODE1_TOKEN=<base64>, NODE2_TOKEN=<base64>, AGH_CREDS=admin:password.

Usage sudo ./agh-cert-renew.sh — updates only if expiry ≤ 7 days. sudo ./agh-cert-renew.sh --force — forces update on all nodes regardless of expiry.
Cron Add to root crontab: 0 3 * * * /opt/adguard/agh-cert-renew.sh >> /var/log/agh_ssl_update.log 2>&1
agh-cert-renew.sh — Full Script
#!/bin/bash
# ────────────────────────────────────────────────────────────────────────────
# agh-cert-renew.sh
# Checks certificate expiry on each AdGuard node, pushes renewed cert via
# the AdGuard TLS API, and restarts the container via Portainer API.
#
# Usage:
#   sudo ./agh-cert-renew.sh            # Update only if expiry <= DAYS_THRESHOLD
#   sudo ./agh-cert-renew.sh --force    # Force update regardless of expiry
#
# Requirements:  jq, curl, openssl, root privileges
# ────────────────────────────────────────────────────────────────────────────

# ── CONFIGURATION ────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENC_FILE="$SCRIPT_DIR/cred.base64"        # Credential file (see format below)
LOG_FILE="/var/log/agh_ssl_update.log"

# Local certificate path — where certbot stores the renewed certificate
CERT_BASE_DIR="/etc/letsencrypt/live"
DOMAIN_PATTERN="dns.yourdomain.com"       # Must match your certbot domain name
SERVER_NAME="dns.yourdomain.com"          # AdGuard server_name in TLS config

DAYS_THRESHOLD=7                          # Renew if expiry is within this many days

# Portainer endpoints — one per node
# These are the URLs of your Portainer instances, accessible from this server
PORTAINER_NODE1_URL="https://node1.dns.yourdomain.com:9443"
PORTAINER_NODE2_URL="https://node2.dns.yourdomain.com:9443"

# Portainer environment IDs (find in Portainer UI: Environments menu, numeric ID in URL)
PORTAINER_NODE1_ENV=1
PORTAINER_NODE2_ENV=1

CONTAINER_NAME="adguardhome"

# AdGuard node hostnames — used to check live cert expiry and push the update
# These must be reachable over HTTPS from this server
HOSTS=(
    "node1.dns.yourdomain.com"
    "node2.dns.yourdomain.com"
)

# ── SETUP ─────────────────────────────────────────────────────────────────────
# Create log symlink in script directory for convenience
SYMLINK_PATH="$SCRIPT_DIR/agh_ssl_update.log"
if [[ ! -L "$SYMLINK_PATH" ]]; then
    touch "$LOG_FILE"
    ln -s "$LOG_FILE" "$SYMLINK_PATH"
fi

# Parse --force flag
FORCE_UPDATE=0
for arg in "$@"; do
    [[ "$arg" == "--force" || "$arg" == "-f" ]] && FORCE_UPDATE=1
done

# Must run as root (required to read /etc/letsencrypt/live/)
if [[ $EUID -ne 0 ]]; then
    echo "This script must be run as root (use sudo)."
    exit 1
fi

# Check dependency
if ! command -v jq &> /dev/null; then
    echo "ERROR: 'jq' is missing. Install with: apt-get install jq"
    exit 1
fi

log_message() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}

# ── CREDENTIAL FILE FORMAT ────────────────────────────────────────────────────
# File: cred.base64 (in same directory as this script)
# Each Portainer token is stored base64-encoded for basic obfuscation.
# To encode a token:  printf "your-token-here" | base64
#
# File contents:
#   NODE1_TOKEN=
#   NODE2_TOKEN=
#   AGH_CREDS=admin:your-adguard-password
#
# AGH_CREDS is used for HTTP Basic Auth against the AdGuard API.
# ─────────────────────────────────────────────────────────────────────────────

if [[ ! -f "$ENC_FILE" ]]; then
    log_message "ERROR: Credential file $ENC_FILE not found."
    exit 1
fi

# Source the credential file and decode tokens
source "$ENC_FILE"
NODE1_TOKEN=$(printf "%s" "$NODE1_TOKEN" | base64 -d 2>/dev/null)
NODE2_TOKEN=$(printf "%s" "$NODE2_TOKEN" | base64 -d 2>/dev/null)

if [[ -z "$AGH_CREDS" || -z "$NODE1_TOKEN" || -z "$NODE2_TOKEN" ]]; then
    log_message "ERROR: Missing credentials. Required: AGH_CREDS, NODE1_TOKEN, NODE2_TOKEN"
    exit 1
fi

# ── PORTAINER RESTART FUNCTION ────────────────────────────────────────────────
# Looks up the AdGuard container ID by name and triggers a restart via
# the Portainer Docker proxy API.
portainer_restart() {
    local host="$1"
    local portainer_url portainer_token portainer_env

    # Select credentials based on which host we are restarting
    if [[ "$host" == "node1.dns.yourdomain.com" ]]; then
        portainer_url="$PORTAINER_NODE1_URL"
        portainer_token="$NODE1_TOKEN"
        portainer_env="$PORTAINER_NODE1_ENV"
    else
        portainer_url="$PORTAINER_NODE2_URL"
        portainer_token="$NODE2_TOKEN"
        portainer_env="$PORTAINER_NODE2_ENV"
    fi

    log_message "ACTION: Looking up container '$CONTAINER_NAME' on $portainer_url..."

    # Query Portainer's Docker proxy for a list of all containers,
    # then extract the ID of the one matching CONTAINER_NAME
    CONTAINER_ID=$(curl -s \
        -H "X-API-Key: $portainer_token" \
        "$portainer_url/api/endpoints/$portainer_env/docker/containers/json?all=true" \
        | jq -r ".[] | select(.Names[] | contains(\"$CONTAINER_NAME\")) | .Id")

    if [[ -z "$CONTAINER_ID" ]]; then
        log_message "ERROR: Container '$CONTAINER_NAME' not found on $portainer_url."
        return 1
    fi

    log_message "INFO: Container ID ${CONTAINER_ID:0:12} found."

    # Trigger a restart — Portainer proxies this to the Docker daemon
    # HTTP 204 = success (no content)
    HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
        -X POST \
        -H "X-API-Key: $portainer_token" \
        "$portainer_url/api/endpoints/$portainer_env/docker/containers/$CONTAINER_ID/restart")

    if [[ "$HTTP_STATUS" == "204" ]]; then
        log_message "SUCCESS: '$CONTAINER_NAME' restarted via Portainer on $host (HTTP 204)."
    else
        log_message "FAILURE: Portainer restart failed on $host (HTTP $HTTP_STATUS)."
        return 1
    fi
}

# ── FIND LATEST LOCAL CERTIFICATE ─────────────────────────────────────────────
LATEST_DIR=$(ls -d "${CERT_BASE_DIR}/${DOMAIN_PATTERN}"* 2>/dev/null | sort -V | tail -n 1)
if [[ -z "$LATEST_DIR" ]]; then
    log_message "ERROR: No certificate directory found matching '${DOMAIN_PATTERN}' in $CERT_BASE_DIR."
    exit 1
fi

CERT_FILE="$LATEST_DIR/fullchain.pem"
KEY_FILE="$LATEST_DIR/privkey.pem"
log_message "INFO: Using certificate from $LATEST_DIR"

# ── MAIN LOOP: CHECK AND UPDATE EACH NODE ─────────────────────────────────────
for HOST in "${HOSTS[@]}"; do
    log_message "--- Checking $HOST ---"

    # Connect to port 443 and read the live certificate's expiry date
    REMOTE_EXPIRY_DATE=$(echo | openssl s_client \
        -servername "$HOST" -connect "$HOST":443 2>/dev/null \
        | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)

    if [[ -z "$REMOTE_EXPIRY_DATE" ]]; then
        log_message "ERROR: Could not reach $HOST:443 to check certificate expiry."
        continue
    fi

    EXPIRY_EPOCH=$(date -d "$REMOTE_EXPIRY_DATE" +%s)
    NOW_EPOCH=$(date +%s)
    DIFF_DAYS=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

    log_message "INFO: $HOST certificate expires in $DIFF_DAYS days ($REMOTE_EXPIRY_DATE)."

    # Update if within threshold OR if --force was passed
    if [[ "$DIFF_DAYS" -le "$DAYS_THRESHOLD" ]] || [[ "$FORCE_UPDATE" -eq 1 ]]; then

        if [[ "$FORCE_UPDATE" -eq 1 ]]; then
            log_message "ACTION: --force flag set. Pushing certificate to $HOST regardless of expiry."
        else
            log_message "ACTION: $DIFF_DAYS days until expiry — within threshold. Updating $HOST..."
        fi

        # Base64-encode certificate and key for the API payload
        CERT_B64=$(base64 -w 0 "$CERT_FILE")
        KEY_B64=$(base64 -w 0 "$KEY_FILE")

        # Build JSON payload for AdGuard TLS API
        # strict_sni_check: true — always enforce, even after cert update
        PAYLOAD=$(jq -n \
            --arg sname "$SERVER_NAME" \
            --arg cert "$CERT_B64" \
            --arg key "$KEY_B64" \
            '{
                "enabled": true,
                "server_name": $sname,
                "force_https": true,
                "port_https": 443,
                "port_dns_over_tls": 853,
                "certificate_chain": $cert,
                "private_key": $key,
                "strict_sni_check": true
            }')

        # Push to AdGuard TLS configuration API
        RESP_FILE=$(mktemp)
        HTTP_STATUS=$(curl -s -u "$AGH_CREDS" \
            -X POST "https://$HOST/control/tls/configure" \
            -H "Content-Type: application/json" \
            -d "$PAYLOAD" \
            -w "%{http_code}" -o "$RESP_FILE")
        RESPONSE_BODY=$(cat "$RESP_FILE")
        rm -f "$RESP_FILE"

        if [[ "$HTTP_STATUS" == "200" ]]; then
            log_message "SUCCESS: Certificate pushed to $HOST via AdGuard API (HTTP 200)."
            # Restart the container so the new certificate is loaded cleanly
            portainer_restart "$HOST"
        else
            log_message "FAILURE: AdGuard API update failed on $HOST (HTTP $HTTP_STATUS)."
            log_message "DETAIL: $RESPONSE_BODY"
        fi

    else
        log_message "INFO: $HOST is healthy ($DIFF_DAYS days remaining). No action needed."
    fi

done

log_message "--- Run complete ---"

Setting up the credential file

Create cred.base64 (same directory as the script)
# Encode each Portainer token — run this on your server for each token
printf "your-node1-portainer-api-token" | base64
# Output: eW91ci1ub2RlMS1wb3J0YWluZXItYXBpLXRva2Vu

printf "your-node2-portainer-api-token" | base64
# Output: eW91ci1ub2RlMi1wb3J0YWluZXItYXBpLXRva2Vu

# Then create the credential file
cat > /opt/adguard/cred.base64 << 'EOF'
NODE1_TOKEN=eW91ci1ub2RlMS1wb3J0YWluZXItYXBpLXRva2Vu
NODE2_TOKEN=eW91ci1ub2RlMi1wb3J0YWluZXItYXBpLXRva2Vu
AGH_CREDS=admin:your-adguard-admin-password
EOF

chmod 600 /opt/adguard/cred.base64

Install and schedule

Install script and set up cron
mkdir -p /opt/adguard
cp agh-cert-renew.sh /opt/adguard/
chmod +x /opt/adguard/agh-cert-renew.sh

# Test manually first
sudo /opt/adguard/agh-cert-renew.sh --force

# If successful, add to root crontab
crontab -e
# Add this line — runs at 3am daily:
0 3 * * * /opt/adguard/agh-cert-renew.sh >> /var/log/agh_ssl_update.log 2>&1

How the script handles each node — step by step

  1. Check live certificate expiry (OpenSSL): connects to node.dns.yourdomain.com:443 and reads the actual certificate expiry from the live server — not from the local file. This catches the case where a node has an outdated certificate even if the local Let's Encrypt file has already been renewed.
    How expiry is checked (inside the script)
    REMOTE_EXPIRY_DATE=$(echo | openssl s_client \
        -servername "$HOST" -connect "$HOST":443 2>/dev/null \
        | openssl x509 -noout -enddate | cut -d= -f2)
    
    DIFF_DAYS=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
  2. Push certificate via AdGuard TLS API: reads the local fullchain.pem and privkey.pem, base64-encodes both, and POSTs a JSON payload to /control/tls/configure. The payload includes the certificate chain, private key, server name, port settings, and critically strict_sni_check: true — this ensures SNI enforcement is always re-applied after a certificate update, even if it was accidentally disabled. HTTP 200 = success.
    AdGuard TLS API payload structure
    POST https://node1.dns.yourdomain.com/control/tls/configure
    Authorization: Basic admin:password
    Content-Type: application/json
    
    {
        "enabled": true,
        "server_name": "dns.yourdomain.com",
        "force_https": true,
        "port_https": 443,
        "port_dns_over_tls": 853,
        "certificate_chain": "<base64-encoded fullchain.pem>",
        "private_key": "<base64-encoded privkey.pem>",
        "strict_sni_check": true        ← always enforced on every update
    }
    
    Response: HTTP 200 OK
  3. Restart container via Portainer API (two sub-steps):

    Sub-step 3a — Look up container ID: the script cannot hardcode a container ID because Docker generates a new one on each container recreation. Instead, it queries the Portainer Docker proxy API for a list of all containers and filters by name. This works even if the container was recreated since the script was last run.
    Portainer API — look up container ID by name
    GET https://node1.dns.yourdomain.com:9443/api/endpoints/1/docker/containers/json?all=true
    X-API-Key: your-portainer-api-token
    
    # Response is a JSON array of all containers.
    # The script filters for the one whose name contains "adguardhome":
    CONTAINER_ID=$(... | jq -r ".[] | select(.Names[] | contains(\"adguardhome\")) | .Id")
    Sub-step 3b — Restart the container: sends a POST to the Portainer Docker proxy restart endpoint using the container ID from sub-step 3a. Portainer proxies this to the Docker daemon. HTTP 204 = success (no content, which is Docker's standard success response for a restart).
    Portainer API — restart container
    POST https://node1.dns.yourdomain.com:9443/api/endpoints/1/docker/containers/<CONTAINER_ID>/restart
    X-API-Key: your-portainer-api-token
    
    Response: HTTP 204 No Content  ← success
    Response: HTTP 404             ← container not found
    Response: HTTP 500             ← Docker daemon error
    After restart, AdGuard loads the updated certificate from the mounted /etc/letsencrypt volume and applies all settings from AdGuardHome.yaml including strict_sni_check: true.
  4. Logging: every action — check result, API response, Portainer restart status — is timestamped and appended to /var/log/agh_ssl_update.log. A symlink is created in the script directory for convenience. Use tail -f /var/log/agh_ssl_update.log to monitor a live run.

Script C — Log Monitor Summary (Bash)

Purpose Parses AdGuard Home container logs for a configurable time window and prints a summary of key events: invalid SNI rejections, TLS errors, filter list updates, admin logins, upstream timeouts, and top attacking IPs.

Usage ./adguard-log-summary.sh (last 24 hours) or ./adguard-log-summary.sh 6 (last 6 hours).
adguard-log-summary.sh — Full Script
#!/bin/bash
# ────────────────────────────────────────────────────────────────────────────
# adguard-log-summary.sh
# Parses AdGuard Home container logs and prints a health summary.
# Usage:
#   ./adguard-log-summary.sh        # Last 24 hours
#   ./adguard-log-summary.sh 6      # Last 6 hours
# ────────────────────────────────────────────────────────────────────────────

CONTAINER="adguardhome"
HOURS=${1:-24}

LOGS=$(docker logs "${CONTAINER}" --since "${HOURS}h" 2>&1)

echo "========================================================"
echo " AdGuard Home — Log Summary (last ${HOURS} hours)"
echo "========================================================"
echo ""
echo "Event Counts:"
echo "  Invalid SNI rejections   : $(echo "$LOGS" | grep -c 'invalid SNI')"
echo "  TLS handshake errors     : $(echo "$LOGS" | grep -c 'TLS handshake error')"
echo "  Filter list updates      : $(echo "$LOGS" | grep -c 'filter updated')"
echo "  Admin logins             : $(echo "$LOGS" | grep -c 'successful login')"
echo "  Upstream timeouts        : $(echo "$LOGS" | grep -c 'i/o timeout')"
echo "  Service restarts         : $(echo "$LOGS" | grep -c 'starting adguard home')"
echo "  Upstream conn errors     : $(echo "$LOGS" | grep -c 'response received upstream')"
echo ""
echo "Top Source IPs (TLS errors):"
echo "$LOGS" | grep 'TLS handshake error from' | \
    grep -oP '\d+\.\d+\.\d+\.\d+' | \
    sort | uniq -c | sort -rn | head -10 | \
    awk '{printf "  %-8s hits  :  %s\n", $1, $2}'
echo ""
echo "Recent Invalid SNI values:"
echo "$LOGS" | grep 'unknown SNI in Client Hello' | \
    grep -oP 'server_name=\K[^\s"]+' | \
    sort | uniq -c | sort -rn | head -10 | \
    awk '{printf "  %-6s times  :  %s\n", $1, $2}'
echo ""
echo "Container Status:"
docker inspect --format='  Status   : {{.State.Status}}' "${CONTAINER}" 2>/dev/null
docker inspect --format='  Started  : {{.State.StartedAt}}' "${CONTAINER}" 2>/dev/null
echo "========================================================"

Script D — Enable Strict SNI via Bash

Purpose One-time setup script. Backs up the AdGuard config, sets strict_sni_check: true if not already present, verifies the change, and restarts the container. Run after initial AdGuard deployment before registering any clients.

Usage Run from the directory containing your docker-compose.yml: ./enable-strict-sni.sh
enable-strict-sni.sh — Full Script
#!/bin/bash
# ────────────────────────────────────────────────────────────────────────────
# enable-strict-sni.sh
# Enables strict SNI enforcement in AdGuardHome.yaml and restarts the container.
# Run from the directory containing your docker-compose.yml.
# ────────────────────────────────────────────────────────────────────────────

CONTAINER="adguardhome"
CONFIG_PATH="./conf/AdGuardHome.yaml"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

echo "[$TIMESTAMP] Enabling strict SNI check on $CONTAINER..."

# Verify config exists
if [[ ! -f "$CONFIG_PATH" ]]; then
    echo "ERROR: Config file not found at $CONFIG_PATH"
    echo "       Run this script from the directory containing your docker-compose.yml"
    exit 1
fi

# Back up current config
BACKUP="${CONFIG_PATH}.backup.$(date +%s)"
cp "$CONFIG_PATH" "$BACKUP"
echo "Config backed up to $BACKUP"

# Apply the setting
if grep -q "strict_sni_check" "$CONFIG_PATH"; then
    # Key exists — update it (handles both true and false)
    sed -i 's/strict_sni_check: false/strict_sni_check: true/' "$CONFIG_PATH"
    echo "Updated strict_sni_check to true."
else
    # Key not present — add it after 'enabled: true' in the tls section
    sed -i '/^tls:/,/^[^[:space:]]/{s/\(  enabled: true\)/\1\n  strict_sni_check: true/}' "$CONFIG_PATH"
    echo "Added strict_sni_check: true to tls section."
fi

# Verify the change took effect
if grep -q "strict_sni_check: true" "$CONFIG_PATH"; then
    echo "Verification: strict_sni_check: true confirmed in $CONFIG_PATH"
else
    echo "WARNING: Could not verify the change. Check $CONFIG_PATH manually."
    exit 1
fi

# Restart the container
echo "Restarting $CONTAINER..."
docker restart "$CONTAINER"

if [[ $? -eq 0 ]]; then
    echo ""
    echo "Done. Strict SNI enforcement is now active."
    echo "Run the PowerShell verification script (Script A) to confirm client access."
else
    echo "ERROR: Container failed to restart. Check 'docker logs $CONTAINER'."
    exit 1
fi

Security Audit — What Attacks Look Like

Once your server has a public IP, internet scanners find it within minutes. The following table records the attack types observed in a 12-hour live production audit and how the architecture handled each.

Real threats hitting a DNS server daily
Attack types recorded in a 12-hour live audit — every attempt was blocked before reaching the DNS layer.
Attack TypeLog MessageOutcome
TLS version scannerstls: client offered only unsupported versions: [302 301]Blocked at TLS handshake
Cipher suite sweepstls: no cipher suite supported by both client and serverBlocked at TLS handshake
Bare domain DoT probesunknown SNI in Client Hello server_name=dns.yourdomain.comBlocked by strict_sni_check
Empty SNI probesunknown SNI in Client Hello server_name=""Blocked by strict_sni_check
Raw TCP on port 853first record does not look like a TLS handshakeBlocked — not valid TLS
TLS fuzzing / bad MACslocal error: tls: bad record MACBlocked by Go TLS layer
Malformed DNS UDPbad header id: dns: overflow unpacking uint16Dropped by AdGuard
SSLv2 probestls: unsupported SSLv2 handshake receivedBlocked — obsolete protocol
Zero unauthorised DNS queries were processed in the full 12-hour production audit. All external attempts were rejected before reaching the DNS layer.

Privacy Analysis

DNS Privacy Before and After
Before: your ISP saw every domain your household visited. After: they see only an encrypted connection to your server IP.
DataISP VisibilityNotes
DNS query contentNoneEncrypted inside DoT TLS tunnel
Which domains you visitNoneNot visible from encrypted traffic
DNS query historyNoneFully encrypted — no accessible record
That you use DoTYesPort 853 connections are visible
Your server IP addressesYesIPs are not encrypted
Website SNI (browser TLS)YesSeparate from DNS — use ECH to address
Approximate traffic volumeYesPacket counts and timing visible

Browser SNI — The Remaining Gap

When your browser connects to a website, the hostname is sent in the TLS ClientHello as Server Name Indication. This is visible to your ISP independently of DNS. Encrypted Client Hello (ECH) addresses this:

Security Assessment Scorecard

9.6
Overall Security Score / 10 — Production-grade private DNS
Security LayerScoreStatus
DoT SNI enforcement10/10Zero unauthorised queries in production audit
Certificate design10/10Bare domain unreachable — scanners get cert error
Port 53 infrastructure block10/10Blocked at cloud firewall — independent of AdGuard
Per-device identification10/10Unique SNI alias per device — no software on device
Encrypted upstream10/10Private Cloudflare Gateway — not public resolver
Home router lockdown10/10IoT and hardcoded DNS bypass fully blocked
Certificate renewal automation10/10API-push + Portainer restart — fully automated
Filter lists9/101.6M+ rules, 7 lists, updated daily
Container isolation9/10Dedicated Docker network per service
Port 853/443 exposure8/10Required for DoT clients — intentional, not a gap

Zero-Trust Private DNS Architecture — Complete Implementation Guide
Written by Saugata Datta · DevOps Manager · Cloud Infrastructure Specialist
technochat.in · 2026