xin

One proxy engine · nginx.conf & Envoy xDS · Rust

Your nginx.conf is a specification. Most replacements treat it as a suggestion.

xin is a memory-safe reverse proxy that runs your existing config bug-for-bug — including the surprises — or refuses to start and names the line it could not honour. What it never does is quietly behave differently.

xind -t
$ xind -t -c /etc/nginx/nginx.conf

refused  nginx.conf:142:5  limit_conn
         limit_conn is not supported
         see docs/design/02-nginx-facade.md

refused  sites/api.conf:31:9  ssl_ciphers
         cannot map OpenSSL cipher-string token "HIGH"
         6 suites are available; name them explicitly
         a cipher policy that is approximated is a config that lies

warned   sites/api.conf:58:5  ssl_prefer_server_ciphers on
         accepted with no effect; xin always behaves as off
         catalogued: divergence 1.4

configuration test failed: 2 errors, 1 warning
(every error at once, never just the first)

The premise

Nobody knows what their nginx.conf does.

Six questions about configuration you have almost certainly deployed. Answer each one before you open it. Every answer is measured against nginx 1.26.3, not recalled from documentation.

01 if_modified_since A client sends If-Modified-Since with a date newer than the file. Does nginx answer 304?

No — it sends the whole entity. The default is exact, and nginx compares the two dates for equality, not for “at least as new”. Implementing the intuitive <= would return 304 where nginx returns 200 — the direction that serves a stale body. So xin defaults to exact too.

02 location Two location blocks match the request. Does the first one in the file win?

No. nginx location selection is not first-match-wins: = beats ^~ beats regexes-in-order beats longest-prefix, and that precedence is independent of the order you wrote them in. Envoy's route table is strictly ordered. xin carries both, because both are the meaning of a real config file.

03 error_log off; What does this turn off?

Nothing. There is no off keyword for error_log. nginx takes off as a filename and creates a file called off relative to its prefix. Your errors are not suppressed; they are in a file you have never opened.

Measured on nginx 1.26.3: a vhost with error_log off; had its connect() failed line written to /tmp/a1/off, and the file was recreated on SIGUSR1. This is one of the few places xin follows the intent instead: nothing is written, and no file named after a directive argument appears.

04 gzip on; Which response statuses actually get compressed?

Exactly 200, 403 and 404. Not 201, not 301, not your 500 page. There is no principle here to derive it from — it is a list in the source, and configs depend on it.

05 proxy_ssl_verify You wrote proxy_pass https://backend;. Is the backend's certificate checked?

Not unless you asked. proxy_ssl_verify defaults to off. The TLS is encrypted and unauthenticated — which is a different security property than the one the https:// in your config appears to promise.

06 proxy_hide_header How many response headers does nginx hide from the client by default?

Seven — the four X-Accel-* control headers plus Date, Server and X-Pad. We shipped this as four and were wrong.

Decision log D41. The first measurement missed Date, Server and X-Pad because those three are replaced rather than dropped — the name is still on the response, so “present” got read as “forwarded”. A test that checks for a header's presence cannot tell forwarded from regenerated. Cost: a duplicate Server on every proxied response, three on a cache hit, and an upstream's stored Date replayed out of cache — a 2001 timestamp served in 2026. Fixed, with a regression test that also fails an over-stripping “fix”.

xin preserves every one of these. Not because they are good, but because somewhere in your fleet a config depends on the one you got wrong — and the moment you find out is the moment you switched binaries, which is exactly when nobody is looking.

The rule, stated in the architecture doc: configuration is a specification; match it, including the surprises. What a config means is the whole of the drop-in claim, so “we improved it” is the single most damaging thing this project could do to itself.

The doctrine

Three outcomes. There is no fourth.

Every directive in a config you hand xin lands in exactly one of these states — at load, before a single byte of traffic arrives.

supported

It is lowered and it runs.

The directive maps onto the intermediate representation and the engine executes it. Behaviour is pinned by a differential conformance case run against real nginx.

divergent

It runs, differently, and loudly.

Where refusing would block adoption, xin accepts and warns — and the difference is written down in a catalog generated from the source, with the line number and the reason.

refused

It will not start.

Unknown or unimplemented directives are a hard load error with a byte span and a “did you mean”. xind -t reports all of them at once, not just the first.

never

Accepted, ignored, and unmentioned.

A drop-in replacement that silently drops deny all turns a closed door into an open one. One that ignores client_max_body_size turns a rejection into an OOM. This is the failure mode the whole design exists to make structurally impossible, and it is why xin has no module ABI — extensions are Rust crates behind Cargo features, so nothing can inject behaviour the engine does not know about, which is what keeps -t meaningful.

The proof

We generate our own bug list.

Every project claims compatibility. The question is what stops the claim from rotting. In xin the answer is a test:

cargo test -p xin-facade-nginx divergence_catalog_is_complete

It fails the build when a directive is accepted with no effect and is not written down in the public catalog. The catalog is derived from the source, not from anyone's memory — which matters, because the first version written from memory missed the fact that xin wrote no access logs at all.

So the compatibility claim is falsifiable rather than aspirational: a difference from nginx is either in the catalog, or it is a bug. It is never ambiguous, and it is never a surprise you find in production.

Read the catalog →

docs/design/05-divergences.md
# Divergence catalog

Every way xin behaves differently from nginx
for a config nginx would accept.

Bugs are not here. Anything in neither place
is a bug nobody has found yet.

Categories, in descending order of how much
they should worry you:

  1. Silently absent behaviour
  2. Behaviour that differs
  3. Configs nginx accepts and xin refuses
  4. Deliberate improvements

--- 3. refused on purpose ---

ssl_ciphers with OpenSSL's cipher-string
grammar (HIGH, !aNULL, @STRENGTH) is
refused at config load, naming the token.

  nginx expands the keyword against whatever
  its local OpenSSL carries. rustls implements
  six TLS 1.2 suites and no CBC/DHE ones, so
  honouring HIGH:!aNULL would mean
  approximating a cipher policy.

  A config nginx accepts fails xind -t.
  That is a real migration cost, and a
  deliberate one: an approximated security
  control is invisible until an audit.

The architecture

One engine. Your choice of config language.

xin is not an nginx clone with other front ends bolted on. It is a proxy engine with an intermediate representation, and a facade is anything that can lower a config file into it. nginx.conf is the first. Envoy's xDS is the second.

config facade crate contract runtime nginx.conf file · SIGHUP reload Envoy xDS stream · ACK by version Caddyfile planned xin-facade-nginx lexer · AST · lowering xin-facade-envoy LDS · RDS · CDS · EDS xin-facade-caddy depends on xin-ir only xin-ir IrConfig whole worlds, never patches xin-engine PostRead → FindConfig → Rewrite → Access → Content → Filter → Log xin-proxy HTTP/1.1 · HTTP/2 · TLS upstreams · cache · ranges RFCs outrank both oracles

The facade rule

A facade depends on xin-ir and nothing else of xin — enforced in CI. It expresses its semantics through IR capabilities, never through engine special cases. If a facade would need a hook in the engine, that is a defect in the plan.

Batch or resident

nginx and Caddy are batch: file to config, driven by the driver binary's signal conventions. xDS and the Kubernetes controllers are resident: a long-lived task producing a sequence of config worlds and ACKing versions back to the control plane. Same seam.

Why it matters to you

nginx at the edge and Envoy in the mesh is two data planes, two CVE feeds, two sets of 3am reflexes and two answers to “what does this header do here?” One engine means one answer, whichever language a given tier happens to speak.

The numbers

Here is the benchmark we lost.

Every proxy's landing page has a bar chart where its bar is longer. Ours is a table where it mostly isn't, because a compatibility project that shades its own measurements has no argument left to make.

Workload xin nginx 1.26.3 ratio
Static, 3-byte file (header generation, not file I/O) 341,030 rps 443,483 rps 77%
Static, 1 MB file (sendfile off, as the harness runs it) 6,475 rps 17,832 rps 36%
Static, 1 MB file (sendfile on, as you would deploy it) 5,712 rps 24,210 rps 24%

What the numbers mean

The 1 MB gap is ~3x, and only ~1.4x of it is sendfile. Our plain read()+write() path is itself ~3x slower — which is a different problem from the one the design documents attribute it to, and knowing that is worth more than the missing feature.

77% is an upper bound, not a best case. nginx's ceiling was still climbing at two samples, so the true figure is 77% or worse, never better.

Why you should believe them

The decision log records four benchmark corrections in one session. Every one had the same shape: a harness measuring something adjacent to what its name claimed.

The durable fix is not more care. It is that a benchmark must state its workload in its output, so the reader sees “3-byte file” next to the number.

The full account, including the one we nearly got wrong →

Who this is for

Three situations. One of them is yours.

01

You have an nginx fleet nobody wants to rewrite.

Sixty-odd vhosts, a decade of accreted rewrite rules, and the person who wrote them left in 2019. The config is the institutional memory. Every other migration path asks you to re-derive its meaning from observed behaviour, under time pressure, with production as the test suite.

xin's answer: don't migrate the config. Point the binary at it. If any part of it cannot be honoured exactly, you learn that from xind -t in staging, with a line number.

02

You run nginx at the edge and Envoy in the mesh.

Two data planes with different header handling, different timeout semantics, different log formats and two independent CVE feeds. The seam between them is where the incidents live, and no single person holds both models in their head.

xin's answer: both config languages lower into one IR and run on one engine. The edge and the mesh stop being two products that happen to sit next to each other.

03

You need memory safety on port 443.

The argument for Rust on the most exposed process you operate makes itself. What kills it in practice is that every memory-safe alternative also demands a behaviour change, and you cannot buy safety with an outage.

xin's answer: the safety argument only works if the compatibility argument holds first. That ordering is why the divergence catalog exists before the benchmark chart does.

Where it actually is

xin is not 1.0, and this page will not pretend otherwise.

A project whose entire pitch is not overstating things has an obvious obligation here. “Feature-complete as nginx” is not a milestone, it is a decade. The target is narrower and defensible: drop-in for the configs people actually run.

running

  • listen, server_name, location with nginx precedence
  • Static files, conditional requests, byte ranges incl. multipart
  • proxy_pass incl. https:// and the proxy_ssl_* set, with exact certificate pinning
  • Upstreams and LB policies with passive health checks
  • rewrite / return / try_files / error_page
  • TLS with SNI, dual cert, ALPN; HTTP/1.1 and HTTP/2
  • gzip, map, real_ip, autoindex, sub_filter
  • auth_request, mirror, X-Accel-Redirect
  • proxy_cache — in memory, catalogued as such
  • Access and error logging with the full variable set

in flight

  • Envoy facade: listeners, routes, clusters and endpoints lower; the xDS state machine is built
  • Kubernetes Ingress and Gateway controllers over the same resident-driver seam
  • A privileged broker for cert reads and zero-downtime binary upgrade
  • The sendfile-class static path the numbers above are asking for

Known operational gaps, published rather than discovered: there is no zero-downtime binary upgrade yet, and after a Let's Encrypt renewal a SIGHUP cannot re-read 0700 root key directories. That one fails safe — the running config is kept and traffic continues on the old certificate — but it does not fix itself.

not doing

  • nginx's mail and stream modules
  • FastCGI, uwsgi, SCGI
  • SSI
  • Lua and njs scripting
  • Third-party C modules
  • A stable module ABI, ever

These are refused at load with a diagnostic, not silently skipped. A build without a feature says so at startup rather than failing at request time.

The whole product is one promise, and it is a negative one.

xin will run your config, or tell you it can't. It will not decide, on your behalf and without saying so, that it knows better.