Swiss...
Privacy-Focused DNS on Swiss Infrastructure: Deploying Encrypted DNS Resolvers
Your server's encrypted traffic means nothing when every domain lookup is broadcast in plaintext. This guide covers deploying your own recursive DNS resolver with DNS-over-TLS, DNS-over-HTTPS, and DNSSEC validation on Swiss VPS infrastructure — eliminating the last major surveillance blind spot in your privacy hosting stack.
September 14, 2026
by SwissLayer 17 min read
Privacy-Focused DNS Resolvers on Swiss Infrastructure

You encrypted everything. TLS on all web traffic. WireGuard tunnels between servers. SSH with Ed25519 keys. Full-disk LUKS encryption at rest. Your data is wrapped in layers of cryptography that would take centuries to brute-force. And then your server sends a DNS query for api.stripe.com in plain text, broadcast to anyone positioned between your server and the resolver — your hosting provider's network, the upstream transit provider, the recursive resolver operator, and every network hop in between. Every single domain your server communicates with, logged in cleartext, timestamped, correlated with your server's IP address.

DNS is the last major plaintext protocol in most server stacks. It is also one of the most revealing — DNS query logs paint a complete picture of what software runs on your server, what external services it depends on, what APIs it calls, and when it calls them. For privacy-conscious operators running infrastructure on Swiss VPS hosting, this is not a theoretical concern. It is a measurable gap in an otherwise encrypted environment.

This guide addresses that gap. We are going to deploy a full recursive DNS resolver on Swiss infrastructure — one that validates DNSSEC, serves clients over encrypted transports (DNS-over-TLS and DNS-over-HTTPS), and queries upstream root servers directly rather than trusting a third-party recursive resolver. The result is a DNS infrastructure where no external party sees your query patterns, and the answers you receive are cryptographically validated against tampering.

Why DNS Is the Privacy Blind Spot Most Operators Ignore

The Domain Name System was designed in 1983. Privacy was not a consideration. Every DNS query and response travels as unencrypted UDP (or occasionally TCP) on port 53. Anyone who can observe the network path between your server and its configured resolver can see every domain your server looks up, in real time.

This matters more than most operators realise because of what DNS queries reveal:

Application fingerprinting: A server querying registry.npmjs.org, pypi.org, and rubygems.org is running a multi-language development environment. One querying smtp.gmail.com and imap.gmail.com is relaying email through Google. One querying api.openai.com is running AI workloads. DNS queries are a perfect fingerprint of the software stack.
Behavioural analysis: DNS queries are timestamped. A server that queries api.trading-platform.com every 30 seconds during market hours is running an automated trading bot. One that queries updates.wordpress.org at 03:00 daily is running WordPress with automated updates. Temporal patterns in DNS queries reveal operational patterns.
Relationship mapping: If your server queries DNS for domains belonging to your clients, partners, or the services you depend on, those relationships are visible in DNS logs. An investigator with access to DNS query logs can map your entire service dependency graph without ever touching your server.
Content inference: A user accessing your server resolves your domain via DNS. If you host multiple services on different subdomains, the specific subdomain resolved tells an observer which service the user is accessing — even if the actual traffic is encrypted with TLS and uses SNI encryption (ECH).

Most servers ship configured to use their hosting provider's recursive resolver, or a public resolver like Google (8.8.8.8), Cloudflare (1.1.1.1), or Quad9 (9.9.9.9). Every DNS query your server makes is visible to that resolver operator. Google's privacy policy explicitly states they log DNS queries temporarily. Cloudflare publishes audit reports claiming minimal logging. But "trust us" is not a privacy architecture — it is a business relationship subject to change without notice, legal compulsion, or acquisition.

Running your own recursive resolver on your own Swiss VPS infrastructure eliminates the third-party resolver from the equation entirely. Your server queries the DNS root servers and authoritative nameservers directly, following the delegation chain for each lookup. No single external party sees your full query pattern. The root servers see that your IP queried for the .com TLD servers. The .com TLD servers see that your IP queried for the authoritative nameserver for example.com. The authoritative nameserver for example.com sees that your IP queried for a specific record. No one entity has the complete picture.

Architecture Overview: What We Are Building

The architecture has three layers:

Unbound — a recursive, caching, DNSSEC-validating DNS resolver. It handles the actual work of walking the DNS hierarchy from root servers to authoritative nameservers. It listens on localhost for queries from the server itself and on the WireGuard interface for queries from other servers in your network.
DNS-over-TLS (DoT) frontend — Unbound natively supports serving DNS-over-TLS on port 853. Clients that support DoT (Android 9+, systemd-resolved, stubby, knot-resolver) can send encrypted queries to your resolver.
DNS-over-HTTPS (DoH) frontend — For clients that support DoH (Firefox, Chrome, curl, and most modern HTTP libraries), we deploy a lightweight DoH proxy that accepts HTTPS queries on port 443 and forwards them to Unbound over localhost. This uses nginx as a TLS terminator with a small Go-based DoH handler behind it.

The entire stack runs on a single Swiss VPS instance. You can run it alongside other services (web server, mail, etc.) or dedicate a small instance solely to DNS — a 1-core, 1GB RAM VPS is more than sufficient for a private resolver serving a small fleet of servers.

Step 1: Installing and Configuring Unbound

Unbound is developed by NLnet Labs, a Dutch non-profit focused on Internet infrastructure. It is widely audited, used by major DNS providers, and available in every major Linux distribution's package repository.

# Install Unbound and DNS utilities
apt update && apt install -y unbound unbound-anchor dns-root-data dnsutils

# Fetch the root trust anchor for DNSSEC validation
unbound-anchor -a /var/lib/unbound/root.key

# Fetch the root hints file (list of root DNS servers)
wget -O /var/lib/unbound/root.hints https://www.internic.net/domain/named.root

The root hints file tells Unbound where to find the 13 DNS root servers. The root trust anchor (root.key) is the DNSSEC trust anchor that allows Unbound to validate the entire DNSSEC chain from the root zone down. Without it, DNSSEC validation is impossible.

Now the main configuration. This is a production-ready /etc/unbound/unbound.conf optimised for privacy:

server:
    # Network binding
    interface: 127.0.0.1
    interface: ::1
    interface: 10.100.0.1        # WireGuard management network
    port: 53

    # Access control — restrict who can query
    access-control: 127.0.0.0/8 allow
    access-control: ::1/128 allow
    access-control: 10.100.0.0/24 allow    # WireGuard peers
    access-control: 0.0.0.0/0 refuse       # Everyone else denied

    # Privacy: minimise data sent to authoritative servers
    qname-minimisation: yes
    # Send only the label being resolved, not the full query name
    # e.g., when resolving mail.example.com, ask .com servers
    # only about example.com, not mail.example.com

    # Privacy: do not send client subnet information
    send-client-subnet: 0.0.0.0/0
    client-subnet-always-forward: no

    # Privacy: strip identifying information from responses
    hide-identity: yes
    hide-version: yes
    harden-glue: yes

    # DNSSEC validation
    auto-trust-anchor-file: "/var/lib/unbound/root.key"
    val-clean-additional: yes
    val-permissive-mode: no     # Strict: reject DNSSEC failures

    # Root hints
    root-hints: "/var/lib/unbound/root.hints"

    # Performance tuning
    num-threads: 2
    msg-cache-slabs: 4
    rrset-cache-slabs: 4
    infra-cache-slabs: 4
    key-cache-slabs: 4

    # Cache sizing — adjust based on available RAM
    msg-cache-size: 64m
    rrset-cache-size: 128m      # Should be 2x msg-cache
    key-cache-size: 32m
    neg-cache-size: 16m

    # Cache behaviour
    cache-min-ttl: 300          # Minimum cache time: 5 minutes
    cache-max-ttl: 86400        # Maximum cache time: 24 hours
    prefetch: yes               # Prefetch popular entries before expiry
    prefetch-key: yes           # Prefetch DNSSEC keys

    # Hardening
    harden-below-nxdomain: yes
    harden-referral-path: yes
    harden-algo-downgrade: yes
    harden-large-queries: yes
    harden-short-bufsize: yes
    use-caps-for-id: yes        # 0x20 encoding — randomise case for
                                 # cache poisoning resistance

    # Prevent DNS rebinding attacks
    private-address: 10.0.0.0/8
    private-address: 172.16.0.0/12
    private-address: 192.168.0.0/16
    private-address: 169.254.0.0/16
    private-address: fd00::/8
    private-address: fe80::/10

    # Logging — minimal for privacy
    verbosity: 1
    log-queries: no             # Do NOT log individual queries
    log-replies: no             # Do NOT log individual replies
    log-local-actions: no
    log-servfail: yes           # Log failures for debugging
    logfile: ""                 # Log to syslog, not a file
    use-syslog: yes

    # DNS-over-TLS (DoT) — serve on port 853
    tls-port: 853
    tls-service-pem: "/etc/letsencrypt/live/dns.example.com/fullchain.pem"
    tls-service-key: "/etc/letsencrypt/live/dns.example.com/privkey.pem"
    tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"

    # Connection limits
    incoming-num-tcp: 256
    outgoing-num-tcp: 64
    ip-ratelimit: 100           # Rate-limit per source IP

    # Aggressive NSEC — use cached NSEC records to answer
    # negative queries without contacting authoritative servers
    aggressive-nsec: yes

    # Do not use systemd socket activation
    do-daemonize: no

The critical privacy settings deserve explanation:

qname-minimisation is the single most important privacy feature in a recursive resolver. Without it, when your server queries for api.internal.example.com, Unbound sends the full domain name to every server in the resolution chain — the root servers, the .com TLD servers, and the example.com authoritative servers all see the complete name. With qname-minimisation enabled, Unbound sends only the minimum label needed at each step: the root servers see a query for .com, the .com servers see a query for example.com, and only the example.com authoritative server sees the full api.internal.example.com query. This is defined in RFC 7816 and dramatically reduces the information leaked to upstream servers.

log-queries: no is a deliberate choice. If you are running this resolver for privacy, logging every DNS query defeats the purpose — you are creating exactly the surveillance dataset you are trying to avoid. Log only errors and operational events, not query content.

use-caps-for-id implements 0x20 encoding, which randomises the capitalisation of letters in the query name (DNS is case-insensitive). The response must match the same randomised capitalisation. This makes cache poisoning attacks significantly harder because an attacker must guess both the transaction ID and the exact capitalisation pattern used in the query.

Verify the configuration and start Unbound:

# Check configuration syntax
unbound-checkconf

# Start Unbound
systemctl enable unbound
systemctl start unbound

# Test basic resolution
dig @127.0.0.1 www.swisslayer.com A +dnssec

# Verify DNSSEC validation is working
# This domain has a deliberately broken DNSSEC signature
dig @127.0.0.1 dnssec-failed.org A
# Should return SERVFAIL — Unbound refuses to return unvalidated answers

# Test qname minimisation
unbound-control stats_noreset | grep qname

Step 2: DNS-over-TLS for Network Clients

With the configuration above, Unbound already serves DNS-over-TLS on port 853. Any client that supports DoT can connect directly. The TLS certificate is required — self-signed certificates work for your own infrastructure, but Let's Encrypt provides free, automatically renewable certificates that any client will trust without custom CA configuration.

# Obtain a TLS certificate for your DNS resolver hostname
# Assumes nginx is NOT running on port 80 (standalone mode)
apt install -y certbot

certbot certonly --standalone -d dns.example.com \
    --agree-tos --email admin@example.com --non-interactive

# Set up automatic renewal with a hook to restart Unbound
cat > /etc/letsencrypt/renewal-hooks/deploy/restart-unbound.sh << 'EOF'
#!/bin/bash
# Copy certs to Unbound-readable location and restart
cp /etc/letsencrypt/live/dns.example.com/fullchain.pem /etc/unbound/tls-cert.pem
cp /etc/letsencrypt/live/dns.example.com/privkey.pem /etc/unbound/tls-key.pem
chown unbound:unbound /etc/unbound/tls-cert.pem /etc/unbound/tls-key.pem
chmod 640 /etc/unbound/tls-key.pem
systemctl restart unbound
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/restart-unbound.sh

On your other servers (the ones that should use this resolver), configure DNS-over-TLS as the upstream. The most common DoT client on Linux servers is stubby (from the getdns project) or systemd-resolved (built into systemd 247+).

Using stubby as a local DoT forwarder:

# Install stubby
apt install -y stubby

# /etc/stubby/stubby.yml
resolution_type: GETDNS_RESOLUTION_STUB
dns_transport_list:
  - GETDNS_TRANSPORT_TLS
tls_authentication: GETDNS_AUTHENTICATION_REQUIRED
tls_query_padding_blocksize: 128    # Pad queries to uniform size
round_robin_upstreams: 1
idle_timeout: 10000

listen_addresses:
  - 127.0.0.1@5353                  # Listen on localhost only
  - 0::1@5353

upstream_recursive_servers:
  - address_data: 10.100.0.1        # Your Unbound resolver
    tls_auth_name: "dns.example.com"
    tls_port: 853

# Then point /etc/resolv.conf to stubby
echo "nameserver 127.0.0.1" > /etc/resolv.conf

# Prevent NetworkManager/systemd-resolved from overwriting
chattr +i /etc/resolv.conf

Using systemd-resolved (if your distribution uses it):

# /etc/systemd/resolved.conf
[Resolve]
DNS=10.100.0.1#dns.example.com
DNSOverTLS=yes
DNSSEC=yes
FallbackDNS=
# Empty FallbackDNS prevents fallback to unencrypted resolvers

# Restart
systemctl restart systemd-resolved

# Verify DoT is active
resolvectl status
# Should show "DNS over TLS: yes"

The tls_query_padding_blocksize in stubby's configuration pads every DNS query to a multiple of 128 bytes before encryption. Without padding, an observer can estimate the length of the domain name being queried based on the encrypted packet size. A query for a.io is noticeably shorter than one for api.longdomainname.example.com. Padding eliminates this side channel. RFC 8467 standardises this as EDNS(0) Padding.

Step 3: DNS-over-HTTPS for Maximum Compatibility

DNS-over-HTTPS wraps DNS queries inside standard HTTPS requests on port 443. This has two advantages over DoT: it is indistinguishable from normal HTTPS traffic to network observers (DoT on port 853 is trivially identifiable and can be blocked), and it works through corporate firewalls and hotel captive portals that often block non-standard ports.

We will use dnsproxy from AdGuard — a lightweight, single-binary DNS proxy that handles the DoH protocol and forwards to Unbound over localhost:

# Download dnsproxy
DNSPROXY_VERSION="0.72.0"
wget "https://github.com/AdguardTeam/dnsproxy/releases/download/v${DNSPROXY_VERSION}/dnsproxy-linux-amd64-v${DNSPROXY_VERSION}.tar.gz"
tar xzf "dnsproxy-linux-amd64-v${DNSPROXY_VERSION}.tar.gz"
mv linux-amd64/dnsproxy /usr/local/bin/
chmod +x /usr/local/bin/dnsproxy

# Create systemd service
cat > /etc/systemd/system/dnsproxy-doh.service << 'EOF'
[Unit]
Description=DNS-over-HTTPS proxy
After=network.target unbound.service
Requires=unbound.service

[Service]
Type=simple
User=nobody
ExecStart=/usr/local/bin/dnsproxy \
    --listen=127.0.0.1 \
    --port=8053 \
    --https-port=0 \
    --upstream=127.0.0.1:53 \
    --http3 \
    --cache
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable dnsproxy-doh
systemctl start dnsproxy-doh

Then configure nginx as the TLS terminator, proxying /dns-query to dnsproxy:

# /etc/nginx/sites-available/doh
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name dns.example.com;

    ssl_certificate /etc/letsencrypt/live/dns.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/dns.example.com/privkey.pem;

    # Modern TLS only
    ssl_protocols TLSv1.3;
    ssl_prefer_server_ciphers off;

    # HSTS
    add_header Strict-Transport-Security "max-age=63072000" always;

    # DoH endpoint
    location /dns-query {
        proxy_pass http://127.0.0.1:8053/dns-query;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_http_version 1.1;
        proxy_set_header Connection "";

        # Prevent access logging of DNS queries
        access_log off;
    }

    # Block everything else
    location / {
        return 444;
    }
}

# Enable and reload
ln -sf /etc/nginx/sites-available/doh /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

Test the DoH endpoint:

# Using curl to query DNS-over-HTTPS (RFC 8484 wire format)
curl -s -H 'accept: application/dns-message' \
    'https://dns.example.com/dns-query?dns=AAABAAABAAAAAAAAA3d3dwtzd2lzc2xheWVyA2NvbQAAAQAB' | \
    python3 -c "import sys; data=sys.stdin.buffer.read(); print(f'Response: {len(data)} bytes')"

# Using kdig (from knot-dnsutils) — cleaner output
apt install -y knot-dnsutils
kdig @dns.example.com +https www.swisslayer.com A

# Using dog (modern DNS client)
dog www.swisslayer.com --https @https://dns.example.com/dns-query

The nginx configuration deliberately sets access_log off for the DoH endpoint. The entire purpose of running your own resolver is to avoid creating query logs. Nginx's default access log would record every DNS query as an HTTP request, complete with timestamps, source IPs, and query parameters. Turning it off is essential.

Step 4: DNSSEC Validation Deep Dive

DNSSEC does not encrypt DNS traffic — it authenticates it. DNSSEC ensures that the DNS records you receive are the same records the domain owner published. Without DNSSEC, an attacker who can modify DNS responses in transit (or poison a resolver's cache) can redirect your server to a malicious IP without any cryptographic evidence of tampering.

Unbound's DNSSEC validation works through a chain of trust:

• The DNS root zone is signed with the root zone signing key (ZSK), which is itself signed by the root key signing key (KSK). The root KSK's hash is the trust anchor stored in /var/lib/unbound/root.key.
• When Unbound resolves www.example.com, it follows the chain: the root zone signs a delegation signer (DS) record for .com, the .com zone signs a DS record for example.com, and the example.com zone signs the actual A record. Each signature is verified against the parent zone's delegation.
• If any signature in the chain is missing, expired, or invalid, Unbound returns SERVFAIL rather than an unvalidated answer. This is the strict mode configured by val-permissive-mode: no.

Monitoring DNSSEC validation health is important because DNSSEC failures can break legitimate domains — misconfigured DNSSEC on a domain you depend on will cause Unbound to refuse to resolve it:

# Check DNSSEC validation statistics
unbound-control stats_noreset | grep -E "num.answer.(secure|bogus|nxdomain)"

# Manual DNSSEC chain validation
# Walk the chain for a specific domain
dig @127.0.0.1 . DNSKEY +dnssec +multi        # Root DNSKEY
dig @127.0.0.1 com. DS +dnssec +short          # .com DS record
dig @127.0.0.1 com. DNSKEY +dnssec +multi      # .com DNSKEY
dig @127.0.0.1 example.com. DS +dnssec +short   # example.com DS
dig @127.0.0.1 example.com. A +dnssec          # Final answer with RRSIG

# The +dnssec flag requests DNSSEC records in the response
# Look for 'ad' (Authenticated Data) flag in the response header
# 'ad' means DNSSEC validation passed

# Test against known-good and known-bad domains
dig @127.0.0.1 good.dnssec-or-not.net A        # Should resolve (ad flag)
dig @127.0.0.1 bad.dnssec-or-not.net A          # Should SERVFAIL

# Automate DNSSEC health checks
cat > /usr/local/bin/check-dnssec.sh << 'SCRIPT'
#!/bin/bash
# Daily DNSSEC health check

RESOLVER="127.0.0.1"
PASS=0
FAIL=0

# Test a known-good DNSSEC domain
if dig @${RESOLVER} internetsociety.org A +dnssec +short | grep -q "^[0-9]"; then
    ((PASS++))
else
    echo "ALERT: DNSSEC validation failing for signed domain"
    ((FAIL++))
fi

# Test that Unbound rejects bad DNSSEC
RESULT=$(dig @${RESOLVER} dnssec-failed.org A +short 2>&1)
if echo "$RESULT" | grep -qi "SERVFAIL\|timed out"; then
    ((PASS++))  # Correctly rejected
else
    echo "ALERT: Unbound accepting DNSSEC-invalid responses"
    ((FAIL++))
fi

# Check trust anchor freshness
unbound-anchor -a /var/lib/unbound/root.key 2>/dev/null
if [ $? -eq 1 ]; then
    echo "ALERT: Root trust anchor update failed"
    ((FAIL++))
fi

echo "DNSSEC check: ${PASS} passed, ${FAIL} failed"
exit ${FAIL}
SCRIPT
chmod +x /usr/local/bin/check-dnssec.sh

One operational concern with strict DNSSEC: some domains have broken DNSSEC configurations. Their DNS works fine without validation, but a validating resolver correctly rejects their responses because the DNSSEC signatures are invalid or expired. When this happens, you have two options: fix it at the source (if it is your domain or you can contact the domain owner) or temporarily add that domain to Unbound's insecure zone list. The second option is a security trade-off — you are disabling validation for that specific domain — but it keeps your services operational while the domain owner fixes their DNSSEC.

# Add a domain with broken DNSSEC to the insecure list
# /etc/unbound/unbound.conf.d/insecure-domains.conf
server:
    # Only add domains here when DNSSEC is genuinely broken
    # upstream and you cannot wait for it to be fixed
    domain-insecure: "broken-dnssec-domain.com"

# Reload without restart
unbound-control reload

Step 5: Response Policy Zones for Threat Blocking

While we are deploying a DNS resolver for privacy, we can also use it for security — blocking known malicious domains, phishing infrastructure, malware command-and-control servers, and tracking domains at the DNS level. This is more effective than browser-based ad blockers because it protects every application on every server using the resolver, not just web browsers.

# Download threat intelligence feeds
mkdir -p /etc/unbound/blocklists

# StevenBlack's unified hosts — comprehensive, well-maintained
wget -O /tmp/hosts-unified \
    "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"

# Convert hosts format to Unbound local-zone format
grep "^0.0.0.0" /tmp/hosts-unified | \
    awk '{print "local-zone: \""$2"\" always_nxdomain"}' | \
    grep -v "0.0.0.0" > /etc/unbound/blocklists/threat-domains.conf

# OISD blocklist — another well-curated option
wget -O /etc/unbound/blocklists/oisd.conf \
    "https://big.oisd.nl/unbound"

# Include in main config
# Add to /etc/unbound/unbound.conf:
# include: "/etc/unbound/blocklists/threat-domains.conf"

# Automate weekly updates
cat > /etc/cron.weekly/update-dns-blocklists << 'EOF'
#!/bin/bash
wget -q -O /tmp/hosts-unified \
    "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
grep "^0.0.0.0" /tmp/hosts-unified | \
    awk '{print "local-zone: \""$2"\" always_nxdomain"}' | \
    grep -v "0.0.0.0" > /etc/unbound/blocklists/threat-domains.conf
unbound-control reload
logger "DNS blocklists updated: $(wc -l < /etc/unbound/blocklists/threat-domains.conf) domains blocked"
EOF
chmod +x /etc/cron.weekly/update-dns-blocklists

Blocking at the DNS resolver level is a defence-in-depth measure. It is not a substitute for proper network security — a determined attacker can hardcode IP addresses to bypass DNS — but it catches the vast majority of commodity malware and tracking that relies on domain names for communication.

Step 6: Firewall Rules for DNS Infrastructure

The resolver should be accessible only from authorised sources. Public recursive resolvers are a favourite target for DNS amplification attacks, where an attacker spoofs the source IP of queries to redirect large DNS responses at a victim.

# nftables rules for the DNS resolver
# /etc/nftables.conf (relevant excerpt)

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # Allow established connections
        ct state established,related accept

        # Allow loopback
        iif lo accept

        # DNS (port 53) — only from WireGuard management network
        iifname "wg0" udp dport 53 accept
        iifname "wg0" tcp dport 53 accept

        # DNS-over-TLS (port 853) — WireGuard clients only
        # Or open to specific external IPs if needed
        iifname "wg0" tcp dport 853 accept

        # HTTPS (port 443) — for DoH endpoint
        # Restrict to known client IPs or VPN range
        iifname "wg0" tcp dport 443 accept

        # SSH — through WireGuard only
        iifname "wg0" tcp dport 22 accept

        # Rate-limit everything else
        meta l4proto { tcp, udp } limit rate 10/second burst 20 packets accept

        # Log dropped packets (optional — set rate limit to avoid log flood)
        limit rate 5/minute log prefix "nftables-dropped: " drop
    }

    chain output {
        type filter hook output priority 0; policy accept;

        # Allow DNS queries to root/authoritative servers
        udp dport 53 accept
        tcp dport 53 accept

        # Allow HTTPS for certificate renewal and blocklist updates
        tcp dport 443 accept
        tcp dport 80 accept

        # Allow WireGuard
        udp dport 51820 accept

        # Allow established
        ct state established,related accept
    }
}

# Apply
nft -f /etc/nftables.conf
systemctl enable nftables

The key principle: DNS port 53 is never exposed to the public Internet on this resolver. It listens only on localhost and the WireGuard interface. DoT on 853 and DoH on 443 can be opened more broadly if you need to serve external clients, but even then, restrict to known IPs where possible.

Step 7: Monitoring and Operational Visibility

You need to know if your resolver is healthy without creating the surveillance logs you are trying to avoid. The trick is monitoring operational metrics (cache hit rate, query volume, latency, DNSSEC validation failures) without logging individual query content.

# Enable Unbound's control interface for statistics
# Add to /etc/unbound/unbound.conf:
remote-control:
    control-enable: yes
    control-interface: 127.0.0.1
    control-port: 8953
    server-key-file: "/etc/unbound/unbound_server.key"
    server-cert-file: "/etc/unbound/unbound_server.pem"
    control-key-file: "/etc/unbound/unbound_control.key"
    control-cert-file: "/etc/unbound/unbound_control.pem"

# Generate control certificates
unbound-control-setup

# Basic health check script
cat > /usr/local/bin/dns-health.sh << 'SCRIPT'
#!/bin/bash
# Operational DNS monitoring — no query logging

echo "=== Unbound Statistics ==="
echo ""

# Query volume
STATS=$(unbound-control stats_noreset)
TOTAL=$(echo "$STATS" | grep "total.num.queries=" | cut -d= -f2)
CACHED=$(echo "$STATS" | grep "total.num.cachehits=" | cut -d= -f2)

if [ -n "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
    HIT_RATE=$(awk "BEGIN {printf \"%.1f\", ($CACHED/$TOTAL)*100}")
    echo "Total queries: $TOTAL"
    echo "Cache hits: $CACHED ($HIT_RATE%)"
else
    echo "Total queries: $TOTAL"
    echo "Cache hits: N/A"
fi

# DNSSEC stats
SECURE=$(echo "$STATS" | grep "num.answer.secure=" | cut -d= -f2)
BOGUS=$(echo "$STATS" | grep "num.answer.bogus=" | cut -d= -f2)
echo "DNSSEC secure answers: $SECURE"
echo "DNSSEC bogus (rejected): $BOGUS"

# Response time
echo ""
echo "=== Response Time Test ==="
RESOLVE_TIME=$(dig @127.0.0.1 www.swisslayer.com A +noall +stats 2>&1 | \
    grep "Query time" | awk '{print $4}')
echo "Query time: ${RESOLVE_TIME}ms"

# Memory usage
echo ""
echo "=== Memory ==="
echo "$STATS" | grep "mem.cache"

# Upstream query latency
echo ""
echo "=== Upstream Health ==="
echo "$STATS" | grep "total.num.recursivereplies"
SCRIPT
chmod +x /usr/local/bin/dns-health.sh

For Prometheus-based monitoring, Unbound has a native exporter:

# Install unbound_exporter
wget "https://github.com/letsencrypt/unbound_exporter/releases/latest/download/unbound_exporter-linux-amd64"
mv unbound_exporter-linux-amd64 /usr/local/bin/unbound_exporter
chmod +x /usr/local/bin/unbound_exporter

# Systemd service
cat > /etc/systemd/system/unbound-exporter.service << 'EOF'
[Unit]
Description=Unbound Prometheus Exporter
After=unbound.service

[Service]
Type=simple
User=nobody
ExecStart=/usr/local/bin/unbound_exporter \
    -unbound.host tcp://127.0.0.1:8953 \
    -unbound.cert /etc/unbound/unbound_control.pem \
    -unbound.key /etc/unbound/unbound_control.key \
    -unbound.ca /etc/unbound/unbound_server.pem \
    -web.listen-address 127.0.0.1:9167
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable unbound-exporter
systemctl start unbound-exporter

# Prometheus scrape config (add to prometheus.yml)
# - job_name: 'unbound'
#   static_configs:
#     - targets: ['10.100.0.1:9167']

The exporter provides aggregate metrics — total queries, cache hit rates, DNSSEC validation counts, response latency distributions — without exposing individual query content. This gives you full operational visibility while preserving the privacy guarantee.

Hardening the Resolver Against Attack

A DNS resolver is an attractive target. Compromising the resolver means the attacker can redirect any domain to any IP — perfect for phishing, credential theft, or injecting malware into software update channels. Harden the resolver beyond the base Unbound configuration:

# Systemd hardening for the Unbound service
# /etc/systemd/system/unbound.service.d/hardening.conf
[Service]
# Filesystem restrictions
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/unbound /run/unbound

# Network restrictions
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
PrivateDevices=yes

# Capability restrictions
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_SYS_CHROOT
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes

# System call filtering
SystemCallFilter=@system-service
SystemCallArchitectures=native

# Memory protection
MemoryDenyWriteExecute=yes

# Misc
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectKernelLogs=yes

# Resource limits
LimitNOFILE=65535

systemctl daemon-reload
systemctl restart unbound

These systemd restrictions implement defence in depth: even if an attacker finds a vulnerability in Unbound, the compromised process cannot read files outside its working directories, cannot write to the system, cannot load kernel modules, and is restricted to network-related system calls only. The MemoryDenyWriteExecute flag prevents the classic exploit technique of writing shellcode to memory and executing it.

Additional hardening measures:

Run Unbound in a chroot: Unbound supports chroot natively (chroot: "/var/lib/unbound" in the config). Combined with systemd's ProtectSystem, this creates two layers of filesystem isolation.
Automatic root hints updates: The root hints file changes infrequently (root server IP changes happen every few years), but automate the update anyway. A monthly cron job that fetches a fresh named.root from IANA ensures you do not slowly drift out of date.
Monitor for DNS rebinding: The private-address directives in the Unbound config prevent external domains from resolving to private IP addresses. This blocks DNS rebinding attacks where an attacker's domain resolves to 127.0.0.1 or 192.168.x.x to access internal services through the victim's browser.

Multi-Server Deployment: Serving Your Entire Fleet

In a Swiss dedicated server environment with multiple machines, you do not want every server running its own recursive resolver. One resolver (or two for redundancy) serving the entire fleet is more efficient, more cacheable, and easier to maintain.

The architecture for a multi-server deployment:

Primary resolver on a dedicated small VPS — runs Unbound with full recursion, DNSSEC, DoT, DoH
Secondary resolver on a different VPS — identical Unbound configuration, provides redundancy if the primary goes down
• All other servers use stubby or systemd-resolved to forward DNS queries to the primary (with failover to secondary) over DoT through the WireGuard management network
• No DNS traffic ever leaves the WireGuard mesh unencrypted

# On each managed server: configure dual resolvers
# /etc/stubby/stubby.yml
upstream_recursive_servers:
  - address_data: 10.100.0.1        # Primary resolver
    tls_auth_name: "dns1.example.com"
    tls_port: 853
  - address_data: 10.100.0.2        # Secondary resolver
    tls_auth_name: "dns2.example.com"
    tls_port: 853

# Or with systemd-resolved
# /etc/systemd/resolved.conf
[Resolve]
DNS=10.100.0.1#dns1.example.com 10.100.0.2#dns2.example.com
FallbackDNS=
DNSOverTLS=yes
DNSSEC=yes

# Verify resolution is using your private resolvers
dig +short txt o-o.myaddr.l.google.com @127.0.0.1
# This returns the IP of the resolver that queried Google's authoritative server
# It should be your Swiss VPS IP, not a public resolver

The cache benefit of centralised resolvers is significant. When server A queries api.stripe.com, the result is cached. When server B queries the same domain 30 seconds later, it gets the cached answer instantly — no external DNS traffic at all. For common domains, this means the recursive resolver makes one external query and serves the cached answer to all servers in the fleet for the duration of the TTL.

DNS Privacy Leaks You Might Not Know About

Even with a perfectly configured encrypted resolver, DNS privacy can leak through unexpected channels:

Certificate transparency logs. When you request a TLS certificate from Let's Encrypt for your DNS resolver's hostname, the certificate is logged in public Certificate Transparency (CT) logs. Anyone can search CT logs to discover that dns.yourdomain.com has a certificate, revealing the existence of your resolver and the domain name it is associated with. Mitigation: use a generic hostname, or use IP-based TLS with self-signed certificates for internal resolvers.

Reverse DNS on the resolver's IP. If the VPS hosting your resolver has a PTR record (reverse DNS) set to a meaningful hostname, querying the resolver's IP reveals its purpose. Set PTR records to something generic or leave them as the default ISP assignment.

EDNS Client Subnet (ECS). Some authoritative DNS servers request the client's subnet to serve geographically appropriate responses (CDN steering). Without the send-client-subnet: 0.0.0.0/0 directive in Unbound, your clients' IP ranges could be forwarded to authoritative servers. The configuration above already disables this, but verify it is active.

Application-level DNS bypass. Some applications bypass the system resolver and make their own DNS queries directly. Chrome, Firefox, and Electron-based applications can be configured to use DoH endpoints independently of the system resolver. Docker containers, by default, get a copy of the host's /etc/resolv.conf, but some container images override it. Kubernetes pods have their own DNS configuration that may use CoreDNS or kube-dns rather than the host resolver. Audit every application to ensure it uses your encrypted resolver, not a fallback.

# Check for DNS leaks: all queries should go through your resolver
# Run this on each managed server

# tcpdump for plaintext DNS traffic leaving the server
# Should see ZERO packets if everything uses encrypted DNS
tcpdump -i eth0 -n port 53 -c 10 -W 1 -w /tmp/dns-leak-test.pcap &
TCPDUMP_PID=$!

# Generate some DNS traffic
for domain in google.com github.com apt.ubuntu.com pypi.org; do
    dig $domain +short > /dev/null 2>&1
done

sleep 5
kill $TCPDUMP_PID 2>/dev/null

# Analyse the capture
PACKETS=$(tcpdump -r /tmp/dns-leak-test.pcap 2>/dev/null | wc -l)
if [ "$PACKETS" -gt 0 ]; then
    echo "WARNING: $PACKETS plaintext DNS packets detected!"
    echo "DNS encryption is NOT complete. Leaking queries:"
    tcpdump -r /tmp/dns-leak-test.pcap -n 2>/dev/null
else
    echo "OK: No plaintext DNS leaks detected"
fi
rm -f /tmp/dns-leak-test.pcap

The Swiss Jurisdictional Advantage for DNS Infrastructure

Running your own DNS resolver is a technical measure. Hosting it in Switzerland is a jurisdictional one. Together, they address complementary threat vectors.

The technical measure — self-hosted recursive resolution with encrypted transports — eliminates third-party visibility into your DNS queries. No external resolver operator sees your query patterns. The queries your resolver makes to authoritative nameservers are distributed across thousands of authoritative servers worldwide, and no single server sees more than a fragment of your total query activity.

The jurisdictional measure — hosting on Swiss offshore hosting infrastructure — protects the resolver itself. If an adversary wants to monitor your DNS traffic at the network level, they need to compromise or subpoena the hosting provider. In Switzerland, this requires Swiss judicial approval under the Federal Act on the Surveillance of Post and Telecommunications (BÜPF), which mandates proportionality review by a Swiss judge. Mass surveillance orders and fishing expeditions are rejected. The request must specify the target and provide evidence of a specific criminal investigation.

For operators running high-bandwidth server infrastructure, the Swiss location also provides excellent peering connectivity to European Internet exchanges. DNS resolution speed depends partly on network latency to root and authoritative servers — Swiss data centres with direct peering to DE-CIX, AMS-IX, and SwissIX provide sub-5ms latency to most European authoritative nameservers.

This combination — no third-party resolver involvement, encrypted transport between all components, DNSSEC validation to prevent tampering, and Swiss jurisdictional protection over the infrastructure — creates a DNS architecture where DNS surveillance requires either compromising the resolver itself (protected by systemd hardening, firewall rules, and physical data centre security) or obtaining Swiss judicial cooperation (a high bar for foreign requests).

Automated Deployment: Ansible Playbook

For operators managing multiple resolver instances or deploying to new infrastructure, here is a condensed Ansible playbook that deploys the complete stack:

---
# deploy-dns-resolver.yml
- hosts: dns_resolvers
  become: yes
  vars:
    resolver_hostname: "dns.example.com"
    wireguard_subnet: "10.100.0.0/24"
    cache_msg_size: "64m"
    cache_rrset_size: "128m"

  tasks:
    - name: Install packages
      apt:
        name:
          - unbound
          - unbound-anchor
          - dns-root-data
          - dnsutils
          - certbot
          - knot-dnsutils
          - nftables
        state: present
        update_cache: yes

    - name: Fetch root trust anchor
      command: unbound-anchor -a /var/lib/unbound/root.key
      register: anchor_result
      failed_when: anchor_result.rc not in [0, 1]

    - name: Fetch root hints
      get_url:
        url: https://www.internic.net/domain/named.root
        dest: /var/lib/unbound/root.hints
        mode: '0644'

    - name: Deploy Unbound configuration
      template:
        src: templates/unbound.conf.j2
        dest: /etc/unbound/unbound.conf
        mode: '0644'
      notify: restart unbound

    - name: Generate control certificates
      command: unbound-control-setup
      args:
        creates: /etc/unbound/unbound_server.pem

    - name: Deploy systemd hardening
      copy:
        src: files/unbound-hardening.conf
        dest: /etc/systemd/system/unbound.service.d/hardening.conf
      notify:
        - daemon-reload
        - restart unbound

    - name: Deploy nftables rules
      template:
        src: templates/nftables.conf.j2
        dest: /etc/nftables.conf
      notify: reload nftables

    - name: Deploy DNS health check
      copy:
        src: files/dns-health.sh
        dest: /usr/local/bin/dns-health.sh
        mode: '0755'

    - name: Deploy blocklist updater
      copy:
        src: files/update-dns-blocklists.sh
        dest: /etc/cron.weekly/update-dns-blocklists
        mode: '0755'

    - name: Enable and start Unbound
      systemd:
        name: unbound
        enabled: yes
        state: started

    - name: Verify resolution works
      command: dig @127.0.0.1 www.swisslayer.com A +short
      register: dig_result
      failed_when: dig_result.stdout == ""
      changed_when: false

  handlers:
    - name: daemon-reload
      systemd:
        daemon_reload: yes

    - name: restart unbound
      systemd:
        name: unbound
        state: restarted

    - name: reload nftables
      systemd:
        name: nftables
        state: reloaded

What This Does Not Solve

Honesty about limitations is part of running infrastructure responsibly. Encrypted DNS resolves one specific problem — third-party visibility into your domain lookups. It does not solve:

IP-level traffic analysis: After DNS resolution, your server connects to the resolved IP address. An observer who cannot see the DNS query can still see the destination IP and infer the domain through reverse DNS or IP-to-domain correlation databases (many CDN IPs serve a single domain). Encrypted Client Hello (ECH) and domain fronting partially address this, but they are not universally deployed.
Metadata at the authoritative level: The authoritative nameservers for the domains you query still see your resolver's IP address. If you query secret-api.example.com, the authoritative server for example.com logs your resolver's Swiss IP making that query. Qname minimisation reduces this exposure (the authoritative server only sees the minimum necessary label), but does not eliminate it entirely.
SNI exposure in TLS 1.2: Before TLS 1.3 with ECH, the Server Name Indication (SNI) field in the TLS handshake exposes the domain name in plaintext, even when DNS was encrypted. TLS 1.3 is now widely deployed, and ECH is in the process of standardisation, but legacy TLS connections still leak SNI.
Compromise of the resolver itself: If an attacker gains root access to the resolver, they can enable query logging, modify DNS responses, or redirect traffic. The systemd hardening, firewall rules, and DNSSEC (which prevents the resolver from serving spoofed answers for DNSSEC-signed domains) mitigate this, but no software is invulnerable.

Privacy is layers, not absolutes. Encrypted DNS is one essential layer in a stack that should also include encrypted transport (TLS 1.3), network-level anonymity (VPN/Tor where appropriate), application-level privacy controls, and the jurisdictional protection of hosting in Switzerland.

Getting Started: The Minimum Viable Private Resolver

If the full deployment above looks like more than you need right now, here is the absolute minimum to eliminate third-party DNS visibility from your privacy hosting stack:

1. Provision a Swiss VPS — even the smallest tier is sufficient for DNS
2. Install Unbound with the privacy-focused configuration above (qname minimisation, no query logging, DNSSEC validation, no client subnet)
3. Configure your servers to use 127.0.0.1 (localhost) as the resolver if Unbound runs on the same machine, or the WireGuard IP if it runs on a dedicated instance
4. Block outbound port 53 on all servers except the resolver — this catches any application trying to bypass the resolver
5. Run the DNS leak test script above to verify no plaintext queries escape

That takes about 20 minutes and eliminates the DNS surveillance blind spot from your infrastructure. Add DoT/DoH frontends, threat blocklists, monitoring, and multi-server redundancy as your needs grow.

The DNS system was designed 40 years ago for a network where everyone trusted everyone. That network does not exist anymore. Your encrypted traffic deserves encrypted name resolution, validated against tampering, hosted on infrastructure where the query logs — if they existed, which they should not — are protected by Swiss law rather than subject to bulk surveillance orders. That is the architecture this guide builds. Deploy it, verify it, and close the last plaintext gap in your privacy stack.