tutorial

Uptime monitoring for Unbound validating recursive DNS resolver

Unbound is a validating, recursive, caching DNS resolver trusted by privacy-focused organizations, ISPs, and infrastructure teams. When Unbound goes down, ev...

Unbound is a validating, recursive, caching DNS resolver trusted by privacy-focused organizations, ISPs, and infrastructure teams. When Unbound goes down, every service on the network that relies on DNS resolution — internal service discovery, API calls, database connections — stops working. Because Unbound runs as a system daemon rather than an application, it often lacks the same alerting coverage as web services.

This tutorial covers production-grade uptime monitoring for Unbound using Vigilmon. We will walk through:

  • Exposing a health endpoint from Unbound via unbound-control
  • Setting up TCP port monitoring for DNS availability
  • Heartbeat monitoring for scheduled health checks
  • Webhook alerts for DOWN/UP events

Prerequisites

  • Unbound 1.17+ running on Linux (Debian/Ubuntu or RHEL/Rocky)
  • A free account at vigilmon.online

Part 1: Enable unbound-control and verify Unbound is healthy

Unbound ships with unbound-control, a CLI tool for querying status, statistics, and managing the resolver at runtime. Enable it so you can inspect Unbound from scripts.

Configure unbound-control

# Generate the control certificates
unbound-control-setup

This writes keys and certificates to /etc/unbound/:

  • unbound_control.key
  • unbound_control.pem
  • unbound_server.key
  • unbound_server.pem

Add the remote control block to your Unbound configuration:

# /etc/unbound/unbound.conf

server:
    # Listen on all interfaces for DNS queries
    interface: 0.0.0.0
    port: 53

    # Enable DNSSEC validation
    auto-trust-anchor-file: "/var/lib/unbound/root.key"

    # Hide version string from external queries
    hide-version: yes
    hide-identity: yes

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"

Restart Unbound:

systemctl restart unbound

Verify unbound-control can connect:

unbound-control status

Expected output:

version: 1.19.3
verbosity: 1
threads: 4
modules: 3 [ validator iterator ]
uptime: 42 seconds
options: reuseport
unbound (pid 12345) is running...

Part 2: Expose a health endpoint for external monitoring

Unbound does not have a built-in HTTP server. The standard pattern is a small HTTP health server that wraps unbound-control status and returns 200/503 based on its exit code.

Python health server

#!/usr/bin/env python3
# unbound_health.py
import subprocess
from http.server import HTTPServer, BaseHTTPRequestHandler

def check_unbound():
    result = subprocess.run(
        ["unbound-control", "status"],
        capture_output=True,
        text=True,
        timeout=5
    )
    is_running = result.returncode == 0 and "is running" in result.stdout
    return is_running, result.stdout.strip()

class HealthHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/health":
            running, status_output = check_unbound()
            if running:
                self.send_response(200)
                self.send_header("Content-Type", "application/json")
                self.end_headers()
                self.wfile.write(b'{"status":"ok","resolver":"running"}')
            else:
                self.send_response(503)
                self.send_header("Content-Type", "application/json")
                self.end_headers()
                self.wfile.write(b'{"status":"error","resolver":"stopped"}')
        else:
            self.send_response(404)
            self.end_headers()

    def log_message(self, format, *args):
        pass  # suppress default access log

if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 8095), HealthHandler)
    print("Unbound health server on :8095")
    server.serve_forever()

Run it as a systemd service:

# /etc/systemd/system/unbound-health.service
[Unit]
Description=Unbound health check HTTP server
After=unbound.service
Requires=unbound.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/unbound-health/unbound_health.py
Restart=always
RestartSec=5
User=root

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now unbound-health

Verify the health endpoint

curl http://localhost:8095/health
# {"status":"ok","resolver":"running"}

Part 3: Expose the health endpoint via nginx for external monitoring

# /etc/nginx/sites-available/unbound-health
server {
    listen 443 ssl;
    server_name dns-health.example.com;

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

    # Allow only the health endpoint externally
    location /health {
        proxy_pass http://127.0.0.1:8095/health;
        proxy_set_header Host $host;
        access_log off;
    }

    # Block everything else
    location / {
        return 403;
    }
}
nginx -t && systemctl reload nginx

Verify:

curl https://dns-health.example.com/health
# {"status":"ok","resolver":"running"}

Part 4: Set up monitoring in Vigilmon

Monitor Unbound process health

  1. Log in to vigilmon.online and click Add Monitor.
  2. Choose HTTP(S) monitor.
  3. Enter: https://dns-health.example.com/health
  4. Set interval to 1 minute.
  5. Add a keyword check: must contain "status":"ok".
  6. Add your alert channel.
  7. Click Save.

Monitor DNS port availability (TCP monitor)

The HTTP health check confirms the Unbound process is running. A TCP port 53 monitor confirms DNS queries can actually be served — if a firewall rule blocks port 53 or Unbound is bound only to localhost, the health endpoint might return 200 while DNS resolution is broken for clients.

  1. In Vigilmon, click Add Monitor.
  2. Choose TCP monitor (or Port monitor).
  3. Enter: your Unbound server's public IP, port 53.
  4. Set interval to 1 minute.
  5. Add your alert channel.
  6. Click Save.

Use both monitors together:

  • HTTP health (:8095/health) = "Is the Unbound process alive?"
  • TCP port 53 = "Can DNS clients actually reach it?"

Part 5: DNS resolution heartbeat

Confirming that DNS port 53 is open does not verify that Unbound is resolving queries correctly. DNSSEC validation failures, forwarder misconfiguration, or a corrupted root hints file can cause Unbound to reject all queries while appearing to listen on port 53.

Add a heartbeat cron job that runs a real DNS query and PINGs Vigilmon on success:

#!/bin/bash
# /opt/unbound-health/check-resolution.sh

set -euo pipefail

RESOLVER="127.0.0.1"
TEST_DOMAIN="cloudflare.com"
VIGILMON_HEARTBEAT_URL="https://vigilmon.online/api/heartbeat/your-unbound-heartbeat-id"

# Perform a real DNS query via Unbound
RESULT=$(dig +short @${RESOLVER} ${TEST_DOMAIN} A 2>&1)

if echo "$RESULT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
  # Query returned a valid IP — ping Vigilmon heartbeat
  curl -s -o /dev/null "$VIGILMON_HEARTBEAT_URL"
  echo "$(date): OK - resolved ${TEST_DOMAIN} to ${RESULT}"
else
  echo "$(date): FAIL - unexpected result: ${RESULT}" >&2
  exit 1
fi
chmod +x /opt/unbound-health/check-resolution.sh

Add to cron:

# /etc/cron.d/unbound-resolution-check
* * * * * root /opt/unbound-health/check-resolution.sh >> /var/log/unbound-health.log 2>&1

In Vigilmon, configure the heartbeat monitor with a 3-minute window — if Vigilmon does not receive a ping within 3 minutes of the expected 1-minute interval, it sends a DOWN alert.


Part 6: Enhanced health check with DNSSEC validation

For environments where DNSSEC validation is critical, extend the resolution check to verify that DNSSEC-signed zones validate correctly:

#!/bin/bash
# check-dnssec.sh

RESOLVER="127.0.0.1"
DNSSEC_DOMAIN="dnssec.works"  # A domain known to require DNSSEC validation
VIGILMON_HEARTBEAT="https://vigilmon.online/api/heartbeat/your-dnssec-heartbeat-id"

# Check a domain that must pass DNSSEC validation
RESULT=$(dig +dnssec +short @${RESOLVER} ${DNSSEC_DOMAIN} A 2>&1)
STATUS=$?

# Check a domain with a deliberately broken DNSSEC signature
BROKEN_RESULT=$(dig +short @${RESOLVER} dnssec-failed.org A 2>&1)

# Successful validation: DNSSEC_DOMAIN resolves, broken domain does not
if [ $STATUS -eq 0 ] && echo "$RESULT" | grep -qE '[0-9]+\.[0-9]+' && [ -z "$BROKEN_RESULT" ]; then
  curl -s -o /dev/null "$VIGILMON_HEARTBEAT"
  echo "$(date): DNSSEC validation OK"
else
  echo "$(date): DNSSEC validation FAIL - valid: ${RESULT}, broken: ${BROKEN_RESULT}" >&2
  exit 1
fi

Part 7: Docker deployment

# docker-compose.yml
version: "3.8"

services:
  unbound:
    image: mvance/unbound:latest
    ports:
      - "53:53/udp"
      - "53:53/tcp"
    volumes:
      - ./unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
    restart: unless-stopped

  unbound-health:
    image: python:3.12-slim
    command: python /app/unbound_health.py
    ports:
      - "8095:8095"
    volumes:
      - ./unbound_health.py:/app/unbound_health.py:ro
    # Override health check to use the process-level approach for Docker
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8095/health"]
      interval: 30s
      timeout: 5s
      retries: 3
    depends_on:
      - unbound
    restart: unless-stopped

Kubernetes deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: unbound
  namespace: dns
spec:
  replicas: 2
  selector:
    matchLabels:
      app: unbound
  template:
    metadata:
      labels:
        app: unbound
    spec:
      containers:
        - name: unbound
          image: mvance/unbound:latest
          ports:
            - containerPort: 53
              protocol: UDP
            - containerPort: 53
              protocol: TCP

        - name: health
          image: python:3.12-slim
          command: ["python", "/app/unbound_health.py"]
          ports:
            - containerPort: 8095
          livenessProbe:
            httpGet:
              path: /health
              port: 8095
            initialDelaySeconds: 10
            periodSeconds: 30
          readinessProbe:
            httpGet:
              path: /health
              port: 8095
            initialDelaySeconds: 5
            periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: unbound-health
  namespace: dns
spec:
  selector:
    app: unbound
  ports:
    - name: health
      port: 8095
      targetPort: 8095
  type: ClusterIP

Part 8: SSL certificate monitoring

If your health endpoint is behind TLS (recommended for production):

  1. In Vigilmon, click Add Monitor.
  2. Choose SSL monitor.
  3. Enter: dns-health.example.com
  4. Set alert threshold to 14 days before expiry.
  5. Add your alert channel.

Part 9: Webhook alerts

// webhook-receiver.ts
import express from 'express';

const app = express();
app.use(express.json());

app.post('/webhook/vigilmon', (req, res) => {
  const { monitor_name, status, url, response_code, checked_at } = req.body;

  if (status === 'down') {
    console.error('[VIGILMON] Unbound monitor DOWN', {
      monitor: monitor_name,
      url,
      code: response_code,
      at: checked_at,
    });

    // DNS failures are P1 — page on-call immediately
    pageOnCall({
      title: `Unbound DNS resolver is DOWN`,
      severity: 'critical',
      details: `Monitor: ${monitor_name}\nURL: ${url}\nHTTP: ${response_code}`,
    });
  } else if (status === 'up') {
    console.info('[VIGILMON] Unbound recovered', { monitor: monitor_name });
    resolveIncident({ monitor: monitor_name });
  }

  res.sendStatus(204);
});

app.listen(3000);

Vigilmon sends this payload on DOWN and UP transitions:

{
  "monitor_id": "mon_abc123",
  "monitor_name": "Unbound DNS Health",
  "status": "down",
  "url": "https://dns-health.example.com/health",
  "checked_at": "2026-06-30T09:00:00Z",
  "response_code": 503,
  "response_time_ms": 5003
}

Summary

Your Unbound deployment now has four layers of monitoring:

  1. Process health endpoint — a sidecar HTTP server wraps unbound-control status and returns 200/503; Vigilmon polls it every 60 seconds to confirm the resolver is alive.
  2. TCP port 53 monitor — confirms DNS clients can actually reach Unbound on port 53, independent of the health sidecar.
  3. DNS resolution heartbeat — a cron job runs a real DNS query every minute and pings Vigilmon's heartbeat endpoint on success; a missed ping triggers a DOWN alert.
  4. DNSSEC validation check — an extended heartbeat verifies that DNSSEC validation is working correctly, not just that port 53 is open.

Vigilmon handles check scheduling, multi-region polling, alert routing, and uptime history — so you know within 60 seconds when Unbound fails, before your services do.


Monitor your Unbound DNS resolver free at vigilmon.online

#unbound #dns #dnssec #devops #monitoring #infrastructure

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →