Private DNS
    Ad Blocking

    Private Ad-Blocking DNS with Blocky and Unbound on a VPS

    Run your own filtering, validating DNS resolver on a RamNode VPS — Blocky for blocklists and caching, Unbound for recursive DNSSEC resolution.

    Public DNS resolvers see every domain you look up. Running your own resolver on a RamNode VPS gives you a private, fast, filtering nameserver that you control end to end. This guide pairs two excellent open source tools:

    • Blocky is a fast, lightweight DNS proxy written in Go. It handles ad and tracker blocking from external blocklists, per client groups, caching, custom DNS records, and metrics.
    • Unbound is a validating, recursive, caching resolver from NLnet Labs. Instead of forwarding your queries to Google or Cloudflare, it walks the DNS hierarchy from the root servers down, performing DNSSEC validation along the way.

    Chaining them means Blocky does the filtering and presents the user facing resolver, while Unbound does the actual recursion privately. No third party ever sees your full query stream.

    Architecture

    shell
    Clients --> Blocky :53 (filtering, caching) --> Unbound :5335 (recursive, DNSSEC) --> Root servers

    Unbound listens on a non standard local port so it does not collide with Blocky on port 53. Blocky uses Unbound as its single upstream.

    Prerequisites

    • A RamNode VPS running Ubuntu 24.04 LTS. DNS is light, so a small plan with 1 to 2 GB RAM is plenty.
    • Root or sudo access.
    • The VPS public IP. You will point your devices or network at this address.

    A note on open resolvers

    A DNS resolver exposed to the entire internet can be abused for amplification attacks. Do not allow the world to query your resolver. Either restrict access by source IP in the firewall to only your known networks, or front it with a VPN such as WireGuard and only let VPN clients reach port 53. This guide assumes you will lock down access; do not skip that step.

    Step 1: Install and configure Unbound

    shell
    sudo apt update
    sudo apt install -y unbound unbound-anchor dnsutils

    Stop the service while you configure it:

    shell
    sudo systemctl stop unbound

    Create /etc/unbound/unbound.conf.d/recursive.conf:

    shell
    server:
        # Listen only on loopback, on a non standard port for Blocky to use
        interface: 127.0.0.1
        port: 5335
        do-ip4: yes
        do-ip6: no
        do-udp: yes
        do-tcp: yes
    
        # Only localhost (Blocky) may query Unbound
        access-control: 127.0.0.0/8 allow
        access-control: 0.0.0.0/0 refuse
    
        # Run as the unbound user
        username: "unbound"
        directory: "/etc/unbound"
    
        # Performance and privacy
        prefetch: yes
        prefetch-key: yes
        cache-min-ttl: 60
        cache-max-ttl: 86400
        msg-cache-size: 128m
        rrset-cache-size: 256m
        aggressive-nsec: yes
        hide-identity: yes
        hide-version: yes
        qname-minimisation: yes
    
        # Serve slightly stale records while refreshing, per RFC 8767
        serve-expired: yes
        serve-expired-ttl: 86400
    
        # Logging
        verbosity: 1
        use-syslog: yes

    The validator and iterator modules are enabled by default in Ubuntu's packaging, which is what gives you DNSSEC validation plus recursion. Validate the config:

    shell
    sudo unbound-checkconf

    Start and enable Unbound:

    shell
    sudo systemctl enable --now unbound

    Test it directly:

    shell
    dig @127.0.0.1 -p 5335 example.com

    A successful answer with an ad flag on a signed domain confirms recursion and DNSSEC validation are working. Test validation explicitly against the deliberately broken test zone:

    shell
    dig @127.0.0.1 -p 5335 dnssec-failed.org

    That should return SERVFAIL, which is correct: Unbound is refusing a record that fails validation.

    Step 2: Free up port 53

    Ubuntu runs systemd-resolved with a stub listener on port 53, which will conflict with Blocky. Point the system at Unbound and disable the stub. Edit /etc/systemd/resolved.conf:

    shell
    [Resolve]
    DNS=127.0.0.1:5335
    DNSStubListener=no

    Restart and relink resolv.conf:

    shell
    sudo systemctl restart systemd-resolved
    sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf

    Confirm nothing is still bound to port 53:

    shell
    sudo ss -tulnp | grep :53

    Step 3: Install Blocky

    Blocky ships as a single static binary. Create a user and directories:

    shell
    sudo useradd -r -s /usr/sbin/nologin blocky
    sudo mkdir -p /opt/blocky

    Download the latest release for your architecture from the project's GitHub releases page, then place the binary:

    shell
    cd /tmp
    curl -fsSLO https://github.com/0xERR0R/blocky/releases/latest/download/blocky_Linux_x86_64.tar.gz
    tar xzf blocky_Linux_x86_64.tar.gz
    sudo mv blocky /opt/blocky/blocky
    sudo chown root:root /opt/blocky/blocky
    sudo chmod 755 /opt/blocky/blocky

    Because Blocky binds privileged port 53 as a non root user, grant it the capability:

    shell
    sudo setcap 'cap_net_bind_service=+ep' /opt/blocky/blocky

    Step 4: Configure Blocky

    Create /opt/blocky/config.yml:

    shell
    upstreams:
      groups:
        default:
          # Point Blocky at our local Unbound resolver
          - 127.0.0.1:5335
    
    blocking:
      denylists:
        ads:
          - https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
        trackers:
          - https://raw.githubusercontent.com/blocklistproject/Lists/master/tracking.txt
      clientGroupsBlock:
        default:
          - ads
          - trackers
    
    caching:
      minTime: 5m
      maxTime: 30m
      prefetching: true
    
    # Optional: a local A record for an internal host
    customDNS:
      mapping:
        nas.lan: 10.0.0.20
    
    ports:
      dns: 53
      http: 4000
    
    prometheus:
      enable: true
    
    log:
      level: info

    Set ownership:

    shell
    sudo chown -R blocky:blocky /opt/blocky

    The key schema points to know: upstream servers live under upstreams.groups, blocklists under blocking.denylists, and you map which groups receive which lists under blocking.clientGroupsBlock. Allowlists, if you add them, take precedence over denylists for the same domain.

    Step 5: Create a systemd unit

    Create /etc/systemd/system/blocky.service:

    shell
    [Unit]
    Description=Blocky DNS proxy
    After=network.target unbound.service
    Wants=unbound.service
    
    [Service]
    User=blocky
    Group=blocky
    ExecStart=/opt/blocky/blocky --config /opt/blocky/config.yml
    Restart=on-failure
    RestartSec=5
    AmbientCapabilities=CAP_NET_BIND_SERVICE
    
    [Install]
    WantedBy=multi-user.target

    Enable and start:

    shell
    sudo systemctl daemon-reload
    sudo systemctl enable --now blocky
    sudo systemctl status blocky

    Step 6: Test the full chain

    Query Blocky on port 53 locally:

    shell
    dig @127.0.0.1 example.com

    Confirm blocking works by querying a known ad domain. It should return 0.0.0.0 or NXDOMAIN rather than a real address:

    shell
    dig @127.0.0.1 doubleclick.net

    Blocky's REST API and metrics are on port 4000. Check that it is alive:

    shell
    curl http://127.0.0.1:4000/api/blocking/status

    Step 7: Lock down access with the firewall

    This is the step that keeps you off open resolver blocklists. Allow DNS only from the networks you trust. Replace the example CIDR with your real client network or VPN subnet:

    shell
    sudo ufw allow OpenSSH
    sudo ufw allow from 203.0.113.0/24 to any port 53 proto udp
    sudo ufw allow from 203.0.113.0/24 to any port 53 proto tcp
    sudo ufw enable

    Never use sudo ufw allow 53 with no source restriction on a public VPS. The Blocky metrics port 4000 should stay bound to localhost or be restricted the same way.

    Step 8: Point your devices at the resolver

    On each client, or on your router for whole network coverage, set the primary DNS server to your RamNode VPS public IP. Verify from a client:

    shell
    dig @your.vps.ip example.com

    Maintenance

    • Refresh blocklists. Blocky reloads lists periodically on its own. You can force a refresh through the REST API or by restarting the service.
    • Monitor. The Prometheus endpoint on port 4000 exposes query counts, cache hit rates, and block counts. Point Grafana at it for a live dashboard.
    • Update Blocky. Download the new binary, replace /opt/blocky/blocky, reapply setcap, and restart the service.
    • Update Unbound root anchor. The DNSSEC trust anchor refreshes automatically through unbound-anchor, but you can add a monthly systemd timer if you want belt and suspenders.

    Troubleshooting

    • Blocky fails to start on port 53. Something still holds the port, almost always the systemd-resolved stub. Recheck Step 2 and ss -tulnp | grep :53.
    • Queries resolve but nothing is blocked. The blocklists failed to download. Check journalctl -u blocky for fetch errors, often a DNS bootstrap problem; add a bootstrapDns entry pointing at Unbound.
    • SERVFAIL on everything. Unbound is not reachable on 127.0.0.1:5335. Confirm it is running and listening with ss -tulnp | grep 5335.

    Wrap up

    You now run a private, recursive, DNSSEC validating resolver with network wide ad and tracker blocking, entirely on your own RamNode VPS. Your DNS queries no longer flow through a third party, filtering happens before recursion, and the whole stack fits comfortably on a small instance. Combine it with WireGuard and you have a filtering resolver you can use securely from anywhere.