Reverse proxy DoH and DoT with Caddy

Thank you @Mohammed90 and @francislavoie for your help I managed to get it working using @matt 's L4 plugin.

For future time-travellers, here’s my caddy.json and docker-compose.yml files in case you’re interested:

caddy.json:
{
  "apps": {
    "tls": {
      "certificates": {
        "automate": [
          "example.com"
        ]
      }
    },
    "layer4": {
      "servers": {
        "doh": {
          "listen": [
            ":443"
          ],
          "routes": [
            {
              "match": [
                {
                  "tls": {
                    "sni": [
                      "example.com"
                    ]
                  }
                }
              ],
              "handle": [
                {
                  "handler": "proxy",
                  "upstreams": [
                    {
                      "dial": [
                        "adguard:443"
                      ]
                    }
                  ]
                }
              ]
            }
          ]
        },
        "dot": {
          "listen": [
            ":853"
          ],
          "routes": [
            {
              "match": [
                {
                  "tls": {
                    "sni": [
                      "example.com"
                    ]
                  }
                }
              ],
              "handle": [
                {
                  "handler": "proxy",
                  "upstreams": [
                    {
                      "dial": [
                        "adguard:853"
                      ]
                    }
                  ]
                }
              ]
            }
          ]
        }
      }
    }
  }
}

Caddy docker-compose.yml:
version: "3.9"
services:
  caddy:
    image: 0xlem0nade/caddy:latest  # My custom built Caddy baked with l4 and a host of other plugins, you can substitute with any image that has l4
    restart: unless-stopped
    container_name: caddy
    cap_add:
      - NET_ADMIN
      - CAP_NET_BIND_SERVICE
      - CAP_NET_RAW
    networks:
      - caddy
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
      - "853:853"
      - "853:853/udp"
    volumes:
      - ./etc/caddy.json:/etc/caddy/caddy.json
      - ./www:/srv
      - /var/log:/var/log
      - caddy_data:/data
      - caddy_config:/config
    command: caddy run --config /etc/caddy/caddy.json

volumes:
  caddy_data:
    external: true
  caddy_config:

networks:
  caddy:
    external: true

AdGuardHome docker-compose.yml:
version: "3.9"
services:
  adguard:
    image: adguard/adguardhome:latest
    restart: unless-stopped
    container_name: adguard
    cap_add:
      - NET_ADMIN
      - CAP_NET_BIND_SERVICE
      - CAP_NET_RAW
    networks:
      - caddy
    expose:   # 'expose' instead of 'ports' to keep adguard away from the outside world and port conflicts!
      - 53
      - "53/udp"
      - 443
      - "443/udp"
      - 853
      - "853/udp"
    dns:
      - 1.1.1.1
      - 9.9.9.9
    volumes:
      - caddy_data:/etc/letsencrypt:ro   # Access Caddy-generated certs and add them to AdGuard so that TLS passes through directly to AdGuard
      - adguard_data:/opt/adguardhome/work
      - adguard_config:/opt/adguardhome/conf

volumes:
  adguard_data:
    external: true
  adguard_config:
    external: true
  caddy_data:
    external: true

networks:
  caddy:
    external: true

AdGuardHome.yml config:
# REDACTED TO THE RELEVANT PARTS!
bind_host: 0.0.0.0
dns:
  bind_hosts:
    - 0.0.0.0
  port: 53
tls:
  enabled: true
  server_name: "example.com"
  force_https: false
  port_https: 443
  port_dns_over_tls: 853
  port_dns_over_quic: 853
  allow_unencrypted_doh: false
  certificate_path: "/etc/letsencrypt/caddy/certificates/acme-v02.api.letsencrypt.org-directory/example.com/example.com.crt"
  private_key_path: "/etc/letsencrypt/caddy/certificates/acme-v02.api.letsencrypt.org-directory/example.com/example.com.key"

There was one quirk or maybe because I didn’t know how to make it work, and that was I had to add another domain to my server for this purpose and implement SNI based routing, because I run other apps on the server and I wanted to keep the domain and filter only the /dns-query to AdGuard and other paths to my other apps (although that would break TLS passthrough), saw this comment here from matt but didn’t find any examples!

Path-based routing, DNS-over-QUIC, and HTTP3 are the only things, this setup left me wishing for, other than that pretty nifty! I hope future versions bring them along!

Cheers! :wink:

3 Likes