xin

Compatibility, stated as a liability

The divergence catalog

Every way xin behaves differently from nginx for a config nginx would accept. Bugs are not in here — open bugs live in the roadmap — and anything in neither place is a bug nobody has found yet.

The point of a catalog is that it is complete, so it is generated from the source rather than from anyone's memory:

cargo test -p xin-facade-nginx divergence_catalog_is_complete

fails the build when a directive is accepted with no effect and is not listed. Writing this list from recollection missed that xin wrote no access logs at all, which is not a subtle omission.

What follows is a curated selection. The authoritative list — longer, and updated with the code — is in the repository.

1

Silently absent behaviour

The config asks for something, xin accepts the config, and the thing does not happen. This is the category the whole design exists to prevent. Everything in it is a defect awaiting a fix, not a decision — and it is published first, because it is the one that should worry you most.

ssl_prefer_server_ciphers on

nginx
Decides which side's preference order picks the TLS 1.2 suite. Measured on 1.26.3 with two suites configured and a client preferring AES256: on negotiates AES128 (the server's first choice), off — the default — negotiates AES256.
xin
Accepted with no effect; xin always behaves as off, because rustls walks the client's order unless ignore_client_order is set.
why
Matches nginx exactly when the directive is absent or off, and diverges when it is on. Both suites are in the configured list, so this is a preference difference and not a policy hole — but an operator who put a suite last on purpose does not get their ordering. Not a rustls limitation: what is missing is a field on xin_ir::TlsConfig to carry it.

error_log scope for out-of-frame diagnostics

nginx
A streamed body that fails mid-flight, a tunnelled connection that times out, an accept() failure — each attributed to the scope that was current.
xin
Goes to the http-level log.
why
Those run on tasks spawned after the request handler returned and carry no scope with them. Everything raised while the request is still live — every upstream connect, handshake and read failure, “no endpoint available”, an unreadable file the engine selected — is routed to the matched scope.

access_log scope after an internal redirect

nginx
Logs to the final location's file. Measured: location /missing { error_page 404 /fallback; } with access_log set on /fallback puts the line — still spelling "GET /missing" — in /fallback's file.
xin
Logs to the location the request path selected, before any re-routing.
why
The scope is resolved from the normalized path because the phase machine that does the re-routing lives in another crate and returns only an action. The two agree for every request that is not re-routed. Closing it needs the phase machine to report the location it finished on — a one-field out-parameter, not a redesign.

send_timeout at server or location scope

nginx
That scope's responses get that timeout.
xin
The value from any scope is applied to the whole connection.
why
Responses are written through hyper, which owns the socket; the bound is an AsyncWrite wrapper and there is no per-response write call to hang a per-location value on. The http-level value — where essentially every config sets it, including nginx's own default — is exact.

user, at reload

nginx
The master stays root, so SIGHUP re-reads a renewed certificate whatever its permissions.
xin
The privilege drop is process-wide, so a reload re-reads certificates as the dropped-to user.
why
xin is one process with a thread per core, and Linux credentials are per-process. Operational consequence: with Let's Encrypt's default 0700 root on live/ and archive/, a reload after renewal fails to load the new certificate. It fails safe — the running configuration is kept and traffic continues — but it does not fix itself, and the certificate does expire. Make the keys readable by the group xin drops to, or restart rather than reload after renewal.
2

Configs nginx accepts and xin refuses

Configuration is a specification and gets bug-for-bug fidelity, so this list is short and is about tolerance, never about meaning. Each one is a real migration cost, accepted deliberately because the alternative is a config that lies.

ssl_ciphers with OpenSSL cipher-string grammar

nginx
HIGH, MEDIUM, DEFAULT, !aNULL, @STRENGTH — expanded against whatever suites the local OpenSSL build carries. Measured: ssl_ciphers "HIGH:!aNULL:!MD5" passes nginx -t on 1.26.3.
xin
Refused at config load, naming the token that could not be mapped and listing the six suites that can be. Explicit colon-separated suite lists are supported and enforced.
why
rustls implements six TLS 1.2 suites and no CBC, plain-DHE or static-RSA ones at all, so honouring HIGH:!aNULL would mean approximating it — and a cipher policy that is approximated is a config that lies about a security control. Failing loudly with the token named lets the operator write the suites out. A silent approximation is invisible until an audit.

hash … consistent

nginx
Ketama hashing.
xin
Refused at load.
why
Approximating with plain hashing moves every key on a membership change instead of about 1/n, which defeats the only reason to ask for it.

merge_slashes off

nginx
Leaves repeated / in the URI, so a path segment can carry one through to a proxy.
xin
Refused at load; // is always collapsed.
why
Accepting it and collapsing anyway would silently change which location matches, which is the one thing a routing directive must not do quietly.

ssl_verify_client optional / optional_no_ca

nginx
Requests a certificate, continues without one, reports the outcome via $ssl_client_verify.
xin
Refused at load.
why
Without that variable a config has nothing to act on, so either behaviour enforces something the operator did not ask for.

an unknown $variable

nginx
Refuses it too — unknown "foo" variable, with no file or line, because nginx reports none either.
xin
The same wording. Until recently xin accepted any name and rendered it as an empty string.
why
Under escape=json an unimplemented variable is byte-identical to a genuinely empty one. That is how eight missing variables ran unnoticed in a real production ingress's access log — including $binary_remote_addr, which limit_req_zone keys on, so every client shared one rate-limit bucket where the config asked for one per address.
3

Deliberate, on the data path

The RFCs and operational safety outrank nginx here, and a divergence needs its own reason. Copying a genuine defect on the wire propagates it into every deployment that switches — and the switch is exactly when nobody is looking.

a multi-range request that amplifies

nginx
Serves it, 206. nginx compares only the range payload against the content length, so a set summing to exactly the file size is served and its own ~110-byte-per-part framing then pushes the response past it. Measured on 1.26.3 against a 100,000-byte file: 100 overlapping 1000-byte ranges return 206 with a 110,517-byte body; 1000 one-byte ranges return 105,808; 8000 return 861,808. It is unbounded — amplification grows linearly with header length.
xin
A response is never larger than the resource it ranges over. Where the multipart framing alone would exceed the file, xin sends 200 with the whole file instead.
why
This is the divergence xin is most confident about. The cost is exactly one odd case — two ranges of a 100-byte file arrive as a 200 — and exempting small files would mean picking a byte threshold below which amplification “does not count”. A Range header naming more than 4096 satisfiable ranges declines to the whole file for the same reason.

an upstream where every endpoint is ejected

nginx
Answers 502 without opening a connection. Confirmed under strace -f -e connect: two dead peers, five further requests, zero further connect() calls.
xin
Ejection is suspended once half or more of a pool is out, and the endpoints keep being tried.
why
A pool that looks entirely dead more likely means broken failure detection than N broken backends — and xin counts failures once for all worker threads where nginx counts per worker, so it reaches all-ejected far more readily. Following nginx here would turn one correlated blip into a total outage. Envoy's healthy_panic_threshold defaults to 50% for the same reason.

ejection state sharing

nginx
Per worker process, unless the upstream declares a zone.
xin
One atomic structure shared by every worker thread.
why
Consistent behaviour whichever worker accepts the connection, and faster failover. The same config is N times more sensitive, N being the worker count: max_fails=1 ejects after one failure where nginx on 8 workers needs roughly eight. An operator wanting nginx's tolerance should raise max_fails toward their old worker count.

ip_hash and hash target selection

nginx
nginx's internal hash functions.
xin
Stable, but a different mapping.
why
Those algorithms live in source not available here, and deriving them from a couple of observed answers would be fitting an implementation to output rather than to a specification. Affinity is the promised property and is tested; which backend is not. A migration reshuffles every client's backend once. The property that matters afterwards is matched: taking one backend out moves only the keys that were on it — measured against 1.26.3, four servers, 60 keys, one ejected, zero of the 45 surviving keys moved.

precondition evaluation order

nginx
Evaluates If-Modified-Since even when If-None-Match is present, and both must say “not modified”.
xin
The same.
why
RFC 9110 §13.2.2 orders them instead. nginx is followed, deviating from the RFC: the difference can only turn a 304 into a full response, never serve a stale one, and a client migrating between the two servers must not get a different answer.

proxy_cache storage

nginx
On disk, survives restart, bounded by max_size.
xin
In memory. Does not survive a restart, and is bounded by bytes — every entry is charged its body, its headers and its key, evicting least-recently-used until both the byte budget and nginx's own keys_zone key count fit.
why
A design decision, and the part configs actually depend on — which responses are cacheable and for how long — follows nginx exactly. A zone declared larger than 256 MB holds less than it asked for, and a response above the per-entry ceiling is proxied through uncached; the facade warns when a limit was capped.

$time_local

nginx
Local time with the machine's offset.
xin
UTC, written +0000.
why
xin carries no timezone database, and a log stamped with the wrong offset is worse than one honestly in UTC.

response header spelling

nginx
ETag, Sec-WebSocket-Accept, and whatever case an upstream sent.
xin
Title-cased per hyphen segment, so ETag goes out as Etag.
why
HTTP/1.1 responses are title-cased deliberately — hyper defaults to lowercase, which is right for HTTP/2 and wrong for a client comparing names literally. Field names are case-insensitive by RFC 9110, so this only bites a client that is already broken, and it is invisible over HTTP/2. Checked specifically for WebSockets: a hand-rolled RFC 6455 client that validates Sec-WebSocket-Accept against its own key completes the handshake through xin.
4

Accepted with no effect, and correctly so

Tuning knobs for mechanisms xin does not have, where doing nothing is the faithful behaviour because no response can show the difference: buffer sizing, worker tuning, and the rest. These are listed for completeness — the point of a catalog is that it is complete, so it is generated from the source rather than from memory.

Read the list before you read the pitch.

Most projects publish a compatibility matrix with green ticks in it. This is the other document — the one that says what you lose. If it looks long, that is the point: a short one would mean nobody had gone looking.

The full catalog ↗ The benchmark account Back to the pitch