Remote gRPC with no open ports, no VPN and no CA to run
Point a gRPC channel at a local socket instead of a host name, run one small sidecar per machine, and services on other networks become reachable by name: machines behind NAT, laptops, CI runners, a partner's servers. Nothing listens on the network, so there is no port to open, no VPN to join and no certificate authority to keep alive. gRPC stays gRPC: the stubs, streaming, deadlines and metadata do not change.
We moved a .NET gRPC fleet onto the nfltr connect sidecar and measured it end to end. This is what it gained, how small the change was, and what it costs.
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 dialexits 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.
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.
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.
The fleet in this story is real code: a control plane and three agents in .NET 8 with ASP.NET Core gRPC, in the repository as examples/dotnet-fleet, twice, before and after. 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.
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:
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
SidecarChannelhelper (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.
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.
That is a good trade. A call between networks already spends milliseconds on the wire, and a fraction of a millisecond buys back 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 dialexits 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,/metricsand 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.
- 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.
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: trueand 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,alloworforbid. The hosted relay encrypts new tcp services by default.
Try it
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.
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.
The numbers
From two runs of the fleet's end-to-end test (make test-dotnet-fleet-example) on October 16, 2026, on one Mac (Apple Silicon) with Docker. The relay, sidecars and programs share that host, so "after" is the cost of the sidecars and relay themselves, not network latency. p50 / p95 in ms, measured in the control plane around the gRPC call; the full tables are in the example's README and MIGRATION.md.
| Call | Run 1 | Run 2 |
|---|---|---|
| Session, before (direct TLS) | 0.18 / 0.69 | 0.23 / 0.53 |
| Session, after (encrypted) | 0.31 / 0.74 | 0.51 / 0.83 |
| Agent by name, warm (encrypted) | 0.43 / 0.65 | 0.44 / 0.76 |
| Agent by name, new connection (plain / encrypted) | 0.87 / 1.39 | 1.16 / 1.65 |
| 100 updates, before | 0.94 / 1.74 | 1.37 / 2.06 |
| 100 updates, after | 0.52 / 1.75 | 0.92 / 3.25 |
The new-connection row shows p50 for plain and encrypted. Restart, killed mid-command, to the first successful call: before 593 to 595 ms; after, an agent alone 338 to 347 ms, an agent together with the sidecar it owns 600 to 693 ms. Stack start to the first ok command on every agent: 2101 ms before, 1933 ms after. Listeners were read from each machine's /proc/net/tcp and /proc/net/tcp6 while it ran, sidecars included; the relay is not counted.