nixos-vps/docs/superpowers/specs/2026-03-18-adguard-home-design.md
ashisgreat22 4eeeef121e docs: add explicit podman dependency in implementation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 19:03:57 +01:00

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

  1. External client sends DoH request to https://dns.ashisgreat.xyz/dns-query/CLIENTID
    • Example: https://dns.ashisgreat.xyz/dns-query/xK9mP2vL7nQ4
  2. Nginx handles TLS termination and proxies to AdGuard container on 127.0.0.1:3000
  3. AdGuard validates the ClientID in the request path
  4. Unknown ID → Connection dropped
  5. 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-data named 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 runtime
  • sops-nix - secrets management
  • services.nginx - reverse proxy (shared with other modules)