sing-box config reference for sane self-hosted routing
A practical sing-box configuration guide covering route.final, rule-sets, DNS rule deprecations, selector, URLTest, and tun loop prevention.
The right mental model for sing-box is tags first, rules second.
If you treat the config as one giant JSON blob, it becomes unreadable fast and ages even faster. If you treat it as a graph of named components, it gets much easier to reason about:
- inbounds receive traffic
- outbounds send traffic somewhere
- route rules choose which outbound tag should win
- DNS logic decides how names get resolved
- rule-sets keep policy from turning into copy-pasted garbage
That is the durable picture to keep in your head.
Start smaller than your ambitions
Most bad sing-box configs are not wrong because sing-box is hard. They are wrong because the author started with an overgrown tutorial config full of six transports, nine geo lists, three selector layers, and old fields copied from a previous release.
For most self-hosted routing stacks, the small default is better:
directblock- one real proxy outbound
- optionally
selectororurltest - a
route.finalthat names the normal default path
If you cannot explain those five pieces clearly, you are not ready for a giant config.
One clean starter shape
This is the kind of skeleton worth keeping around:
{
"outbounds": [
{ "type": "direct", "tag": "direct" },
{ "type": "block", "tag": "block" },
{ "type": "socks", "tag": "proxy", "server": "127.0.0.1", "server_port": 1080 },
{
"type": "selector",
"tag": "select",
"outbounds": ["proxy", "direct"],
"default": "proxy"
}
],
"route": {
"rule_set": [
{ "type": "local", "tag": "local-policy", "format": "source", "path": "/etc/sing-box/policy.json" }
],
"final": "select",
"auto_detect_interface": true
}
}
That example is deliberately boring.
It tells you almost everything important:
- outbounds have tags
- route logic targets tags
- policy can live in reusable rule-sets
finalnames the fallback path- loop prevention is not optional under
tun
According to the official route docs, if final is empty the first outbound will be used. That is convenient behavior, but I would not rely on convenience here. A named final is easier to read and harder to misinterpret six months later.
Route is where the config becomes operational
route is the part of the config where sing-box stops being a list of components and starts becoming a traffic policy engine.
This is where the important fields live:
rulesrule_setfinalauto_detect_interfacedefault_interfacedefault_domain_resolver
The route docs are authoritative, but they are reference docs, not a mental model. The practical way to think about it is:
- rules are the immediate matching logic
- rule_set lets you keep reusable policy outside that inline pile
- final is the default outbound when nothing else wins
- interface binding keeps
tunmode from routing into itself
That last part deserves special attention.
The other field worth noticing here is default_domain_resolver, which the route docs added in 1.12.0. The point of that field is not glamour. It is consistency. Some outbounds need domain resolution as part of their own work, and a named default resolver path is better than letting that question stay implicit. When configs get weird, "which resolver does this outbound use when it needs a name turned into an IP?" is exactly the sort of question you do not want to answer by guesswork.
tun loop prevention is not an advanced bonus feature
If you use tun, traffic loops are one of the easiest ways to make a config look alive while behaving nonsense.
The official docs for both route and tun are explicit: set one of these correctly to avoid loopback problems:
route.auto_detect_interfaceroute.default_interfaceoutbound.bind_interface
The simplest sane default is:
- use
route.auto_detect_interface: true
Why? Because it binds outbound connections to the default NIC automatically, which prevents the classic mistake where the proxy's own egress gets caught by the tunnel path and routed back into sing-box.
That one flag is worth more than a lot of copied "advanced routing" advice because it teaches the right thing: the proxy needs a way to escape its own interception path.
This is the same discipline you need in /blog/multi-hop-wireguard-cascade. Return path first, cool topology second.
If you are tempted to skip this because the config "seems to work," remember that routing loops are often partial. They do not always fail instantly and loudly. Sometimes they fail only for one class of outbound, one resolver path, or one boot order. That is why loop prevention belongs in the baseline design rather than the bug-fix phase.
Rule-sets are how you stop hating your future self
sing-box's rule-set docs support inline, local, and remote forms. The right choice depends on ownership and change rate.
Use this decision rule:
- inline for tiny, tightly local policy
- local for policy you own and want under config management
- remote for maintained external lists you are willing to trust operationally
That is much clearer than arguing about format purity.
A remote rule-set can look like:
{
"type": "remote",
"tag": "remote-policy",
"format": "binary",
"url": "https://example.com/policy.srs",
"download_detour": "direct",
"update_interval": "1d"
}
That download_detour detail matters more than it first appears. It tells sing-box how to fetch the policy itself. If you do not think about that path explicitly, you can create circular behavior or unintended policy dependencies.
This is also the place to be opinionated about ownership. Remote rule-sets are attractive because they reduce maintenance, but they are also where people smuggle blind trust into otherwise careful configs. If your routing policy is core to the service rather than a convenience list, prefer local rule-sets you version yourself. Remote is best for maintained commodity policy, not for the most sensitive decisions in your topology.
For policy you own, local rule-sets are often the sweet spot. The docs note that local files automatically reload when modified, which is exactly the sort of quality-of-life feature that rewards keeping policy separate from the main config.
If you need source-format rule-sets, the official workflow is:
sing-box rule-set compile --output route-policy.srs route-policy.json
sing-box format -w -c config.json
And yes, use the formatter. The top-level configuration docs include sing-box format for a reason. Hand-formatting large JSON by force of personality is not a routing strategy.
DNS rules are where stale blog posts hurt people
The official DNS rule docs are the section most people skip right before they copy an obsolete config fragment into production.
The main problem is drift.
Some older sing-box guides still show DNS matching and action fields in locations that are now stale. The big items to call out are:
- legacy DNS
outboundmatching is deprecated in1.12.0and scheduled for removal in1.14.0 - fields like
server,disable_cache,rewrite_ttl, andclient_subnetwere moved into DNS Rule Action in1.11.0
That means two things in practice:
- old blog posts can still look plausible while being structurally stale
- you should prefer current action placement over clever legacy fragments you found on a forum
If you want this article to age better than the average sing-box post, keep that sentence in mind: DNS is where stale examples go to hurt people.
This also overlaps with /blog/pihole-doh-home-network, because resolver architecture and traffic routing age badly when copied without version awareness.
selector and urltest are useful, but optional
The selector docs and urltest docs describe two very useful primitives:
selectorlets you choose among candidate outboundsurltestautomatically prefers the lower-latency option among candidates
selector is especially useful when you want a human-controlled default path, but remember the current limitation from the docs: it is controlled through the Clash API.
urltest is useful when multiple outbounds are really interchangeable and you want automation. The defaults matter here:
- URL defaults to
https://www.gstatic.com/generate_204 - interval defaults to
3m - tolerance defaults to
50ms - idle timeout defaults to
30m
A simple example:
{
"type": "urltest",
"tag": "auto",
"outbounds": ["proxy-a", "proxy-b"],
"url": "https://www.gstatic.com/generate_204",
"interval": "3m",
"tolerance": 50,
"idle_timeout": "30m"
}
But do not cargo-cult these features into every config. If you only have one real outbound and one direct fallback, a fixed final path is often clearer than "automatic intelligence" you will later forget how to debug.
That is the broader sing-box maturity test: every feature that feels powerful also creates another state transition to explain later. selector means the active path can change because a human or controller changed it. urltest means it can change because the latency logic changed its mind. Those are good tools when the topology genuinely needs them. They are lousy defaults when the network should be boring.
How to read an existing config without getting lost
When someone hands you a sing-box config and asks "does this make sense?", read it in this order:
- list the outbounds and their tags
- find
route.final - inspect
rule_setandrules - inspect DNS actions and check for stale fields
- inspect
tunand interface escape settings
If you reverse that order, you end up reading thousands of braces before you even know what the normal path is supposed to be.
This reading order also makes reviews much faster. You can usually detect whether a config is coherent long before you understand every branch. If the tags are chaotic, final is implicit, DNS still uses legacy structure, and tun has no loop story, you already know the config has architectural debt.
A few good habits beat a thousand lines
The durable sing-box habits are not glamorous:
- tag everything clearly
- keep the outbound set small
- move reusable policy into rule-sets
- name your
final - keep DNS logic current
- prevent loops before you chase performance
If you do that, the config stays understandable. If you do not, the config becomes a museum of copied fragments.
That is also why I would rather see a clean 120-line config than a 700-line one that claims to solve the planet. sing-box is flexible enough to reward discipline and flexible enough to punish maximalism.
The RouteHarden opinion
sing-box gets dramatically easier once you stop treating it like a wall of JSON and start treating it like a tagged routing graph.
Start with a small outbound set. Let route.final state the obvious default. Use rule-sets when policy deserves its own file. Keep DNS rule placement current. And if you are running tun, assume loop prevention is part of the minimum viable config, not a later optimization.
That approach will age better than almost every "ultimate sing-box config" post on the internet.
And if a tutorial you are reading does not tell you where the traffic escapes, where the DNS actions live in the current version, and which tag wins by default, close it. It is probably teaching memorization instead of understanding.