Raising a Reality server and connecting to it is a one-evening task. Configuring it so that the bank opens directly, Netflix goes through the proxy, ads are cut at the source, and DNS doesn’t tell the provider where you go — that’s a different order of work. This is where most configs break, and this is usually not covered in vendor subscriptions: there they just put "final": "proxy" and that’s it.
This guide analyzes the client-side sing-box by layers — in the order the packet actually passes through the program. Relevant for the stable 1.13.x branch (latest release at the time of writing — v1.13.16 from August 3, 2026; branch 1.14 is still in beta). Everything below is taken from the official sing-box.sagernet.org documentation, not from third-party articles.
If you haven’t yet explored how the transport itself works, start with the neighboring topic — how Reality (XTLS) works and why it bypasses DPI.
Layer 0. Decide your strategy first, then write the JSON
Before opening the editor, you need to answer one question: what happens to traffic that doesn’t match any rule? The answer determines everything else.
Key principle: rules describe exceptions, and route.final describes the default. Most broken configs are attempts to describe everything with rules at once, instead of choosing the correct default and then adding a dozen exceptions.
For a Russian user in 2026, the sensible default is a white list: final points to a proxy, and direct is given only to explicitly listed Russian addresses and domains. The reason is simple: the blocklist grows faster than you can fix it, while the list of things that should go directly (banks, public services, local services, gray IP ranges) hardly changes.
Layer 1. Traffic intake: tun and why sniff is needed
The tun mode creates a virtual network interface and takes over all system traffic — not only the traffic the app agreed to send to the proxy.
{
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"address": ["172.19.0.1/30", "fdfe:dcba:9876::1/126"],
"auto_route": true,
"strict_route": true,
"stack": "mixed"
}
]
}
What’s important here:
| Field | Value | Why |
|---|---|---|
address |
list of prefixes (since 1.10.0) | Previously there were separate inet4_address / inet6_address — in older guides they still appear |
auto_route |
true |
Specifies the default route in the tunnel |
strict_route |
true |
Forces the system to respect these routes; without it part of the traffic leaks past |
stack |
system / gvisor / mixed |
mixed — compromise: TCP through system, UDP through gVisor |
auto_redirect |
Linux, since 1.10.0 | Speeds up routing via nftables instead of the tun stack |
On Linux with auto_redirect there is a separate action bypass (added in 1.13.0) — it returns the connection to the ordinary network stack bypassing the tunnel. Useful for traffic that should go to the local network without tun overhead.
Why domain-based routing doesn’t work without sniff
When traffic comes from tun, sing-box only sees the destination address — IP and port. There is no domain: the app has already resolved it itself. Rules like “youtube.com — through proxy” operate on domains.
This is solved by the sniff action: sing-box looks at the start of the connection, extracts the SNI from TLS ClientHello, Host from HTTP, or ALPN from QUIC — and then routes by domain.
{
"route": {
"rules": [
{ "action": "sniff", "timeout": "300ms" },
{ "protocol": "dns", "action": "hijack-dns" }
]
}
}
If you migrate a config from a version before 1.11, you likely have sniff as a field inside inbound ("sniff": true). This is deprecated syntax: since 1.11.0 sniffing became an action of a rule. Likewise, special outbound items block and dns disappeared — they became "action": "reject" and "action": "hijack-dns".
Layer 2. DNS — the place where almost all breakages happen
The familiar symptom: a site should go through the proxy, but it opens a “Russian” version, or doesn’t open at all. Almost always the reason is that the name was resolved by the wrong server.
Since version 1.12.0, the DNS server format has been rewritten. The old address field with a URL scheme has been replaced by explicit type + server:
| Was (before 1.12) | Now (1.12+) |
|---|---|
"address": "local" |
"type": "local" |
"address": "1.1.1.1" |
"type": "udp", "server": "1.1.1.1" |
"address": "tls://1.1.1.1" |
"type": "tls", "server": "1.1.1.1" |
"address": "https://1.1.1.1/dns-query" |
"type": "https", "server": "1.1.1.1" |
"address": "quic://1.1.1.1" |
"type": "quic", "server": "1.1.1.1" |
"address": "fakeip" |
"type": "fakeip" with inet4_range / inet6_range |
A working setup looks like this:
{
"dns": {
"servers": [
{
"tag": "dns-proxy",
"type": "https",
"server": "1.1.1.1",
"detour": "proxy"
},
{
"tag": "dns-local",
"type": "udp",
"server": "192.168.1.1",
"detour": "direct"
}
],
"rules": [
{ "rule_set": ["geosite-category-ru"], "server": "dns-local" }
],
"final": "dns-proxy",
"strategy": "prefer_ipv4"
}
}
Meaning: Russian domains are resolved by the router (otherwise you’ll get a foreign CDN node and lose speed); everything else goes to DoH through the proxy itself — thanks to detour. The provider in this case sees only an encrypted connection to your server.
detour in the DNS server is the protection against DNS leaks. Without it, requests go out in plain UDP from your address, and the provider still sees the list of visited domains, even if the traffic itself later goes through the tunnel. To verify, use a dnsleaktest-like service and run tcpdump port 53 on the gateway.
A typical trap: specify a DoH server with a domain name ("server": "dns.example.com") and send it to detour: "proxy". To connect to the proxy, you need to resolve its address; to resolve it — you need DNS that can go through the proxy. The loop closes, and sing-box fails to start correctly. Solutions: specify the DoH server by IP, or set domain_resolver (introduced in 1.12.0) with a separate bootstrap server.
One more subtlety worth knowing: resolve is also a rule action, not an inbound property. It forcefully resolves a domain on the desired server in the desired direction:
{ "action": "resolve", "server": "dns-local", "strategy": "ipv4_only" }
It is used when the provider gives a crooked IPv6 or when the rule further down the chain matches by ip_cidr and it needs a ready address.
Layer 3. route.rules: first match wins
This is the core of the whole construction. Rules are checked top-down, the first one that matches fires, the rest are not considered. Hence the main practical consequence: the order of rules is part of the logic, not cosmetics.
The set of fields in a rule is enormous. Here are the ones actually used in client configurations:
| Field | What it matches |
|---|---|
domain, domain_suffix, domain_keyword, domain_regex |
Domain — exact, by suffix, by substring, by regex |
ip_cidr, source_ip_cidr |
Destination / source address |
ip_is_private, source_ip_is_private |
Non-public addresses (gray networks, localhost) |
port, port_range, source_port, source_port_range |
Ports |
network |
tcp, udp, icmp |
protocol, client |
What the sniffer defined |
process_name, process_path, package_name |
Application: process name (desktop) or Android package |
network_type, network_is_expensive |
Wi-Fi / cellular / Ethernet, bandwidth-limited network |
wifi_ssid, wifi_bssid |
Specific Wi-Fi network |
clash_mode |
Current mode in Clash API (Rule / Global / Direct) |
rule_set |
Link to external list |
invert |
Invert the result of the rule |
Actions (action) that terminate processing:
route— send to outbound (fieldoutbound; fieldoutboundin the rule itself was deprecated since 1.11.0 in favor ofaction);reject— terminate. There ismethod:default(TCP RST / ICMP port unreachable),drop(silently drop),reply(only for ICMP echo);hijack-dns— send to the DNS module;bypass— return to the system stack (Linux +auto_redirect, since 1.13.0).
And not final: sniff, resolve, route-options. The latter is a set of tuning over the route: override_address, override_port, udp_timeout, as well as tls_fragment and tls_record_fragment (since 1.12.0), which cut TLS ClientHello into parts — sometimes enough to bypass primitive DPI without a proxy at all.
Rules can be combined logically: {"type": "logical", "mode": "and", "rules": [...]}. Modes are and and or. Classic example: “domain from the X list and mobile network” — route through a proxy on mobile internet, don’t route on home Wi-Fi.
Layer 4. rule_set: where the lists themselves come from
Earlier there were fields geoip and geosite with monolithic bases. Starting from 1.8.0 they are replaced by rule-set — separate lists that are attached by tag and updated independently.
Three types:
inline(since 1.10.0) — rules directly in the config, no external file;local— file on disk, reread on change;remote— downloaded by URL and cached.
Official builds reside in the repositories SagerNet/sing-geoip and SagerNet/sing-geosite, branch rule-set:
{
"route": {
"rule_set": [
{
"tag": "geoip-ru",
"type": "remote",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-ru.srs",
"download_detour": "proxy",
"update_interval": "1d"
},
{
"tag": "geosite-category-ru",
"type": "remote",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ru.srs",
"download_detour": "proxy"
}
]
},
"experimental": {
"cache_file": { "enabled": true }
}
}
Tag geosite-ru does not exist — using that name will yield 404 and an empty list. The Russian category in sing-geosite is called geosite-category-ru. You can verify the presence of any list simply by opening the URL in a browser: the file is returned — the tag exists, you get 404: Not Found — the tag does not exist. This is the first thing to do before copying someone else’s config from a chat.
experimental.cache_file.enabled: true — not a “future” option, but a mandatory condition for remote rule-set. Without it, lists will be downloaded on every startup: slow, and on mobile internet also expensive.
Your list is assembled from the JSON source with the command:
sing-box rule-set compile --output my-direct.srs my-direct.json
The source looks like this:
{
"version": 3,
"rules": [
{ "domain_suffix": [".gosuslugi.ru", ".nalog.gov.ru"] }
]
}
Field version — this is the version of the format, not your file, and it determines which types of rules can be used:
| version | Appeared in | What it added |
|---|---|---|
| 1 | 1.8.0 | Original format |
| 2 | 1.10.0 | Memory optimization for domain_suffix |
| 3 | 1.11.0 | Network rules (network_type and kin) |
| 4 | 1.13.0 | Rules for interface addresses |
| 5 | 1.14.0 | package_name_regex |
Compiled a list with version: 5 — an older client won’t read it. This is a common cause of the mysterious “rule exists but doesn’t work” after someone shares a fresh .srs in chat. Compatibility goes bottom-up.
If you move to 1.14, note: download_detour is deprecated there, replaced by the general http_client. In 1.13.x it still works.
Putting it all together
Minimal yet meaningful config for a complete white list:
{
"log": { "level": "info", "timestamp": true },
"dns": {
"servers": [
{ "tag": "dns-proxy", "type": "https", "server": "1.1.1.1", "detour": "proxy" },
{ "tag": "dns-local", "type": "udp", "server": "192.168.1.1", "detour": "direct" }
],
"rules": [ { "rule_set": ["geosite-category-ru"], "server": "dns-local" } ],
"final": "dns-proxy"
},
"inbounds": [
{ "type": "tun", "tag": "tun-in", "address": ["172.19.0.1/30"],
"auto_route": true, "strict_route": true, "stack": "mixed" }
],
"outbounds": [
{ "type": "vless", "tag": "proxy", "server": "203.0.113.10", "server_port": 443,
"uuid": "ВАШ-UUID", "flow": "xtls-rprx-vision",
"tls": { "enabled": true, "server_name": "www.example.com",
"utls": { "enabled": true, "fingerprint": "chrome" },
"reality": { "enabled": true, "public_key": "ВАШ-PUBLIC-KEY", "short_id": "ВАШ-SHORT-ID" } } },
{ "type": "direct", "tag": "direct" }
],
"route": {
"rules": [
{ "action": "sniff" },
{ "protocol": "dns", "action": "hijack-dns" },
{ "ip_is_private": true, "action": "route", "outbound": "direct" },
{ "rule_set": ["geosite-ads"], "action": "reject", "method": "drop" },
{ "rule_set": ["geosite-category-ru", "geoip-ru"], "action": "route", "outbound": "direct" }
],
"rule_set": [
{ "tag": "geosite-category-ru", "type": "remote", "format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ru.srs",
"download_detour": "proxy" },
{ "tag": "geoip-ru", "type": "remote", "format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-ru.srs",
"download_detour": "proxy" },
{ "tag": "geosite-ads", "type": "remote", "format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs",
"download_detour": "proxy" }
],
"final": "proxy",
"auto_detect_interface": true
},
"experimental": { "cache_file": { "enabled": true } }
}
The order of rules here is the logic: first the technique (sniffing, DNS interception), then the local network, then ad blocking, then “my own — direct”, and only after that the default to proxy.
Before running — two steps:
sing-box check -c config.json # syntax and non-existent tag checks
sing-box format -c config.json -w # bring to canonical form
check catches the most common typos in tags — the most frequent reason for “config seems correct, but traffic goes somewhere else.”
When a rule doesn’t fire: debugging order
- Check the order. The rule below is more general and will never be satisfied.
ip_is_privateat the end of the list is useless. - Check whether there is a domain at all. No
sniffaction — no domains, and alldomain_suffixare dead. - Increase the log level to
debugand see which outbound is chosen for a specific connection. You can also see whether the sniffer ran there. - Check the list itself —
sing-box rule-set matchwill show whether a given domain falls into.srs, without you having to guess. - Check that the list was downloaded. A remote rule-set without
cache_fileand without GitHub access is an empty list that silently matches nothing.
A separate category — applications that ignore you: browsers with DoH inside them, messengers with hard-coded resolvers, devices with patched 8.8.8.8. They are addressed by the rule {"port": 53, "action": "hijack-dns"} plus blocking outbound DoH — but you cannot completely close this gap on the client side, only at the gateway.
What this approach will not do
Skepticism in good faith, to avoid inflated expectations.
Geo lists lie. geoip-ru is a database of IP ownership, and it lags behind reality. A Russian service behind a foreign CDN will not be included, while a foreign service hosted in Russia will be included in error. Be prepared to supplement the list manually via inline rule-set.
CDN shared across all. The same Cloudflare or Akamai address serves both blocked and allowed resources. The IP-based rule here is inherently powerless — only domain-based routing helps, i.e. a working sniff.
Routing does not fix protocol-level blocking. If DPI throttles your connection to the server, no client-side rules will help — the problem lies lower in the stack. This is a separate topic discussed in diagnostics of blockages by symptoms.
Advertising cannot be blocked completely. reject at the connection level does not affect what comes from the same domain as the content — YouTube is an example.
Sources (all official project documentation)
- sing-box: Route Rule
- sing-box: Rule Action
- sing-box: Rule Set and Source Format
- sing-box: DNS
- sing-box: TUN inbound
- sing-box: Migration — what changed in 1.10–1.14
- sing-box: Changelog
Which strategy do you follow — allowlists, blocklists, or “everything through a proxy and don’t think about it”? And practical experience is welcome: what do you manually add to geosite-category-ru / geoip-ru, which services regularly drop out of these lists and break?


