10 KiB
10 KiB
AdGuard Home Module Design
Date: 2026-03-18 Status: Approved
Overview
Add a NixOS module for AdGuard Home that provides private DNS-over-HTTPS (DoH) with ClientID-based access control. Only requests with recognized ClientIDs are processed; unknown requests are dropped.
Requirements
- AdGuard Home running in Podman container
- DoH via nginx at
dns.ashisgreat.xyz - ClientID-based authentication (acts as password for DNS)
- ClientIDs stored as SOPS secrets
- Upstream DNS: Mullvad DoH (
https://dns.mullvad.net/dns-query) - Port 53 blocked externally (already handled by existing firewall)
- No admin UI exposed externally
Module Options
myModules.adguard = {
enable = lib.mkEnableOption "AdGuard Home DNS server";
domain = lib.mkOption {
type = lib.types.str;
example = "dns.example.com";
description = "Public domain name for DoH endpoint";
};
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Internal port for AdGuard HTTP/DoH listener";
};
upstreamDoh = lib.mkOption {
type = lib.types.str;
default = "https://dns.mullvad.net/dns-query";
description = "Upstream DoH server URL";
};
bootstrapDns = lib.mkOption {
type = lib.types.listOf (lib.types.str);
default = [ "194.242.2.2" "2a07:e340::2" ];
description = "Bootstrap DNS servers for resolving DoH upstream";
};
clients = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Friendly name for the client device";
};
idSecret = lib.mkOption {
type = lib.types.str;
description = "SOPS secret name containing the ClientID";
};
};
});
default = [ ];
description = "List of clients with their ClientID secrets";
};
};
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ VPS Host │
│ │
│ ┌─────────────┐ ┌─────────────────────────────────────┐ │
│ │ nginx │ │ adguard container │ │
│ │ (host) │ │ ┌─────────────────────────────┐ │ │
│ │ │ │ │ AdGuard Home │ │ │
│ │ dns.ashis │─────▶│ │ │ │ │
│ │ .great.xyz │:3000 │ │ - DoH server on :3000 │ │ │
│ │ │ │ │ - Validates ClientIDs │ │ │
│ │ /dns-query/ │ │ │ - Proxies to Mullvad DoH │ │ │
│ │ {clientId} │ │ └─────────────────────────────┘ │ │
│ └─────────────┘ │ ▲ │ │
│ │ │ │ read-only │ │
│ │ │ ┌──────┴──────┐ │ │
│ │ │ │AdGuardHome. │ ← generated by │ │
│ │ │ │ yaml │ Nix from SOPS │ │
│ │ │ └─────────────┘ │ │
│ │ │ │ │
│ │ │ ┌─────────────┐ │ │
│ │ │ │ data/ │ ← persistent vol │ │
│ │ │ │ (stats,logs)│ │ │
│ │ │ └─────────────┘ │ │
│ │ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Firewall │ │
│ │ │ │
│ │ Block 53/udp│ ← External port 53 blocked │
│ │ Block 53/tcp│ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Request Flow
- External client sends DoH request to
https://dns.ashisgreat.xyz/dns-query/CLIENTID- Example:
https://dns.ashisgreat.xyz/dns-query/xK9mP2vL7nQ4
- Example:
- Nginx handles TLS termination and proxies to AdGuard container on
127.0.0.1:3000 - AdGuard validates the ClientID in the request path
- Unknown ID → Connection dropped
- Valid ID → Query resolved via Mullvad DoH upstream
ClientID Configuration
SOPS Secrets
ClientIDs are stored encrypted in secrets/secrets.yaml:
adguard_client_phone: "xK9mP2vL7nQ4"
adguard_client_laptop: "jH3fR8wT5yB1"
Module Configuration
In configuration.nix:
myModules.adguard = {
enable = true;
domain = "dns.ashisgreat.xyz";
clients = [
{ name = "phone"; idSecret = "adguard_client_phone"; }
{ name = "laptop"; idSecret = "adguard_client_laptop"; }
];
};
# SOPS secret declarations (auto-created by module or manual)
sops.secrets.adguard_client_phone = { };
sops.secrets.adguard_client_laptop = { };
Implementation Details
Dependencies
config = lib.mkIf cfg.enable {
myModules.podman.enable = true; # Container runtime
...
};
Container Definition
virtualisation.oci-containers.containers."adguard" = {
image = "docker.io/adguard/adguardhome:latest";
ports = [ "127.0.0.1:${toString cfg.port}:3000/tcp" ];
extraOptions = [
"--cap-drop=ALL"
"--read-only"
"--tmpfs=/tmp"
];
volumes = [
"${config.sops.templates."adguardhome.yaml".path}:/opt/adguardhome/conf/AdGuardHome.yaml:ro"
"adguard-data:/opt/adguardhome/work"
];
};
Notes:
- Container runs with minimal capabilities (
--cap-drop=ALL) - Config file is read-only (managed by Nix/SOPS)
adguard-datanamed volume persists stats and query logs (no host directory needed)
SOPS Template for AdGuardHome.yaml
sops.templates."adguardhome.yaml" = {
content = ''
http:
address: 0.0.0.0:3000
dns:
upstream_dns:
- ${cfg.upstreamDoh}
bootstrap_dns:
${lib.concatStringsSep "\n " (map (d: "- ${d}") cfg.bootstrapDns)}
querylog_enabled: true
querylog_file_enabled: true
statistics_enabled: true
clients:
persistent:
${lib.concatStringsSep "\n " (
map (client: ''
- name: ${client.name}
ids:
- ${config.sops.placeholder.${client.idSecret}}
'') cfg.clients
)}
filtering:
protection_enabled: true
filtering_enabled: true
safebrowsing:
enabled: false
parental:
enabled: false
safesearch:
enabled: false
log:
file: ""
max_backups: 0
max_size: 100
compress: false
local_time: false
verbose: false
'';
};
SOPS Secret Declarations (Auto-generated)
The module automatically declares SOPS secrets for each client:
sops.secrets = lib.mkMerge (
map (client: {
${client.idSecret} = { };
}) cfg.clients
);
Nginx Configuration
The module configures nginx directly (not via the nginx module) because DoH requires special handling:
services.nginx.virtualHosts."${cfg.domain}" = {
enableACME = true;
forceSSL = true;
# Regex location to match /dns-query and /dns-query/{clientId}
locations."~ ^/dns-query" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
extraConfig = ''
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# DoH uses POST with application/dns-message
proxy_pass_request_body on;
proxy_set_header Content-Type "application/dns-message";
# Buffer settings for DNS queries
proxy_buffers 8 16k;
proxy_buffer_size 32k;
'';
};
# Block all other paths including admin UI
locations."/" = {
return = "404";
};
};
# Ensure nginx user can access ACME certs
users.users.nginx.extraGroups = [ "acme" ];
Security Notes:
- ClientIDs appear in URL paths and may be logged by nginx
- Consider filtering nginx logs to redact ClientIDs if needed
- Admin UI is completely blocked (returns 404)
- No rate limiting configured (can be added if abuse occurs)
Firewall
No changes needed. Port 53 is already blocked by the existing firewall configuration since it's not in allowedTCPPorts. Only ports 22 (SSH), 80, and 443 (via nginx) are open.
Files to Create/Modify
| File | Action |
|---|---|
modules/adguard.nix |
Create - new module |
modules/default.nix |
Modify - add import |
configuration.nix |
Modify - enable module with clients |
secrets/secrets.yaml |
Modify - add ClientID secrets |
Dependencies
myModules.podman- container runtimesops-nix- secrets managementservices.nginx- reverse proxy (shared with other modules)