Every service reachable from every remote client, with no inbound ports
Reaching a service on another network usually costs an inbound port, a DNS name, a certificate from a CA someone runs, a VPN route, and TLS and keepalive code in every client language. Each of those has to stay open, renewed and patched, each open port is one more thing to scan and defend, and with many services (customer sites, edge agents) and many clients (laptops, CI runners, partners) the work grows with services times clients. nfltr connects machines through an outbound-only relay: every machine connects out, and any client your account allows reaches any service by name, in both directions.
We moved a .NET gRPC fleet onto nfltr's connect sidecar and ran the relay as three to five replicas on Kubernetes under faults. The fleet lost its only public listener and its private CA with a one-line client change. The relay carried 1.51× the traffic at five replicas and delivered all 24,958 of 24,958 mailbox messages exactly once through 26 faults, but most faults took more than 50 s to fully clear, and that is still open. This post is the overview; part 2 is the migration step by step, and part 3 the gRPC keepalive and deadline settings to use.
What changes
- No inbound ports to open or scan. In a .NET gRPC fleet we migrated, listeners reachable from the network went 1 → 0, read from every machine while it ran. Machines behind NAT need no rule.
- No certificate authority to run. Private CAs 1 → 0, certificates to renew 1 → 0. Every hop to the relay is TLS; end-to-end encryption uses a pinned key, not a CA.
- A one-line client change. The gRPC channel registration changed by one line and the 51-line TLS and keepalive client was deleted. Protobuf definitions and business logic: untouched.
- Calls in both directions. The control plane now calls agents behind NAT by name; before, it could not reach them at all. An agent killed mid-command answered again within 347 ms with no operator action.
- Reliable connections come from the layer, not from each team's code. Keepalives, reconnects, per-session flow control, drains through deploys and typed failures work the same for every service and language, and a cut session never ends cleanly with bytes missing. See what your code no longer has to get right.
- Grows by adding replicas. 3 → 5 relay replicas carried 1.51× the traffic with 0 failed requests.
- Recovery is not instant yet. In a 7-hour soak, 21 of 26 injected faults took more than 50 s to fully clear, and availability was 99.82 to 99.90 % against a 99.9 % goal. That work is open.
For developers: reaching a remote service is a line of configuration, not a ticket, a VPN profile and TLS code in each language, and it works the same from a laptop.
For the organization: the recurring work that grows with every new service and every new client (ports, DNS names, certificates, VPN accounts, connection code) goes away, and one /status and /metrics per machine shows every remote session.
Connectivity that grows with the sum, not the product
Each new service needs a way in: a listener, a DNS name, a certificate, a firewall or load balancer rule. Each new remote client needs a way through: a VPN account, an allow-list entry, a trust bundle. Each pair then needs both to line up, and someone has to keep them lined up. The work grows with services times clients.
With nfltr, each machine runs a small sidecar that connects out to the relay. A service is published under a name such as edge-1/execute; a client maps that name to a local socket, or opens a session with nfltr dial. The relay carries the application's bytes without parsing them: gRPC, HTTP, a database's protocol. A new service or client adds one outbound connection, not a row in every firewall, VPN and certificate inventory, and a control plane can call an agent behind NAT.
What your code no longer has to get right
Most network bugs live in the code each team writes around its calls: keepalives, reconnects, buffering, shutdown, telling a dropped connection from a finished one. With nfltr that work is done once, in the layer under every service and client.
| Failure case | What the layer does, and what you see |
|---|---|
| Machines behind NAT; idle connections dropped by middleboxes | Every machine connects out, and every connection pings while idle, so a NAT keeps its mapping and a silent peer is declared dead within a known bound (presets such as nat). |
| The relay restarts or the network drops | Agents redial on their own. A client's mapping keeps its local endpoint; sessions on the lost connection end with reset, and the next connection opens a new session. |
| One slow or stalled reader | Every session has its own credit and a bounded buffer: a reader that stops stalls only its own sender; other sessions keep flowing. |
| Half close | Passed through both ways: a client done sending still reads the whole answer. |
| Where did it fail? | An open is answered before any byte flows: it reached the target, or it fails with a typed reason naming the hop that refused, and a trace id. gRPC through a mapping gets UNAVAILABLE at once; nfltr dial exits 3 not found, 4 refused, 5 unreachable, 6 reset, 7 pin mismatch. |
| Relay deploys | A leaving relay sends new sessions elsewhere and lets open ones finish up to its drain deadline; one still open then ends with the reason drain_deadline. |
| A cut that looks like a clean end | No hop turns a cut into a clean close: a cut session ends with an error at both ends; a clean end means every byte arrived. Killing, partitioning and draining the middle hop of 5-hop chains: 0 integrity failures in 630 TCP and 69 mixed QUIC and TCP runs. |
| A relay that reads or swaps traffic | Optional: with e2ee and a pin, a wrong key fails the handshake, exit 7, nothing delivered. |
| Seeing it | Per mapping, in /status and /metrics: state, open sessions, failures by code and reason, bytes. |
Evidence: a cut session never ends cleanly with bytes missing
- Setup
- link's chain fault tests, in process: a 5-hop TCP chain whose middle hop is killed, partitioned or drained (
TestChainMiddleHopFaultsIntegrity, at-race -count=10), and a 5-hop chain of QUIC and TCP links picked at random with echoes up to 4 MiB (quiclink'sTestChainMiddleHopFaultsMixed). The tests were written after a chain soak on Kubernetes found 7 sessions in 99,469 echoes that ended with a clean EOF and bytes missing. - Numbers
- 0 integrity failures in 630 TCP runs (link v0.10.6) and 69 mixed QUIC and TCP runs (v0.10.7). The TCP test fails every run on v0.10.5; the mixed test had 15 failures in 30 partition runs on v0.10.6.
- Reproduce
- (a) In a clone of the public link repository:
go test -race -count=10 -run TestChainMiddleHopFaultsIntegrity ., andcd peerlink/quiclink && go test -race -run TestChainMiddleHopFaultsMixed .(its own module) for the mixed chain. (b) Not with the released nfltr binaries. - Limits
- In process, on one machine. They show that no hop turns a cut into a clean end; they say nothing about how fast a cut is reported (part 3 measures that).
What stays yours:
- Deadlines, retries and idempotency of your own calls. The sidecar fails a call at once rather than queueing it, and a cut session is not replayed, so the .NET fleet keeps its retry on UNAVAILABLE and its reconnect loop with backoff.
- Your protocol and its authorization. The relay carries bytes; what they mean and who may send them is yours.
The layer does not make failures disappear. It makes the transport's failures explicit and typed, so the retry logic you keep is short and correct. The sessions ride link, nfltr's stream multiplexer, which does the pinging, flow control and drains. It is checked by link's fault tests (above), the sidecar's conformance suite (below) and the soak further down, whose recovery is not yet instant; the migration post shows it on one fleet.
Evidence: the sidecar's failure behaviour
- Setup
- The connect sidecar's conformance suite: the real
nfltrandnfltr-relaybinaries against a private relay with TLS, agents run bynfltr upin front of in-process services, on one machine. - Numbers
- Pass or fail per case, no timings: bytes and half close both ways, every
nfltr dialexit code (3 to 8), a relay that drains (sessions kept to the deadline, then exit 6 with reasondrain_deadline), a relay that swaps the agent's key or drops the encryption (exit 7, nothing delivered), a client that never reads (only its own sender stalls), reset when the relay dies, grpc-go through mappings anddial. - Reproduce
- (a) With the released binaries: publish a service with
e2ee: trueand runnfltr dialwith a wrong--pin(exit 7), or with the agent stopped (exit 3). (b) Our check, in the closed nfltr repository:make test-connect-conformance(no model calls;CONNECT_CONFORMANCE_COUNT=3repeats it). - Limits
- One host, so no real network loss; the Windows named-pipe and parent-watch code has Windows-only tests but no Windows CI runner yet.
What a developer does to reach a remote service
Most of the cost of reaching a service on another network is not code. It is waiting on other teams, then repeating the same connection code in each language.
The mapping is a few lines in the sidecar's file:
connect:
ledger: { target: billing/ledger, listen: "unix:/run/nfltr/ledger.sock" }
The application connects to that socket instead of a host name; stubs, streaming, deadlines and metadata stay the same. The same file runs on a laptop or in a CI job, against the real remote service.
What the platform team stops maintaining
The same migration, counted from the platform team's side. Every item on the left repeats per service, per client or per language, and most of it has a calendar: renewals, rotations, account reviews.
What moves out is what existed only because the network had to be opened and trusted: in the .NET example, the client code that trusted a private CA, checked a server name and kept NATs from dropping idle connections.
Evidence: a .NET fleet with no inbound listener
- Setup
- A .NET 8 ASP.NET Core gRPC fleet (a control plane and three edge agents), before and after its migration, in containers on one Mac (Apple Silicon) with Docker, against a private relay; two runs on October 16, 2026. Listeners were read from each machine's
/proc/net/tcpand/proc/net/tcp6while it ran, sidecars included, relay not counted. - Numbers
- Non-loopback listeners 1 → 0; private CAs 1 → 0; certificates to renew 1 → 0; the channel registration changed by one line and the 51-line TLS client was deleted; an agent killed mid-command answered again 338 to 347 ms after the kill (593 to 595 ms before); 0.1 to 0.3 ms more per call at p50 through two sidecars and the relay.
- Reproduce
- (a) With the released binaries: the commands at the end on two machines, then
ss -ltnon each. (b) Our check, in the closed nfltr repository:make test-dotnet-fleet-example(needs Docker and openssl), which checks the same business results before and after by exit codes. - Limits
- Two runs of one example fleet on one host, so the latency is the sidecars' and relay's own cost, not network distance. The full tables are in part 2.
Security and control
- Outbound only. No machine accepts a connection from the network, so there is nothing to expose, scan or put behind a load balancer.
- Access belongs to the owner. An account's key reaches only that account's agents, and only the services each agent's own file declares; anyone else reaches a service only through a share. The sidecar adds no policy of its own.
- End-to-end encryption, per service. With
e2ee: trueand a pin, the two sidecars run TLS 1.3 inside the session and accept only the certificate whose SHA-256 is the pin. A relay that swaps in its own key fails the handshake. The relay still sees who talks to which service, when, and how many bytes. - Why it is optional. An organization that runs its own relay may need to inspect traffic for compliance or intrusion detection, and some applications already run their own TLS, which passes through unchanged. The relay's operator sets the policy:
require,alloworforbid, and every session reports whether it is encrypted, so plaintext is visible, never silent.
How it holds up under change
nfltr.xyz runs its relay as one process on one VM. To see what more replicas buy, we ran it as three to five replicas on a Kubernetes cluster for one day, beside production; the multi-replica design and an earlier soak on kind are in The relay as several replicas on Kubernetes.
- Scale-out. Going from 3 to 5 relay replicas carried 1.51× the traffic with no failed request (ideal 1.67×: more visits cross between replicas at five). Through the connect sidecar, gRPC reached 11,556 calls per second with the relays at under one core each; the load generators ran out first.
- Faults. A 7-hour soak injected 26 faults (rolling restarts, pod kills, a Redis restart, partitions, clock skew, node drains, a frozen relay) under 2.57 million requests. All 24,958 of 24,958 mailbox messages arrived exactly once, and the relay fleet returned to its resource baseline after every fault. Recovery was not instant: 21 of 26 faults took more than 50 seconds to fully clear, and that work is open.
Evidence: scale-out and the 7-hour fault soak
- Setup
- One run on a managed Kubernetes cluster (2 vCPU nodes, one zone), October 15 to 16, 2026, then torn down. Scale-out steps: 3 or 5 relay replicas, one per node, 0.5 CPU each, load generators on their own nodes. Sidecar ceiling: 3 replicas alone on dedicated nodes, 1.5 CPU each, three client pods each running a connect sidecar with six mappings, 1 KiB unary calls. Soak: 7 hours, relay-server v0.20.3, a fault every 15 minutes.
- Numbers
- 3 → 5 replicas: 1.51× (small requests) and 1.49× (64 KiB bodies), 0 failed. Sidecar path: 11,556 calls/s at 576 in flight, p99 168 ms, relays at 0.6 to 0.95 of their 1.5 cores, the load pool 91 to 95 % busy. Soak: 2.57 M requests, 24,958 of 24,958 mailbox messages exactly once, 26 of 26 faults back to the resource baseline, recovery 2 s to 5 min with 21 of 26 over the 50 s bound.
- Reproduce
- (a) Not with the released binaries alone: it needs a cloud cluster and the multi-replica relay server, which is closed. (b) Our check, in the closed nfltr repository:
make gke-scale-cluster,make gke-scale-deploy,make gke-scale-benchandmake gke-scale-soakagainst a cloud cluster (cloud cost, no model spend), plus a script for the sidecar ceiling step; the local counterpart,make soak-relay-kindon kind, does not reproduce these numbers. - Limits
- One run, one cluster. Availability was 99.82 to 99.90 % against a 99.9 % goal, rolling restarts lost 30 to 124 requests (the drain window was too short), and the soak found bugs that were fixed after it. The sidecar ceiling was measured with end-to-end encryption off and with three replicas only. The production relay is still one VM.
Adopting it one service at a time
Nothing has to move at once. Install the sidecar on a few machines and publish one service next to its existing listener. Ship a client build that can use either path and let configuration pick one per client; setting the old value back rolls that client back. When every client of a service is on the new path, close its public listener, its firewall rule and its certificate.
gRPC stays gRPC. A Go client changes its target to unix:///run/nfltr/ledger.sock, Python with grpcio uses the same address, and the .NET fleet changed one line of channel registration. The step-by-step migration, with the diff and the measured cost per call, is in Remote gRPC with no open ports, no VPN and no CA to run.
Limits
- One name is one agent. Calls to a service name are not spread over several agents yet; put a load balancer behind one agent.
- The relay authenticates the account key, not the machine. A key reaches every service of the account, so keep the application's own per-caller credential and check it.
- The relay is on the path. A call adds the network distance to the relay, plus 0.1 to 0.3 ms at p50 for the two sidecars and the relay, measured on one host.
- Pins follow certificates. If an agent's certificate changes, encrypted clients fail closed until they are given the new pin.
Is it for you?
Fits if you run services on machines you cannot or do not want to open to the network (customer sites, edge devices behind NAT, laptops, CI runners) and the clients are your own code or tools that can talk to a local socket: gRPC, HTTP, a database's protocol, any TCP byte stream. It fits best when the number of services and clients keeps growing and each new pair is a ticket today.
Not ready for spreading one service name over several agents (put a load balancer behind one agent); per-machine identity (the relay authenticates the account key, so keep your own per-caller credential); clients that cannot run a sidecar or start nfltr dial; latency-critical paths that cannot take the trip to the relay. The relay on nfltr.xyz is one VM; several replicas are measured on a test cluster only, and running your own relay is a beta. Windows support has tests but no Windows CI runner yet. The nfltr CLI and the relay server are closed source; the multiplexer under the sessions, the relay's client SDK and protocol, and a one-replica relay core became open source on October 29, 2026.
Try next on two machines. Install nfltr and sign in on both:
curl -fsSL https://nfltr.xyz/install.sh | sh
nfltr config login
On the service's machine, publish a service under an agent name and note the pin it prints:
# home.yaml
agent: home
services:
db: { tcp: 5432, e2ee: true }
nfltr up -c home.yaml
On the client, map it to a local endpoint and point the application there:
# app.yaml
connect:
db: { target: home/db, listen: "127.0.0.1:5432", pin: "<pin>" }
status: "127.0.0.1:7801"
nfltr up -c app.yaml
curl -s http://127.0.0.1:7801/status
or open one session from a script:
nfltr dial home/db --pin <pin> < query.bin > answer.bin
The nfltr up and nfltr dial reference has every flag, and Run your own relay covers putting the relay on your network. Then read part 2 for a real migration, part 3 for the gRPC keepalive and deadline settings, and the relay against frp, rathole, chisel and bore for how the relay compares with other tunnels.