Post

Run REALITY and Hysteria2 on port 443 with Traefik, HAProxy, sing-box, and optional Pi-hole filtering

A complete, secret-free guide for sharing port 443 between Traefik web services, REALITY over TCP, and Hysteria2 over UDP, with direct Cloudflare DoH and Pi-hole-filtered client profiles backed by cloudflared upstream DoH.

Run REALITY and Hysteria2 on port 443 with Traefik, HAProxy, sing-box, and optional Pi-hole filtering

This guide documents a more advanced proxy layout than a normal reverse proxy. The goal is to keep Traefik serving public websites while also running two censorship-resistant proxy transports on the same public port number:

  • REALITY / VLESS over TCP 443 for reliable TCP-based tunneling.
  • Hysteria2 over UDP 443 for fast QUIC/UDP-based tunneling.
  • Traefik still routes normal HTTPS websites.
  • Pi-hole can optionally filter DNS inside the tunnel.
  • cloudflared encrypts Pi-hole’s upstream DNS to Cloudflare DoH.

The final shape looks like this:

1
2
3
4
5
6
7
8
9
10
11
Internet
  │
  ├─ TCP/443
  │    ↓
  │  HAProxy SNI splitter
  │    ├─ SNI = www.microsoft.com  → sing-box REALITY backend
  │    └─ any other HTTPS SNI      → Traefik
  │
  └─ UDP/443
       ↓
     sing-box Hysteria2

Then client DNS can run in either mode:

1
2
Mode A: Direct secure DNS
Client → tunnel → Cloudflare DoH

or:

1
2
Mode B: Filtered DNS
Client → tunnel → Pi-hole → cloudflared → Cloudflare DoH

This post explains the technologies, why each part exists, what files are edited, how Traefik changes, and how to build both client profiles.

All domains, public IPs, UUIDs, keys, and passwords in this article are placeholders. Do not publish your real origin IP address, real domain, REALITY private key, Hysteria2 password, Cloudflare token, or Pi-hole password.


This setup builds on a few other homelab building blocks:

Read those first if you do not already have Docker, Traefik, and a shared proxy network.


What each technology does

Traefik

Traefik is the HTTPS reverse proxy for normal web applications. It watches Docker labels, terminates TLS, and forwards requests to containers.

In a simple setup Traefik owns public ports 80 and 443 directly:

1
Internet → Traefik TCP/443 → web containers

In this setup, Traefik still routes websites, but it no longer owns public TCP/443 directly. HAProxy receives TCP/443 first and forwards normal HTTPS traffic to Traefik internally.

HAProxy SNI splitter

HAProxy is used here as a TCP router, not as an HTTP reverse proxy.

It peeks at the TLS ClientHello and reads the SNI field. Based on that SNI:

  • If the SNI is the REALITY camouflage hostname, HAProxy forwards the connection to sing-box REALITY.
  • Otherwise, HAProxy forwards it to Traefik.

This lets REALITY and Traefik share public TCP/443.

sing-box

sing-box is the proxy engine used for both server and client profiles. It can run multiple proxy protocols and has modern DNS routing support.

In this design sing-box provides:

  • A VLESS + REALITY inbound for TCP/443 traffic routed by HAProxy.
  • A Hysteria2 inbound for UDP/443.
  • Client-side TUN mode profiles for Android or desktop.
  • DNS routing rules for direct Cloudflare DoH or Pi-hole-filtered DNS.

REALITY

REALITY is a TLS-like transport designed to blend in with normal TLS traffic. The client uses a public decoy SNI such as www.microsoft.com, and the server validates REALITY keys and short IDs.

In this architecture:

1
2
3
4
5
Client REALITY connection
  ↓ TCP/443
HAProxy sees SNI www.microsoft.com
  ↓
sing-box REALITY backend

REALITY is useful when UDP is blocked or unreliable because it rides on TCP/443.

Hysteria2

Hysteria2 is a QUIC/UDP-based proxy protocol. It can be fast and resilient on lossy links, but it depends on UDP reachability.

It can share the same public port number because TCP and UDP are separate protocol families:

1
2
TCP/443 → HAProxy → Traefik or REALITY
UDP/443 → Hysteria2

If a mobile carrier, school, hotel, or corporate network blocks UDP/443, Hysteria2 may fail while REALITY still works.

Pi-hole

Pi-hole is DNS filtering. It can block ads, trackers, malware domains, or custom domains, and it can enforce SafeSearch using DNS rewrites.

In the filtered mode, the client sends DNS through the proxy tunnel to the server-side Pi-hole:

1
Client DNS → tunnel → Pi-hole → upstream resolver

cloudflared

cloudflared runs as Pi-hole’s encrypted upstream DNS forwarder. Pi-hole still receives and filters DNS first, including SafeSearch rewrites and local block rules. Only allowed queries are forwarded to cloudflared, which sends them to Cloudflare DoH over HTTPS.

This replaced the older encrypted-DNS forwarder in the final design because that stack repeatedly entered socket-saturation states under this workload. cloudflared’s DNS proxy mode is simpler here: one local listener, Cloudflare DoH upstreams, fewer moving parts.

1
Client → tunnel → Pi-hole filtering → cloudflared → Cloudflare DoH

Cloudflare DNS and ECH records

Cloudflare DoH is used for secure DNS transport and for HTTPS/SVCB records used by ECH-capable browsers.

ECH, or Encrypted ClientHello, is what modern Cloudflare pages refer to as Secure SNI. DNS can deliver the ECH bootstrap records, but the browser must also support ECH.


Important limitation: Cloudflare orange cloud

Cloudflare’s orange-cloud proxy is for HTTP and HTTPS websites. It does not proxy arbitrary REALITY or Hysteria2 traffic.

For REALITY and Hysteria2 client connections, the hostname must resolve to the server’s real origin address, or the client must use the server IP directly.

Recommended DNS behavior:

1
A/AAAA record for vpn.example.com → DNS-only / grey cloud

Do not put REALITY or Hysteria2 behind orange-cloud proxy mode.


Folder layout

Create a dedicated folder:

1
2
3
4
5
6
7
8
/home/ubuntu/xray-hysteria2/
├── docker-compose.yml
├── haproxy/
│   └── haproxy.cfg
├── reality-singbox/
│   └── config.json
└── hysteria2/
    └── sing-box-server.json

The folder name is historical. The REALITY backend in this guide uses sing-box, not Xray Core.


Step 1 — Create or verify the Docker network

Traefik, HAProxy, REALITY, Pi-hole, and cloudflared should share the same Docker network so containers can reach each other by name or internal IP.

1
docker network create proxy

If the network already exists, Docker will report that it exists. That is fine.


Step 2 — Change Traefik so it no longer owns public TCP/443

This is the most important Traefik edit.

Before this architecture, Traefik usually has:

1
2
3
ports:
  - "80:80"
  - "443:443"

That means Traefik binds public TCP/443 directly. HAProxy cannot also bind TCP/443.

For this design, edit this file:

1
/home/ubuntu/traefik/docker-compose.yml

Change Traefik’s HTTPS publish from public 443:443 to localhost-only 127.0.0.1:9443:443:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
services:
  traefik:
    image: traefik:latest
    container_name: traefik
    restart: unless-stopped
    networks:
      proxy:
    ports:
      - "80:80"
      - "127.0.0.1:9443:443"
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /home/ubuntu/traefik/data/traefik.yml:/traefik.yml:ro
      - /home/ubuntu/traefik/data/acme.json:/acme.json
      - /home/ubuntu/traefik/data/config.yml:/config.yml:ro
    env_file: .env

networks:
  proxy:
    external: true

Why this works:

  • Public HTTP 80 remains Traefik’s job.
  • Public HTTPS 443 moves to HAProxy.
  • Traefik still listens on container port 443 inside Docker.
  • HAProxy forwards normal HTTPS traffic to traefik:443 over the Docker network.
  • The localhost 9443 publish is optional but useful for local debugging.

If your Compose file is under a host-mounted path, use absolute volume paths. Relative bind mounts can accidentally create directories where files should be mounted, causing errors such as read /traefik.yml: is a directory.

Restart Traefik after the edit:

1
2
cd /home/ubuntu/traefik
docker compose up -d

Step 3 — Add the HAProxy SNI splitter

Create this file:

1
/home/ubuntu/xray-hysteria2/haproxy/haproxy.cfg

Use this config:

global
  log stdout format raw local0
  maxconn 4096

defaults
  log global
  mode tcp
  option tcplog
  timeout connect 5s
  timeout client  2m
  timeout server  2m

frontend https_443
  bind :443
  tcp-request inspect-delay 5s
  tcp-request content accept if { req_ssl_hello_type 1 }

  # REALITY clients use this camouflage SNI.
  use_backend reality_backend if { req.ssl_sni -i www.microsoft.com }

  # Everything else stays normal HTTPS through Traefik.
  default_backend traefik_https

backend reality_backend
  server reality reality-singbox:10443

backend traefik_https
  server traefik traefik:443

What happens here:

  • HAProxy accepts TCP/443.
  • It waits briefly for the TLS ClientHello.
  • It reads SNI without terminating TLS.
  • It sends REALITY camouflage traffic to sing-box.
  • It sends all normal website traffic to Traefik.

HAProxy does not need TLS certificates for this role because it is not decrypting HTTPS.


Step 4 — Create the REALITY sing-box server config

Create this file:

1
/home/ubuntu/xray-hysteria2/reality-singbox/config.json

Use placeholder values first:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{
  "log": {
    "level": "info",
    "timestamp": true
  },
  "inbounds": [
    {
      "type": "vless",
      "tag": "reality-in",
      "listen": "::",
      "listen_port": 10443,
      "users": [
        {
          "name": "user-reality",
          "uuid": "00000000-0000-4000-8000-000000000000",
          "flow": "xtls-rprx-vision"
        }
      ],
      "tls": {
        "enabled": true,
        "server_name": "www.microsoft.com",
        "reality": {
          "enabled": true,
          "handshake": {
            "server": "www.microsoft.com",
            "server_port": 443
          },
          "private_key": "REPLACE_WITH_REALITY_PRIVATE_KEY",
          "short_id": [
            "0123456789abcdef"
          ],
          "max_time_difference": "1m"
        }
      }
    }
  ],
  "outbounds": [
    {
      "type": "direct",
      "tag": "direct"
    },
    {
      "type": "block",
      "tag": "block"
    }
  ],
  "route": {
    "final": "direct"
  }
}

Generate your REALITY keypair with sing-box:

1
docker run --rm ghcr.io/sagernet/sing-box:latest generate reality-keypair

Generate a UUID:

1
uuidgen

Generate a short ID:

1
openssl rand -hex 8

Replace these placeholders:

PlaceholderMeaning
00000000-0000-4000-8000-000000000000Client UUID
REPLACE_WITH_REALITY_PRIVATE_KEYServer-side REALITY private key
0123456789abcdefREALITY short ID
www.microsoft.comCamouflage SNI and handshake server

The client receives the public key, not the private key.


Step 5 — Create the Hysteria2 server config

Create this file:

1
/home/ubuntu/xray-hysteria2/hysteria2/sing-box-server.json

Use this template:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
{
  "log": {
    "level": "info",
    "timestamp": true
  },
  "inbounds": [
    {
      "type": "hysteria2",
      "tag": "hy2-in",
      "listen": "::",
      "listen_port": 443,
      "users": [
        {
          "name": "user-hysteria2",
          "password": "REPLACE_WITH_LONG_RANDOM_PASSWORD"
        }
      ],
      "obfs": {
        "type": "salamander",
        "password": "REPLACE_WITH_LONG_RANDOM_OBFS_PASSWORD"
      },
      "tls": {
        "enabled": true,
        "certificate_path": "/etc/sing-box/certs/hy2.crt",
        "key_path": "/etc/sing-box/certs/hy2.key"
      },
      "masquerade": {
        "type": "string",
        "status_code": 404,
        "headers": {
          "content-type": [
            "text/html; charset=utf-8"
          ]
        },
        "content": "<!doctype html><html><head><title>404 Not Found</title></head><body><h1>404 Not Found</h1></body></html>"
      }
    }
  ],
  "outbounds": [
    {
      "type": "direct",
      "tag": "direct"
    },
    {
      "type": "block",
      "tag": "block"
    }
  ],
  "route": {
    "final": "direct"
  }
}

Hysteria2 needs TLS certificate files. For a private proxy endpoint, a self-signed certificate can be enough when the client profile sets insecure: true.

Example certificate generation:

1
2
3
4
5
6
mkdir -p /home/ubuntu/sing-box/certs
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout /home/ubuntu/sing-box/certs/hy2.key \
  -out /home/ubuntu/sing-box/certs/hy2.crt \
  -days 3650 \
  -subj "/CN=vpn.example.com"

Generate long random passwords:

1
2
openssl rand -base64 32
openssl rand -base64 32

Use different values for the Hysteria2 user password and Salamander obfuscation password.


Step 6 — Create the combined Docker Compose file

Create this file:

1
/home/ubuntu/xray-hysteria2/docker-compose.yml

Use this Compose file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
services:
  haproxy-sni:
    image: haproxy:lts-alpine
    container_name: haproxy-sni
    restart: unless-stopped
    user: "0:0"
    ports:
      - "443:443/tcp"
    networks:
      - proxy
    volumes:
      - /home/ubuntu/xray-hysteria2/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
    security_opt:
      - no-new-privileges:true

  reality-singbox:
    image: ghcr.io/sagernet/sing-box:latest
    container_name: reality-singbox
    restart: unless-stopped
    user: "0:0"
    networks:
      - proxy
    command: ["run", "-c", "/etc/sing-box/config.json"]
    volumes:
      - /home/ubuntu/xray-hysteria2/reality-singbox/config.json:/etc/sing-box/config.json:ro
    security_opt:
      - no-new-privileges:true

  hysteria2:
    image: ghcr.io/sagernet/sing-box:latest
    container_name: hysteria2
    restart: unless-stopped
    network_mode: host
    command: ["run", "-c", "/etc/sing-box/config.json"]
    volumes:
      - /home/ubuntu/xray-hysteria2/hysteria2/sing-box-server.json:/etc/sing-box/config.json:ro
      - /home/ubuntu/sing-box/certs:/etc/sing-box/certs:ro
    security_opt:
      - no-new-privileges:true

networks:
  proxy:
    external: true

Why Hysteria2 uses host networking:

  • It needs UDP/443 exposed directly.
  • TCP/443 is already published by HAProxy.
  • TCP and UDP can both use port 443 at the same time.

Start the stack:

1
2
cd /home/ubuntu/xray-hysteria2
docker compose up -d

Check containers:

1
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'

Expected result:

1
2
3
4
haproxy-sni       Up ...   0.0.0.0:443->443/tcp
reality-singbox   Up ...
hysteria2         Up ...
traefik           Up ...   0.0.0.0:80->80/tcp, 127.0.0.1:9443->443/tcp

Step 7 — Pi-hole and cloudflared mode

This section is optional. Skip it if you only want direct Cloudflare DoH from the client.

For the final filtered setup, Pi-hole does local filtering first and cloudflared handles only encrypted upstream DNS afterwards. The DNS path is:

1
Client → tunnel → Pi-hole → dnsmasq SafeSearch/blocking → cloudflared → Cloudflare DoH

This keeps Pi-hole useful while avoiding the socket-saturation problems that can make Cloudflare checks and normal browsing unstable in heavier encrypted-DNS forwarder setups. A stable Pi-hole container IP also makes mobile client profiles easier to maintain.

Edit this file:

1
/home/ubuntu/pihole/docker-compose.yml

Use this shape for the DNS services:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
services:
  pihole:
    image: pihole/pihole
    container_name: pihole
    restart: unless-stopped
    environment:
      FTLCONF_misc_etc_dnsmasq_d: "true"
      FTLCONF_dns_upstreams: "cloudflared#53"
      FTLCONF_dns_listeningMode: "all"
    volumes:
      - /home/ubuntu/pihole/pihole:/etc/pihole/
      - /home/ubuntu/pihole/dnsmasq.d:/etc/dnsmasq.d/
    depends_on:
      - cloudflared
    networks:
      proxy:
        ipv4_address: 172.18.0.33

  cloudflared:
    image: cloudflare/cloudflared:2025.10.1
    container_name: cloudflared
    restart: unless-stopped
    command:
      - proxy-dns
      - --address
      - 0.0.0.0
      - --port
      - "53"
      - --upstream
      - https://1.1.1.1/dns-query
      - --upstream
      - https://1.0.0.1/dns-query
    networks:
      - proxy

networks:
  proxy:
    external: true

Pin the cloudflared image instead of using latest. Newer cloudflared releases removed proxy-dns, so pinning keeps this design reproducible.

The client profiles below use Pi-hole at:

1
172.18.0.33:53

That is an internal Docker network address, not a public IP address.

SafeSearch rewrites

Create this file:

1
/home/ubuntu/pihole/dnsmasq.d/99-safesearch.conf

Example SafeSearch rules:

host-record=forcesafesearch.google.com,216.239.38.120
host-record=restrict.youtube.com,216.239.38.120
host-record=restrictmoderate.youtube.com,216.239.38.119
host-record=strict.bing.com,204.79.197.220
host-record=safe.duckduckgo.com,52.142.126.100
host-record=search.brave.com,3.33.205.124

cname=www.google.com,forcesafesearch.google.com
cname=google.com,forcesafesearch.google.com
cname=www.bing.com,strict.bing.com
cname=bing.com,strict.bing.com
cname=duckduckgo.com,safe.duckduckgo.com
cname=www.duckduckgo.com,safe.duckduckgo.com
cname=www.youtube.com,restrictmoderate.youtube.com
cname=m.youtube.com,restrictmoderate.youtube.com
cname=youtube.googleapis.com,restrictmoderate.youtube.com
cname=youtubei.googleapis.com,restrictmoderate.youtube.com

Use host-record for Brave’s search.brave.com force-safe IP. Do not use a broad address=/search.brave.com/... rule for Brave, because that also catches safe.search.brave.com and can cause HSTS certificate errors.

If you want to block search engines that do not support network-level SafeSearch, keep those rules in a separate file, for example:

1
/home/ubuntu/pihole/dnsmasq.d/98-search-bypass-blocks.conf

Then add only the domains you intentionally want to block:

address=/qwant.com/0.0.0.0
address=/qwant.com/::
address=/yahoo.com/0.0.0.0
address=/yahoo.com/::
address=/you.com/0.0.0.0
address=/you.com/::
address=/kagi.com/0.0.0.0
address=/kagi.com/::

Restart Pi-hole:

1
2
cd /home/ubuntu/pihole
docker compose up -d

Verify upstream:

1
docker exec pihole pihole-FTL --config dns.upstreams

Expected:

1
[ cloudflared#53 ]

Verify cloudflared is alive:

1
docker logs --tail 50 cloudflared

Expected log lines include:

1
2
Starting DNS over HTTPS proxy server address=dns://0.0.0.0:53
Adding DNS upstream url=https://1.1.1.1/dns-query

Client mode A — direct Cloudflare DoH, no Pi-hole filtering

Use this when you want the client to send DNS directly over Cloudflare DoH through the tunnel. This mode is simple and usually passes Cloudflare’s secure DNS test, but it bypasses Pi-hole filtering.

REALITY direct-DoH client

Use this as a template:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
{
  "log": {
    "level": "info",
    "timestamp": true
  },
  "dns": {
    "servers": [
      {
        "type": "https",
        "tag": "cf-doh",
        "server": "1.1.1.1",
        "server_port": 443,
        "path": "/dns-query",
        "detour": "proxy"
      }
    ],
    "rules": [
      {
        "action": "route",
        "server": "cf-doh"
      }
    ],
    "final": "cf-doh",
    "strategy": "ipv4_only"
  },
  "inbounds": [
    {
      "type": "tun",
      "tag": "tun-in",
      "interface_name": "tun0",
      "address": [
        "172.19.0.1/30"
      ],
      "auto_route": true,
      "strict_route": true,
      "route_exclude_address": [
        "203.0.113.10/32"
      ]
    }
  ],
  "outbounds": [
    {
      "type": "vless",
      "tag": "proxy",
      "server": "203.0.113.10",
      "server_port": 443,
      "uuid": "00000000-0000-4000-8000-000000000000",
      "flow": "xtls-rprx-vision",
      "tls": {
        "enabled": true,
        "server_name": "www.microsoft.com",
        "utls": {
          "enabled": true,
          "fingerprint": "chrome"
        },
        "reality": {
          "enabled": true,
          "public_key": "REPLACE_WITH_REALITY_PUBLIC_KEY",
          "short_id": "0123456789abcdef"
        }
      },
      "packet_encoding": "xudp"
    },
    {
      "type": "direct",
      "tag": "direct"
    },
    {
      "type": "block",
      "tag": "block"
    }
  ],
  "route": {
    "auto_detect_interface": true,
    "rules": [
      {
        "port": 53,
        "action": "hijack-dns"
      },
      {
        "action": "sniff"
      }
    ],
    "final": "proxy",
    "default_domain_resolver": "cf-doh"
  }
}

Hysteria2 direct-DoH client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
{
  "log": {
    "level": "info",
    "timestamp": true
  },
  "dns": {
    "servers": [
      {
        "type": "https",
        "tag": "cf-doh",
        "server": "1.1.1.1",
        "server_port": 443,
        "path": "/dns-query",
        "detour": "proxy"
      }
    ],
    "rules": [
      {
        "action": "route",
        "server": "cf-doh"
      }
    ],
    "final": "cf-doh",
    "strategy": "ipv4_only"
  },
  "inbounds": [
    {
      "type": "tun",
      "tag": "tun-in",
      "interface_name": "tun0",
      "address": [
        "172.19.0.1/30"
      ],
      "auto_route": true,
      "strict_route": true,
      "route_exclude_address": [
        "203.0.113.10/32"
      ]
    }
  ],
  "outbounds": [
    {
      "type": "hysteria2",
      "tag": "proxy",
      "server": "203.0.113.10",
      "server_port": 443,
      "password": "REPLACE_WITH_HYSTERIA2_PASSWORD",
      "obfs": {
        "type": "salamander",
        "password": "REPLACE_WITH_SALAMANDER_PASSWORD"
      },
      "tls": {
        "enabled": true,
        "alpn": [
          "h3"
        ],
        "insecure": true
      }
    },
    {
      "type": "direct",
      "tag": "direct"
    },
    {
      "type": "block",
      "tag": "block"
    }
  ],
  "route": {
    "auto_detect_interface": true,
    "rules": [
      {
        "port": 53,
        "action": "hijack-dns"
      },
      {
        "action": "sniff"
      }
    ],
    "final": "proxy",
    "default_domain_resolver": "cf-doh"
  }
}

Client mode B — Pi-hole-filtered DNS with encrypted upstream

This is the more complete homelab mode.

Normal DNS goes to Pi-hole through the tunnel:

1
Client → tunnel → 172.18.0.33:53 → Pi-hole

Pi-hole filters the request and forwards allowed queries to cloudflared. cloudflared then sends upstream DNS to Cloudflare DoH.

The final daily profile keeps DNS on the Pi-hole path. Do not bypass Pi-hole for Cloudflare test domains if you want filtering, SafeSearch, and local block rules to remain consistent.

Why use the server IP instead of a domain in this profile?

When DNS itself is inside the tunnel, the client must be able to connect to the tunnel endpoint before DNS works. If the outbound server is a hostname, the client may need DNS before the tunnel exists.

That creates a bootstrap loop:

1
2
Need DNS to start tunnel
Need tunnel to reach DNS

The filtered profiles therefore use a literal server IP such as:

1
203.0.113.10

Use your own origin IP privately. Do not publish it.

REALITY Pi-hole-filtered client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
{
  "log": {
    "level": "info",
    "timestamp": true
  },
  "dns": {
    "servers": [
      {
        "type": "tcp",
        "tag": "pihole",
        "server": "172.18.0.33",
        "server_port": 53,
        "detour": "proxy"
      }
    ],
    "rules": [
      {
        "action": "route",
        "server": "pihole"
      }
    ],
    "final": "pihole",
    "strategy": "ipv4_only"
  },
  "inbounds": [
    {
      "type": "tun",
      "tag": "tun-in",
      "interface_name": "tun0",
      "address": [
        "172.19.0.1/30"
      ],
      "auto_route": true,
      "strict_route": true,
      "route_exclude_address": [
        "203.0.113.10/32"
      ]
    }
  ],
  "outbounds": [
    {
      "type": "vless",
      "tag": "proxy",
      "server": "203.0.113.10",
      "server_port": 443,
      "uuid": "00000000-0000-4000-8000-000000000000",
      "flow": "xtls-rprx-vision",
      "tls": {
        "enabled": true,
        "server_name": "www.microsoft.com",
        "utls": {
          "enabled": true,
          "fingerprint": "chrome"
        },
        "reality": {
          "enabled": true,
          "public_key": "REPLACE_WITH_REALITY_PUBLIC_KEY",
          "short_id": "0123456789abcdef"
        },
        "min_version": "1.3",
        "max_version": "1.3"
      },
      "packet_encoding": "xudp"
    },
    {
      "type": "direct",
      "tag": "direct"
    },
    {
      "type": "block",
      "tag": "block"
    }
  ],
  "route": {
    "auto_detect_interface": true,
    "rules": [
      {
        "port": 53,
        "action": "hijack-dns"
      },
      {
        "action": "sniff"
      }
    ],
    "final": "proxy",
    "default_domain_resolver": "pihole"
  }
}

Hysteria2 Pi-hole-filtered client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
{
  "log": {
    "level": "info",
    "timestamp": true
  },
  "dns": {
    "servers": [
      {
        "type": "tcp",
        "tag": "pihole",
        "server": "172.18.0.33",
        "server_port": 53,
        "detour": "proxy"
      }
    ],
    "rules": [
      {
        "action": "route",
        "server": "pihole"
      }
    ],
    "final": "pihole",
    "strategy": "ipv4_only"
  },
  "inbounds": [
    {
      "type": "tun",
      "tag": "tun-in",
      "interface_name": "tun0",
      "address": [
        "172.19.0.1/30"
      ],
      "auto_route": true,
      "strict_route": true,
      "route_exclude_address": [
        "203.0.113.10/32"
      ]
    }
  ],
  "outbounds": [
    {
      "type": "hysteria2",
      "tag": "proxy",
      "server": "203.0.113.10",
      "server_port": 443,
      "password": "REPLACE_WITH_HYSTERIA2_PASSWORD",
      "obfs": {
        "type": "salamander",
        "password": "REPLACE_WITH_SALAMANDER_PASSWORD"
      },
      "tls": {
        "enabled": true,
        "alpn": [
          "h3"
        ],
        "insecure": true,
        "min_version": "1.3",
        "max_version": "1.3",
        "ech": {
          "enabled": true
        }
      }
    },
    {
      "type": "direct",
      "tag": "direct"
    },
    {
      "type": "block",
      "tag": "block"
    }
  ],
  "route": {
    "auto_detect_interface": true,
    "rules": [
      {
        "port": 53,
        "action": "hijack-dns"
      },
      {
        "action": "sniff"
      }
    ],
    "final": "proxy",
    "default_domain_resolver": "pihole"
  }
}

Why the Pi-hole client uses TCP DNS

DNS over UDP through REALITY/VLESS can be unreliable in some sing-box/TUN combinations. TCP DNS through the tunnel is slower on paper but more predictable.

This is why the filtered profile uses:

1
2
3
4
5
6
7
{
  "type": "tcp",
  "tag": "pihole",
  "server": "172.18.0.33",
  "server_port": 53,
  "detour": "proxy"
}

The client sends DNS to Pi-hole over TCP through the selected proxy outbound.


Validation commands

Run these checks from a temporary client container or a Linux machine with sing-box available.

Validate JSON syntax

1
sing-box check -c client.json

Confirm web traffic exits through the server

1
curl -4 https://ifconfig.me

Expected: your server’s public IP, not your local ISP IP.

Confirm Pi-hole blocking

1
dig @172.19.0.2 doubleclick.net A +short

Expected:

1
0.0.0.0

Confirm SafeSearch

1
dig @172.19.0.2 www.google.com A +short

Expected to include:

1
forcesafesearch.google.com.

Confirm HTTPS/SVCB records

1
dig @172.19.0.2 www.google.com HTTPS +short

Expected: a non-empty HTTPS/SVCB answer.

Confirm ECH bootstrap records

1
dig @172.19.0.2 cloudflare-ech.com HTTPS +short

Expected to include:

1
ech=...

Confirm TLS 1.3

1
curl -s https://one.one.one.one/cdn-cgi/trace | grep -E 'http=|tls=|sni='

Expected:

1
2
http=http/2
tls=TLSv1.3

The sni= value depends on the HTTP client and browser ECH support. A browser can pass Cloudflare’s Secure SNI/ECH test only if it supports ECH and receives valid HTTPS/SVCB records.


Troubleshooting

Hysteria2 does not connect

Check whether UDP/443 is blocked.

Hysteria2 uses UDP. Many restricted networks allow TCP/443 but block or throttle UDP/443. If Hysteria2 fails on one network but REALITY works, the server config may be fine and the network may be blocking UDP.

Server-side signs of a working Hysteria2 connection:

1
docker logs -f hysteria2

Expected log pattern:

1
inbound/hysteria2[hy2-in]: [user-hysteria2] inbound connection to example.com:443

If there are no logs when the client connects, check firewall, security group, router, and provider UDP ingress rules.

REALITY does not connect

Check HAProxy routing:

1
docker logs -f haproxy-sni

Check REALITY logs:

1
docker logs -f reality-singbox

Make sure these values match on client and server:

  • UUID
  • REALITY public/private key pair
  • short ID
  • flow
  • camouflage SNI
  • server port

Websites stop loading in Pi-hole mode

First check Pi-hole:

1
docker exec pihole dig @127.0.0.1 example.com A +short

Then check cloudflared:

1
docker logs --tail 100 cloudflared

If cloudflared is not running, restart the DNS stack:

1
2
cd /home/ubuntu/pihole
docker compose up -d

Then retest Pi-hole resolution.

Cloudflare Secure DNS fails but Pi-hole filtering works

Cloudflare’s help page checks whether its resolver canaries are resolved through Cloudflare secure DNS. In Pi-hole-filtered mode, your DNS path is:

1
Browser → sing-box → Pi-hole → cloudflared → Cloudflare DoH

That path can still pass the Cloudflare Browser Check when the browser actually uses this DNS path. If Secure DNS fails while Pi-hole filtering works, check Android Private DNS, Chrome Secure DNS overrides, and whether the app is excluded from the VPN/TUN profile.

Secure SNI / ECH fails

ECH requires three things:

  1. The website supports ECH.
  2. DNS returns HTTPS/SVCB records containing ech=.
  3. The browser supports and enables ECH.

The sing-box profile can help with DNS delivery, but the browser still decides whether ECH is used.


Security notes

Do not publish:

  • server origin IP address
  • Cloudflare API token
  • REALITY private key
  • REALITY client UUID if it is active
  • REALITY short ID if it is active
  • Hysteria2 password
  • Salamander obfuscation password
  • Pi-hole admin password
  • full production client profiles

Use placeholders in public documentation. Store real client files privately.


Summary

This architecture lets one server keep normal HTTPS websites online while also offering two proxy transports:

1
2
3
4
5
TCP/443 → HAProxy SNI splitter
  ├─ REALITY camouflage SNI → sing-box REALITY
  └─ normal websites        → Traefik

UDP/443 → sing-box Hysteria2

For DNS, use one of two modes:

1
2
Direct mode:
Client → tunnel → Cloudflare DoH
1
2
Filtered mode:
Client → tunnel → Pi-hole → cloudflared → Cloudflare DoH

REALITY is the reliable TCP fallback. Hysteria2 is the fast UDP option. Traefik continues serving websites. Pi-hole adds filtering when wanted. HAProxy is the small TCP-layer component that makes sharing port 443 possible.

This post is licensed under CC BY 4.0 by the author.