Sys-blocky automated installation (5min you're done!)

Blocky vs Pi-hole: Key Advantages

  • Lightweight - Single Go binary (vs Pi-hole’s PHP/SQLite/dnsmasq stack)
  • Qubes-optimized - Native NFTables support & vif* interface handling
  • No web UI - Reduced attack surface (Pi-hole’s admin portal is a risk)
  • Simpler maintenance - Config = one YAML file (vs Pi-hole’s multiple configs/SQL DB)
  • Built for containers - Statically compiled Go binary works better in Qubes VMs
  • Native Prometheus - Metrics without add-ons (Pi-hole needs exporters)

Ideal for Qubes because:

  • Minimal template bloat
  • Secure by design (no unnecessary services)
  • Easier to firewall
  • Clean integration with Qubes networking

Pi-hole drawbacks in Qubes

  • Heavy dependencies (200MB+ footprint)
  • Web UI requires opening ports
  • dnsmasq often conflicts with Qubes networking
  • Complex backup/restore

Blocky delivers equivalent ad-blocking with Qubes-friendly architecture.

:zap: Quick Start
> Copy the script and run it from dom0 then:
Set other VMs to use sys-blocky as their NetVM.

Done! Ads/trackers blocked—zero performance overhead.

What does the script do?

  • Creates a dedicated Qubes OS VM (sys-blocky) for DNS blocking.
  • Sets up a minimal Debian template with Go and core dependencies.
  • Compiles and installs Blocky (optimized Go binary) as a systemd service.
  • Configures NFTables firewall rules to redirect VM DNS traffic
  • Blocks ads/trackers using pre-configured denylists (StevenBlack/hosts etc.).
  • Ensures reboot persistence via rc.local.d.
  • Provides native Prometheus metrics for monitoring.
  • Replaces Pi-hole with lower overhead and tighter Qubes integration.

Result: A lightweight, secure, self-contained DNS server for all Qubes VMs.

#!/bin/bash
set -euo pipefail

TEMPLATE_NAME="debian-12-minimal"
CLONED_TEMPLATE="d12m-blk-template"
VM_NAME="sys-blocky"
NETVM="sys-net"
MEMORY=1000
MAXMEM=2000
VCPUS=2
ADDITIONAL_SIZE="15G"
BLOCKY_REPO="https://github.com/0xERR0R/blocky.git"
BLOCKY_DEST="/opt/blocky"
BLOCKY_BIN="/usr/local/bin/blocky"
GO_VERSION="1.24.2"
LOG_FILE="/var/log/blocky_setup.log"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'

log() {
    local level="$1"
    local message="$2"
    local color
    
    case "$level" in
        "INFO") color="${BLUE}[*]${NC}" ;;
        "SUCCESS") color="${GREEN}[✓]${NC}" ;;
        "WARNING") color="${YELLOW}[!]${NC}" ;;
        "ERROR") color="${RED}[✗]${NC}" ;;
        *) color="${BLUE}[*]${NC}" ;;
    esac
    
    echo -e "${color} ${message}${NC}" | tee -a "$LOG_FILE"
}

check_dependencies() {
    log "INFO" "Checking dependencies..."
    command -v qvm-clone >/dev/null 2>&1 || {
        log "ERROR" "Qubes OS tools not found!"
        exit 1
    }
    log "SUCCESS" "Dependencies verified"
}

prepare_template() {
    if ! qvm-ls --raw-list | grep -q "$TEMPLATE_NAME"; then
        log "INFO" "Installing base template $TEMPLATE_NAME..."
        qvm-template install "$TEMPLATE_NAME" || {
            log "ERROR" "Failed to install base template"
            exit 1
        }
    else
        log "INFO" "Base template $TEMPLATE_NAME exists. Updating..."
        qvm-run -p -u root "$TEMPLATE_NAME" "apt update && apt upgrade -y" || {
            log "WARNING" "Base template update failed (continuing)"
        }
    fi

    if ! qvm-ls --raw-list | grep -q "$CLONED_TEMPLATE"; then
        log "INFO" "Cloning $TEMPLATE_NAME to $CLONED_TEMPLATE..."
        qvm-clone "$TEMPLATE_NAME" "$CLONED_TEMPLATE" || {
            log "ERROR" "Template clone failed"
            exit 1
        }
        
        log "INFO" "Installing essential packages..."
        qvm-run -u root "$CLONED_TEMPLATE" "apt update && apt install -y git wget" || {
            log "ERROR" "Package installation failed"
            exit 1
        }
        qvm-shutdown --wait "$CLONED_TEMPLATE"
        log "SUCCESS" "Template cloned and configured"
    else
        log "WARNING" "Template $CLONED_TEMPLATE already exists. Checking state..."
        
        if ! qvm-run -p "$CLONED_TEMPLATE" "dpkg -l git wget" >/dev/null 2>&1; then
            log "INFO" "Installing packages in existing template..."
            qvm-run -u root "$CLONED_TEMPLATE" "apt update && apt install -y git wget" && \
            qvm-shutdown --wait "$CLONED_TEMPLATE" || {
                log "ERROR" "Failed to update existing template"
                exit 1
            }
        fi
        log "SUCCESS" "Using existing cloned template"
    fi
}

create_blocky_vm() {
    if qvm-ls --raw-list | grep -q "$VM_NAME"; then
        log "WARNING" "VM $VM_NAME exists. Removing previous version..."
        qvm-remove --force "$VM_NAME" || {
            log "ERROR" "Failed to remove existing VM"
            exit 1
        }
        log "SUCCESS" "Old VM removed"
    fi

    log "INFO" "Creating new VM $VM_NAME..."
    qvm-create --standalone -t "$CLONED_TEMPLATE" -l red "$VM_NAME" || {
        log "ERROR" "VM creation failed"
        exit 1
    }
    
    qvm-prefs "$VM_NAME" memory "$MEMORY"
    qvm-prefs "$VM_NAME" maxmem "$MAXMEM"
    qvm-prefs "$VM_NAME" vcpus "$VCPUS"
    qvm-prefs "$VM_NAME" netvm "$NETVM"
    qvm-prefs "$VM_NAME" provides_network true
    
    log "SUCCESS" "VM $VM_NAME created and configured"
}

install_components() {
    log "INFO" "Installing Go $GO_VERSION..."
    qvm-run -p -u root "$VM_NAME" "bash -c '
        wget -q https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz -O /tmp/go.tar.gz &&
        tar -C /usr/local -xzf /tmp/go.tar.gz &&
        echo \"export PATH=\\\$PATH:/usr/local/go/bin\" >> /etc/profile &&
        echo \"export PATH=\\\$PATH:/usr/local/go/bin\" >> /home/user/.bashrc &&
        rm -f /tmp/go.tar.gz
    '" || {
        log "ERROR" "Go installation failed"
        exit 1
    }
    
    log "INFO" "Installing Blocky..."
    ARCH=$(qvm-run -p "$VM_NAME" "uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/;s/armv7l/armv7/'") || {
        log "ERROR" "Failed to detect architecture"
        exit 1
    }
    
    qvm-run -p -u root "$VM_NAME" "bash -c '
        set -e
        rm -rf \"$BLOCKY_DEST\" && git clone --depth 1 \"$BLOCKY_REPO\" \"$BLOCKY_DEST\"
        cd \"$BLOCKY_DEST\"
        /usr/local/go/bin/go build \
            -ldflags=\"-X '\''github.com/0xERR0R/blocky/util.Version=$GO_VERSION'\'' \
                      -X '\''github.com/0xERR0R/blocky/util.BuildTime=\$(date +%Y-%m-%dT%H:%M:%SZ)'\'' \
                      -X '\''github.com/0xERR0R/blocky/util.Architecture=$ARCH'\''\" \
            -o \"$BLOCKY_BIN\"
    '" || {
        log "ERROR" "Blocky installation failed"
        exit 1
    }
    
    log "SUCCESS" "Components installed"
}

configure_services() {
    log "INFO" "Creating Blocky config..."
    qvm-run -p -u root "$VM_NAME" "bash -c '
        mkdir -p /etc/blocky &&
        cat > /etc/blocky/config.yml <<\"EOF\"
upstreams:
  groups:
    default: 
      - 46.227.67.134 #OVPN
      - 192.165.9.158 #OVPN
#       - 1.1.1.1
#       - 8.8.8.8
ports:
  dns: 53

blocking:
  denylists:
    ads:
      - \"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts\"
      - \"https://s3.amazonaws.com/lists.disconnect.me/simple_ad.txt\"
      - \"http://sysctl.org/camaleon/hosts\"
      - \"https://s3.amazonaws.com/lists.disconnect.me/simple_tracking.txt\"
 #   custom:
 #    - file://etc/blocky/local-blacklist.txt
  clientGroupsBlock:
    default:
      - \"ads\"
 #    - \"custom\"
#  blockType: ZeroIp

#prometheus:
#  enable: true
#port: 53
#httpPort: 4000
EOF
    '" || {
        log "ERROR" "Blocky configuration failed"
        exit 1
    }
    
    log "INFO" "Configuring systemd service..."
    qvm-run -p -u root "$VM_NAME" "bash -c '
        cat > /etc/systemd/system/blocky.service <<\"EOF\"
[Unit]
Description=Blocky DNS
After=network.target

[Service]
ExecStart=/usr/local/bin/blocky --config /etc/blocky/config.yml
Restart=always
User=root

[Install]
WantedBy=multi-user.target
EOF
        systemctl daemon-reload &&
        systemctl enable --now blocky
    '" || {
        log "ERROR" "Service configuration failed"
        exit 1
    }
    
    log "SUCCESS" "Services configured"
}

setup_persistence() {
    log "INFO" "Configuring rc.local.d..."
    qvm-run -p -u root "$VM_NAME" "bash -c '
        mkdir -p /rw/config/rc.local.d &&
        cat > /rw/config/rc.local.d/blocky-start.sh <<\"EOF\"
#!/bin/bash
sudo systemctl unmask blocky.service
sudo systemctl daemon-reload
sudo systemctl enable --now blocky.service
EOF
        chmod +x /rw/config/rc.local.d/blocky-start.sh
    '" || {
        log "ERROR" "rc.local.d setup failed"
        exit 1
    }
    
    log "INFO" "Configuring rc.local..."
    qvm-run -p -u root "$VM_NAME" "bash -c '
        echo -e \"#!/bin/bash\\nexec /rw/config/rc.local.d/blocky-start.sh\" > /rw/config/rc.local &&
        chmod +x /rw/config/rc.local
    '" || {
        log "ERROR" "rc.local setup failed"
        exit 1
    }
    
    log "SUCCESS" "Persistence configured"
}

configure_firewall() {
    log "INFO" "Configuring firewall rules..."
    qvm-run -p -u root "$VM_NAME" "bash -c '
        mkdir -p /rw/config/{network-hooks.d,qubes-firewall.d} &&
        
        cat > /rw/config/network-hooks.d/internalise.sh <<\"EOF\"
#!/bin/sh
find /proc/sys/net/ipv4/conf -name \"vif*\" -exec bash -c \"echo 1 | tee {}/route_localnet\" \;
EOF
        
        cat > /rw/config/network-hooks.d/update_nft.sh <<\"EOF\"
#!/bin/sh
nft -f /rw/config/qubes-firewall.d/update_nft.nft
EOF
        
        cat > /rw/config/qubes-firewall.d/internalise.sh <<\"EOF\"
#!/bin/sh
find /proc/sys/net/ipv4/conf -name \"vif*\" -exec bash -c \"echo 1 | tee {}/route_localnet\" \;
EOF
        
        cat > /rw/config/qubes-firewall.d/update_nft.sh <<\"EOF\"
#!/bin/sh
nft -f /rw/config/qubes-firewall.d/update_nft.nft
EOF
        
        cat > /rw/config/qubes-firewall.d/update_nft.nft <<\"EOF\"
#!/usr/sbin/nft -f
flush chain qubes dnat-dns

flush chain qubes custom-forward
insert rule qubes custom-forward tcp dport 53 drop
insert rule qubes custom-forward udp dport 53 drop

flush chain qubes custom-input
insert rule qubes custom-input tcp dport 53 accept
insert rule qubes custom-input udp dport 53 accept

flush chain qubes dnat-dns
insert rule qubes dnat-dns iifname \"vif*\" tcp dport 53 dnat to 127.0.0.1
insert rule qubes dnat-dns iifname \"vif*\" udp dport 53 dnat to 127.0.0.1
EOF
        
        chmod +x /rw/config/rc.local \
                /rw/config/qubes-firewall.d/* \
                /rw/config/network-hooks.d/*
    '" || {
        log "ERROR" "Firewall configuration failed"
        exit 1
    }
    
    log "SUCCESS" "Firewall configured"
}

finalize_setup() {
    log "INFO" "Disabling unnecessary services..."
    qvm-features "$VM_NAME" service.cups 0
    qvm-features "$VM_NAME" service.cups-browsed 0
    
    log "INFO" "Expanding storage..."
    qvm-shutdown --wait "$VM_NAME"
    qvm-start "$VM_NAME"
    
    log "INFO" "Verifying installation..."
    qvm-run -p -u root "$VM_NAME" "systemctl status blocky"
    qvm-run -p -u root "$VM_NAME" "blocky version"

    log "SUCCESS" "Setup complete!"
    echo -e "${CYAN}╔═════════════════════════════════════╗"
    echo -e "║   BLOCKY INSTALLATION COMPLETE!   ║"
    echo -e "╠═════════════════════════════════════╣"
    echo -e "║ • Use sys-blocky as NetVM           ║"
    echo -e "║ • Live journal started              ║"
    echo -e "╚═════════════════════════════════════╝${NC}"
    log "INFO" "Starting live journal..."
    qvm-run -p -u root "$VM_NAME" "xterm -T 'BLOCKY LIVE JOURNAL' -e 'journalctl -u blocky -f'"   
}

main() {
    check_dependencies
    prepare_template
    create_blocky_vm
    install_components
    configure_services
    setup_persistence
    configure_firewall
    finalize_setup
}

main "$@"

6 Likes

Thanks for the guide. I’ve never heard about blocky before.

Is it this project? GitHub - 0xERR0R/blocky: Fast and lightweight DNS proxy as ad-blocker for local network with many features

1 Like

Yes, its an amazing project! you can use it with prometheus to generate dashboards with grafana and have full monitoring stacks

1 Like

Tested the script in a relatively clean install of R4.2.4: it failed with «:x: Go installation failed».
Looking at the running sys-blocky left by the failed install - it does not have an IP address, so the wget attempt to download the Go tarball obviously fails…

I wonder why the script is trying to download Go although it’s packaged in all templates supported by Qubes OS :thinking:

2 Likes

Great question! I assumed that a certain Go version is required, but “assuming” is bad in general. :smile:

1 Like

Thanks for the guide! This looks promising. Since the script assigns NETVM="sys-net", I assume the setup will be appVM -> sys-firewall -> sys-blocky -> sys-net. I’d also be interested in filtering DNS traffic that is tunneled through each of my sys-vpn’s, so presumably one would need an additional upstream sys-blocky VM for each sys-vpn.

I wonder if it would be possible to create an off-chain sys-blocky DNS qube (similar to having pi-hole on a separate raspberry pi device) to filter traffic using qvm-connect-tcp? I’m still baffled by DNS in Qubes, so forgive me if this question is impossibly naive… I was able to prototype something along these lines with opensnitch nodes, but opensnitch implements a client-server model to enable this possibility.

1 Like

The script does not work, there are missing networking connection to install go. To change that, correct the following lines:
Line 72:
qvm-run -u root “$CLONED_TEMPLATE” “apt update && apt install -y qubes-core-agent-networking git wget” || {

Line 81:
if ! qvm-run -p “$CLONED_TEMPLATE” “dpkg -l qubes-core-agent-networking git wget” >/dev/null 2>&1; then

Line 83:
qvm-run -u root “$CLONED_TEMPLATE” “apt update && apt install -y qubes-core-agent-networking git wget” && \

1 Like

would it make more sense to fetch the pre-compiiled binary from the releases page and to provide an additional script to update it as needed

It has a different threat model, but I don’t think it’s worse than installing Go compiler and compiling the binary.