Remote gRPC with no open ports, no VPN and no CA to run

A gRPC client that has to reach a server on another network (an agent at a customer site behind NAT, a laptop, a partner's server) usually needs a public TLS port with a DNS name and a firewall rule, a private CA and its renewals, keepalive tuning so NATs do not drop idle streams, and a VPN for everyone else. Even then the server cannot call the client back, because nothing reaches a machine behind NAT. nfltr connects machines through an outbound-only relay. Its connect sidecar gives the application a local socket and carries the bytes, unparsed, to a named service on another machine, so the gRPC code stays: stubs, streaming, deadlines and metadata.

We moved a .NET 8 gRPC fleet onto the sidecar and measured it end to end in two runs. The change was one line of channel registration plus a 43-line helper (+52 / −42 code lines in all); the public listener and the private CA went away; a call cost 0.1 to 0.3 ms more at p50 on one host; and the control plane can now call agents by name, which it could not do before. Below are the diff, the costs and what is still missing.

What you get

  • No inbound ports. Listeners reachable from outside: 1 → 0, read from every machine while it ran. Machines behind NAT need no rule.
  • No CA to run. Private CAs: 1 → 0. Certificate issuance and renewal are gone; every hop to the relay is TLS.
  • A one-line code change. The gRPC channel registration is one line, and the 51-line TLS client file is deleted. Protocol and business logic: untouched.
  • Every machine reachable by name, both ways. Agents behind NAT, laptops and partners' machines are <agent>/<service>. The control plane now calls agents directly; before, it could not reach them at all.
  • Restarts and relay deploys handled. An agent killed mid-command was back in 347 ms with no operator action (593 ms before). A session cut by a relay deploy ends with the reason drain_deadline; nfltr dial exits with typed codes.
  • Optional end-to-end encryption. TLS 1.3 between the two sidecars with a pinned key. A wrong pin exits 7 and delivers nothing.
  • What it costs and lacks. 0.1 to 0.3 ms more per call at p50 on one host, one sidecar and an account key file per machine. One service name is one agent, and Windows has no CI runner yet. The example is one small fleet, measured in two runs.

Developers stop writing TLS, keepalive and reconnect code in each language, and test against a real remote service from a laptop without a VPN.

The organization stops filing firewall tickets, onboarding people to a VPN and renewing certificates, and gets one /status and /metrics per machine for every remote session, whatever language wrote the client.

One fleet, before and after

Take a company with a control plane in its cloud that sends commands to .NET agents running at many customer sites, often on Windows and behind NAT. Each agent keeps one bidirectional gRPC stream open to the control plane: commands go down, progress and results come up.

Before, the team built and ran: a public TLS port on the control plane with a DNS name and an inbound firewall rule; a private CA and the certificate it issues, renewed before it expires; agent code that trusts that CA, checks the server name and sends HTTP/2 keepalive pings so NATs do not drop an idle connection; and a VPN for the laptops and CI jobs that need the same services. The control plane could only answer agents. It could not call one, because nothing reaches a machine behind NAT.

Before: inbound paths Site agents behind NAT reach a public port on the control plane through a firewall rule; laptops and CI come in through a VPN gateway. A private CA issues the control plane's certificate. Nothing can call an agent. other networks firewall your cloud site agents behind NAT laptops, CI VPN profiles public port DNS, inbound rule VPN gateway accounts control plane cert to renew private CA issues, rotates control plane → agent: impossible, nothing reaches behind NAT
Before: every remote path is an inbound path someone opened, secured and keeps open, and the control plane still cannot call an agent.

After, each machine runs a sidecar that connects out to the relay, and the relay meets the two ends. The control plane's listener is on loopback, the CA is gone, the keepalive code is gone, and the control plane calls any agent by name.

After: outbound-only sidecars Site agents, laptops and CI, and the control plane each run a sidecar that connects out to the relay. The firewall has no inbound rule and no machine listens on anything but loopback; calls flow both ways through the relay. other networks firewall your cloud site agents + sidecar laptops, CI + sidecar or dial relay hosted or yours control plane loopback + sidecar every arrow is outbound; calls flow both ways inside
After: every machine connects out, the firewall has no inbound rule, and the control plane reaches each agent through the relay by name.

The fleet in this story is real code: a control plane and three agents in .NET 8 with ASP.NET Core gRPC, written twice, before and after, as an example in our closed nfltr repository. An end-to-end test runs both in containers and checks by exit codes that every agent's outcome, progress and output are the same. Part 1 puts this fleet in the wider picture of many services and many clients.

Evidence: listeners and CAs before and after

Setup
The fleet's end-to-end test, before and after stacks in containers on one Mac (Apple Silicon) with Docker, against a private relay; two runs on October 16, 2026. Listeners were read from each fleet machine's /proc/net/tcp and /proc/net/tcp6 while it ran, sidecars included.
Numbers
Non-loopback listeners 1 (the control plane's 0.0.0.0:8443) → 0; private CAs 1 → 0; CA-issued certificates 1 → 0; self-signed certificates whose SHA-256 peers pin 0 → 3; processes on the 4 machines 4 → 4 + 4 sidecars; the control plane calls an agent by name: no (UNIMPLEMENTED, checked) → yes.
Reproduce
(a) With the released binaries: run nfltr up on two machines as in the last section and list listening sockets (ss -ltn). (b) Our check, in the closed nfltr repository: make test-dotnet-fleet-example (needs Docker and openssl); the example's README and MIGRATION.md there hold the full tables.
Limits
The relay is not counted: hosted, it is not yours to run; your own relay is one more public endpoint, shared by every fleet of the account. An account key file now sits on every machine.

How small the migration is

The sidecar gives the application a local endpoint and carries its bytes, unparsed, to the service on the other side:

One session through the connect sidecar An app speaks its own protocol to a local socket of its sidecar. The sidecar and the service's agent both connect out to the relay. Optionally the two sidecars run TLS 1.3 inside the session with a pinned certificate. optional: TLS 1.3 inside, pinned app gRPC client sidecar connect: relay agent services: service gRPC server local socket both connect out its own machine the bytes are the app's own: HTTP/2, gRPC, a database's protocol
The application talks to a local socket, both sides connect out, and the relay carries a byte stream it never needs to parse.

In the agent, the channel registration changes from a TLS client to the sidecar's endpoint:

-builder.Services.AddSingleton<GrpcChannel>(ControlPlaneChannel.For(builder.Configuration.GetSection("ControlPlane")));
+builder.Services.AddSingleton<GrpcChannel>(SidecarChannel.For(builder.Configuration["ControlPlane"] ?? throw new ArgumentException("--ControlPlane is required")));

Its configuration loses the URL, the CA path, the server name and three keepalive settings, and gains one line: "ControlPlane": "unix:/run/nfltr/fleet.sock". Each machine gets a sidecar file. The agent's both publishes its own service and connects to the control plane:

agent: edge-1
services:
  execute: { tcp: 50051, e2ee: true }       # reachable as edge-1/execute
connect:
  fleet: { target: controlplane/fleet, listen: "unix:/run/nfltr/fleet.sock", pin: "${CONTROLPLANE_PIN}" }
status: "unix:/run/nfltr/status.sock"

The whole migration is one patch, applied by a script in 22 ms and checked to produce the new tree:

  • App code: 4 files, +52 / −42 code lines. That is the one-line channel change, a new 43-line SidecarChannel helper (unix socket, named pipe or loopback), and the deleted TLS client.
  • Configuration: 4 files, +27 / −18, including the two sidecar files.
  • Unchanged: the protobuf definitions, the business logic and both project files.
  • Optional, new capabilities: 4 files, +158 code lines, for calling agents by name and letting an agent start and own its sidecar. The old fleet could not do this at all.

Evidence: the size of the migration

Setup
The example fleet in our closed nfltr repository: before/, after/ and one migration.patch, applied to a copy of before/ by migrate.sh and checked to produce after/; two runs on October 16, 2026. Code lines counted without comments and blank lines.
Numbers
App code 4 files, +52 / −42 code lines (+79 / −69 with comments); configuration 4 files, +27 / −18; optional new capabilities 4 files, +158 code lines; patch applied in 22 ms in both runs; protobuf definitions, business logic and both project files unchanged.
Reproduce
(a) Not from outside: the example is not published, so the counts are our record; the diff above is its central change. (b) Our check, in the closed nfltr repository: make test-dotnet-fleet-example (needs Docker and openssl), which applies the patch, builds the after stack from the result and writes the counts into the example's MIGRATION.md.
Limits
One fleet, written for this comparison, small and in one language. A real codebase with TLS options spread over several places will have a larger diff; the line counts measure this example only.

The rollout can be gradual: old and new paths run side by side, configuration picks one per agent, and setting the old value back rolls an agent back.

A Go client changes its target the same way, and Python with grpcio uses grpc.insecure_channel("unix:///run/user/1000/nfltr/ledger.sock"):

// before: grpc.NewClient("ledger.internal:443", <TLS credentials>)
conn, _ := grpc.NewClient("unix:///run/user/1000/nfltr/ledger.sock",
    grpc.WithTransportCredentials(insecure.NewCredentials()))
client := ledgerpb.NewLedgerClient(conn) // unchanged

Plaintext to the local endpoint is fine on a unix socket (mode 0600) or a named pipe (your account only). The hop between machines is TLS, and with a pin it is encrypted end to end.

What it costs

  • Latency: 0.1 to 0.3 ms more per call at p50 on a warm session, through two sidecars and the relay with end-to-end encryption on, measured on one host. A new connection costs a session open: 0.9 to 1.2 ms, or 1.4 to 1.7 ms encrypted. Streams did not get slower: 100 progress updates back to back took 0.92 ms after against 1.37 ms before.
  • One small process per machine, beside the application or started by it, and the account key in a 0600 file on each machine.
  • The relay is on the path, so a real deployment adds the network distance to it.

p50 / p95 in ms per call, measured in the control plane around the gRPC call (the new-connection row shows p50 for plain and encrypted):

CallRun 1Run 2
Session, before (direct TLS)0.18 / 0.690.23 / 0.53
Session, after (encrypted)0.31 / 0.740.51 / 0.83
Agent by name, warm (encrypted)0.43 / 0.650.44 / 0.76
Agent by name, new connection (plain / encrypted)0.87 / 1.391.16 / 1.65
100 updates, before0.94 / 1.741.37 / 2.06
100 updates, after0.52 / 1.750.92 / 3.25

Evidence: the cost per call

Setup
The same fleet, two runs on October 16, 2026, on one Mac (Apple Silicon) with Docker; relay, sidecars and programs share that host. "Before" is the same call over a direct TLS connection between two containers. 100 calls per row (30 for new connections, 20 for 100 updates) after as many untimed ones.
Numbers
A session call +0.13 ms and +0.28 ms at p50 (two sidecars and the relay, encryption on); a new connection 0.87 to 1.16 ms p50 plain, 1.39 to 1.65 ms encrypted; 100 progress updates back to back 0.52 and 0.92 ms after against 0.94 and 1.37 ms before.
Reproduce
(a) With the released binaries: the commands at the end and your own client timing calls to the local endpoint against a direct connection. (b) Our check, in the closed nfltr repository: make test-dotnet-fleet-example, whose report script prints these tables from two runs' results.json.
Limits
Two runs on one host: no network distance, no packet loss, no load. A real deployment adds the trip to the relay. p95 moves a lot between the two runs (3.25 against 1.75 ms for the updates), so compare the p50s.

A call between networks already spends milliseconds on the wire, so a fraction of a millisecond is small next to it. What it buys back is a public port, a CA, a VPN and the connection code that every client language had to repeat.

Reliability

  • Fast, typed failures. A session opens only once the agent has reached its target. If the remote is offline, the local endpoint stays up and closes each new connection at once, so gRPC returns UNAVAILABLE and the application's own retry runs. nfltr dial exits 3 not found, 4 refused, 5 unreachable, 6 reset, 7 pin mismatch, 8 parent gone.
  • Relay deploys. A leaving relay sends new sessions to another replica (with a single relay they fail as unreachable until it is back) and lets open ones run until its drain deadline. A session still open then ends with the reason drain_deadline, in /status, /metrics and the exit code.
  • Restarts. In the test, killed agents recovered with no operator action, and gRPC deadlines were cancelled on every agent before and after. An agent killed mid-command took its first successful call 338 to 347 ms after the kill (593 to 595 ms before over TLS); an agent together with the sidecar it owns took 600 to 693 ms. Stack start to the first ok command on every agent: 2054 ms and 2101 ms before, 1941 ms and 1933 ms after.
  • Isolation. Each session has its own flow control, so a client that stops reading stalls only itself. Sessions ride link, which detects a dead peer in seconds; part 3 measures how fast a dead path ends a gRPC call, and which keepalive and deadline settings to keep.

Evidence: restarts and typed failures

Setup
Restarts: the fleet's test, two runs, an agent's container killed mid-command and restarted by Docker, the control plane retrying every 250 ms. Exit codes and drains: the sidecar's conformance suite, real nfltr and nfltr-relay binaries against a private relay on one machine.
Numbers
First ok call after the kill: 338 to 347 ms after, 593 to 595 ms before (Docker's restart included). Conformance: every nfltr dial exit code, a drained session ending with exit 6 and reason drain_deadline, a relay that swaps the key failing with exit 7: pass or fail, no timings.
Reproduce
(a) With the released binaries: stop the agent's nfltr up and run nfltr dial (exit 3), or give it a wrong --pin (exit 7). (b) Our check, in the closed nfltr repository: make test-dotnet-fleet-example and make test-connect-conformance (no model calls).
Limits
A single relay in both tests, so a relay restart is an outage until it is back; with several replicas new sessions go to another one (part 1 has the multi-replica soak).

Security

  • Nothing to scan. Both sides only connect out. The relay admits only your account's key (or a share), and an agent reaches only the targets its own file declares.
  • End-to-end encryption is 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. No CA is involved. 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 may need to inspect traffic on its own relay for compliance or intrusion detection, and some applications already run their own TLS. The relay's operator sets the policy: require, allow or forbid. The hosted relay encrypts new tcp services by default.

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, such as a token in gRPC metadata, and check it.
  • Windows named pipes and Job Objects have Windows-only tests but no Windows CI runner yet; the fleet's end-to-end test runs the Linux side.

Is it for you?

Fits if your gRPC (or any TCP) clients and servers sit on different networks, especially servers behind NAT or on machines you do not control, and you can run one small process beside each application or let the application start nfltr dial. It fits a gradual migration: the old and new paths run side by side and configuration picks one per client.

Not ready for spreading calls to one service name over several agents; per-machine identity at the relay (keep your own per-caller credential); Windows in production without your own testing (Windows code has Windows-only tests, no Windows CI runner yet); paths that cannot take the trip to the relay. Several relay replicas are measured on a test cluster only, the production relay is one VM, running your own relay is a beta, and the nfltr CLI and relay server are closed source (the multiplexer, the relay's client SDK and a one-replica relay core are open source since October 29, 2026).

Try next Install nfltr and sign in on both machines:

curl -fsSL https://nfltr.xyz/install.sh | sh
nfltr config login

On the service's machine, publish a tcp 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, either 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 --log-format json
curl -s http://127.0.0.1:7801/status

or open one session from a script or a program:

nfltr dial home/db --pin <pin> < query.bin > answer.bin

Every flag is in the nfltr up and nfltr dial reference, and Run your own relay covers the relay operator's require / allow / forbid policy. Before you move a long-lived stream, read part 3 on keepalives and deadlines. How the relay compares with frp, rathole, chisel and bore is in this measurement, and running the relay on your own network in Run your own relay.

← All posts