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.

Before and after: services and remote clients Before: three services and four remote clients, with twelve paths crossing a band of ports, VPN routes and certificates. After: the same seven machines each make one outbound connection to a relay, and calls reach services by name in both directions. Before ports, VPN, certs service A service B service C site agents laptops CI runners partners 3 × 4 = 12 pairs to open and secure After service A service B service C site agents laptops CI runners partners relay hosted or yours 3 + 4 = 7 outbound connections, both ways
Before, every service and client pair crosses an opened port, a VPN route and a certificate; after, each machine makes one outbound connection and reaches any allowed service by name.

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 caseWhat the layer does, and what you see
Machines behind NAT; idle connections dropped by middleboxesEvery 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 dropsAgents 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 readerEvery session has its own credit and a bounded buffer: a reader that stops stalls only its own sender; other sessions keep flowing.
Half closePassed 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 deploysA 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 endNo 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 trafficOptional: with e2ee and a pin, a wrong key fails the handshake, exit 7, nothing delivered.
Seeing itPer 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's TestChainMiddleHopFaultsMixed). 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 ., and cd 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 nfltr and nfltr-relay binaries against a private relay with TLS, agents run by nfltr up in front of in-process services, on one machine.
Numbers
Pass or fail per case, no timings: bytes and half close both ways, every nfltr dial exit code (3 to 8), a relay that drains (sessions kept to the deadline, then exit 6 with reason drain_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 and dial.
Reproduce
(a) With the released binaries: publish a service with e2ee: true and run nfltr dial with 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=3 repeats 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.

Steps to reach a remote service, before and after Before: five steps, a firewall and DNS ticket, a VPN profile, a certificate from the private CA, TLS and keepalive code in each language, and a staging tunnel. After: two edits, a connect mapping and pointing the client at its local socket, and the same file works from a laptop, CI or a site. Before After Firewall + DNS ticket another team opens a port VPN profile per person and CI job Certificate from a CA trusted, renewed on time TLS, keepalive code 51 lines in the .NET client Staging tunnel to test from a laptop A connect: mapping or nfltr dial in a script Point the client at it same gRPC or HTTP code Same file everywhere laptop, CI, site, partner 2 edits, no ticket no VPN, no port, no cert 5 steps, 3 through other teams → 2 edits
Reaching a remote service goes from five steps, three of them through other teams, to two edits a developer makes alone.

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 the platform team owns, before and after Stops owning: inbound ports, public DNS names, a private CA, certificate renewals, VPN accounts, TLS client code and keepalive settings. Still owns: the relay, hosted or its own; a sidecar per machine; account key files; access, through declared services and shares; status and metrics per machine. Stops owning Still owns Inbound ports firewall, NAT, LB rules Public DNS names one per exposed service A private CA and its key, kept safe Certificate renewals before each one expires VPN accounts per person and CI job TLS client code in every client language Keepalive settings both sides, every client The relay hosted, or your own A sidecar per machine new: one small process Account key files new: 0600, per machine Access declared services, shares Status and metrics one endpoint per machine 7 kinds of recurring work gone; 2 things added per machine
Seven kinds of work that grow with every service and client go away; what remains is one relay and a sidecar with a key file on each machine.

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/tcp and /proc/net/tcp6 while 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 -ltn on 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: true and 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, allow or forbid, 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-bench and make gke-scale-soak against a cloud cluster (cloud cost, no model spend), plus a script for the sidecar ceiling step; the local counterpart, make soak-relay-kind on 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.

← All posts