Every backend developer has the same dead time. You clone a repo. The README says the app needs Postgres, Redis, and an auth service. Now you have a problem that has nothing to do with code: getting all three reachable from localhost.
Postgres is in a Kubernetes pod. Redis is in a Docker container. The auth service runs on a staging server. Three services, three different locations, three different ways to connect.
So you run kubectl port-forward in one terminal. You run docker commands in another. You hardcode a staging URL in your .env for the third. Fifteen minutes later you’re finally coding. Tomorrow you do it all again. Next week one of the connections dies silently, and you spend ten minutes figuring out which one.
That’s why I built tunl. You write a single config file listing where your services are, and one command opens all the tunnels you need. Your app connects to localhost:5432 to reach Postgres, and it doesn’t matter if Postgres is in a pod, a container, or on a server somewhere else.
[services.postgres]
local_port = 15432
target = "remote://127.0.0.1:5432"
[services.cache]
local_port = 9000
target = "docker://redis:6379"
[services.api]
local_port = 8080
target = "kubectl://default/api-0:8080"Just run tunl --config config.toml and all three ports are ready to go. In this post, I’ll explain how it works and the decisions behind it.
One trait holds the whole thing together
The three target types look nothing alike under the hood. A remote host is a TCP connection. A Docker container needs the Docker socket. A Kubernetes pod needs the API server and a connection upgrade. If I let those differences leak into the rest of the code, every part of the program would need to know about all three.
I gave them one shape instead:
#[async_trait]
pub trait Target: Send + Sync + std::fmt::Debug {
async fn connect(&self) -> anyhow::Result<Box<dyn AsyncReadWrite>>;
fn describe(&self) -> String;
}A target is anything that can return a read-write stream when needed. That’s the whole idea. connect opens the actual connection, whatever that means for the target, and gives you something you can read bytes from and write bytes to. describe provides a label for logging.
This approach pays off throughout the code. The part of the program that handles client connections and moves data doesn’t care about the target type. It just holds a Box<dyn Target>, calls connect, and uses the stream it gets. When I added Docker Target and later Kubernetes Target, I didn’t have to change that code. Each target lives in its own file and only knows about itself.
That AsyncReadWrite in the return type is a small piece of glue:
pub trait AsyncReadWrite: AsyncRead + AsyncWrite + Send + Unpin {}
impl<T: AsyncRead + AsyncWrite + Send + Unpin> AsyncReadWrite for T {}impl<T: AsyncRead + AsyncWrite + Send + Unpin> AsyncReadWrite for T {}It says any type that can be read, written, sent between threads, and moved is a valid stream. A TCP socket qualifies. So does an in-memory pipe. The second line means I never write impl AsyncReadWrite by hand. Any type that fits gets it for free.
Moving bytes is one function
Once a target gives back a stream, the job is to copy bytes between the client and the stream in both directions until one side disconnects. Tokio has a function for this:
pub async fn run(
mut local: impl AsyncReadWrite,
mut target: impl AsyncReadWrite,
) -> io::Result<()> {
tokio::io::copy_bidirectional(&mut local, &mut target).await?;
Ok(())
}copy_bidirectional runs both copy loops in one future. When one side sends EOF, it closes the write half of the other side and drains what is left before returning. That is the right behavior for TCP, where one side can stop sending while still receiving. I could have written the two loops by hand. Reaching for the function that already handles the half-close edge cases was the better call.
Three targets, three surprises
At first, I thought each target would just be a simple TCP connection. I quickly learned that wasn’t the case.
Remote was the easy one and matched my mental model:
let stream = TcpStream::connect(&self.address).await?;
Ok(Box::new(stream))Docker broke my model on the first test. The obvious approach is to ask Docker for the container’s IP and connect to it. That works on Linux. On macOS the container network lives inside a hidden virtual machine, and those IP addresses do not exist on the host. There is no route to them.
The fix was to stop trying to reach into the container’s network from outside. Instead, tunl runs nc inside the container and streams its input and output over the Docker socket. The bytes ride the same socket Docker uses for docker exec, which works on every platform. The cost is a requirement: the container image has to ship a nc binary. Minimal images like distroless and scratch have nothing to run, so they are out. I decided cross-platform support was worth that line in the docs.
Kubernetes had the most ways to fail and the least code. The kube crate exposes the same port-forward the kubectl command uses, and it returns a stream that already fits AsyncReadWrite. No adapter needed:
let mut forwarder = pods.portforward(&self.pod, &[self.port]).await?;
let stream = forwarder.take_stream(self.port)?;
Ok(Box::new(stream))The work here was not the happy path. It was turning every failure into something a person can act on. A missing pod, an RBAC denial, an unreachable API server, and a pod that is still starting all look like the same opaque error if you let them. So I read the API server’s status code and wrote a real message for each one, with the command to run next:
pod default/api-0 not found. check: kubectl -n default get pods
forbidden to port-forward default/api-0. your kubeconfig user needs the pods/portforward permissionWhen a tunnel breaks in the middle of the day, that message is all you see. A generic “Connection refused” makes you hunt for the problem. A message like “The pod is not running, here is how to check” saves you time.
Reconnection without thinking about it
The reason I built this was that hand-rolled tunnels die and stay dead. So tunl reconnects on its own. When a target is down, it retries with a backoff that grows from one second up to fifteen, then connects the moment the target is back.
One tricky part was deciding what “reconnect” should mean. It covers setting up a new connection, but it doesn’t try to fix a live connection that drops in the middle. If a pod restarts while you’re running a query, that connection ends. The next request opens a new tunnel once the pod is ready. Trying to patch up a broken connection could send bad data to the client, so reconnecting only happens at setup.
There was one detail I missed at first: if a target is down and the client is waiting, the client might give up and disconnect. Early versions of tunl kept retrying even after the client was gone. The fix was to watch the client connection during backoff and stop as soon as it closes. It’s a small change, but it means the retry loop only runs when it should.
Stopping cleanly
If you press Ctrl+C, the simple approach is to stop everything right away. But if you do that just after sending a request, that request might be left unfinished.
tunl gives open connections a moment to finish. When it gets a signal, every tunnel stops accepting new connections, and active transfers have up to five seconds to complete before they’re closed. That window is limited on purpose. Graceful shutdown doesn’t mean waiting forever for one stuck connection. Five seconds is enough for most cases; then it moves on.
Why the config is TOML
I went back and forth between TOML and YAML. YAML is everywhere in this space, since Kubernetes and Docker Compose both use it. That was the argument for it, and also the argument against it. YAML has sharp edges. Indentation matters, and the same value can be read as a string or a boolean depending on quoting. For a config a person edits by hand at the start of the day, those edges are a tax.
TOML is simple and clear. Each service is just a block with two keys. There’s no nesting to mess up:
[services.postgres]
local_port = 15432
target = "remote://127.0.0.1:5432"It’s also the format Rust projects use for Cargo.toml, so anyone working on this codebase has seen it before. I chose the format that’s hard to mess up, even if it’s less popular.
Small decisions that paid off
A few decisions didn’t change what the tool does, but made it much easier to use.
tunl binds all local ports before starting any tunnels. If a port is already in use, it tells you which one and exits. You never end up with three services working and the fourth failing silently.
The main logic is in a library, and the binary is just a thin layer on top. This setup lets me properly test reconnection and shutdown. I can create a fake target that fails a certain number of times, then check that the retry loop works, all in memory without needing a network or cluster.
Logs are easy to read by default, and you can switch to JSON with a --json flag. Each connection log includes the service name and client address, so if two clients use the same service, you can still tell their connections apart.
What it does not do
I kept the first version focused on purpose. Local ports only bind to IPv4 loopback. Kubernetes targets use a fixed pod name, not a label or service, so if a Deployment changes the pod name, it’s not followed. Docker targets need the nc binary. I wrote down each of these limits because a tool that’s honest about its edges is easier to trust than one that hides them.
Closing
The whole design comes down to one trait: a target is something that returns a stream. Once that was decided, the proxy, reconnection, and shutdown logic were written once and worked for all three target types without needing to know which was which. Adding a fourth target would just mean one new file and one new line in the parser.
If you’re tired of juggling terminal tabs that keep dying, the code is on GitHub. One config, one command, and every port is forwarded.