Open all your service tunnels with one command.
tunl reads a config file that lists the services you depend on and forwards a local port to each one. Your app connects to localhost:5432 and reaches Postgres, whether that Postgres runs in a Kubernetes pod, a Docker container, or on a server somewhere else. One process, one config, every port forwarded.
Working on one service often means reaching several others. Some live in a Kubernetes cluster, some in local containers, some on a remote host. The usual answer is a handful of terminal tabs running kubectl port-forward and docker and ssh -L, each with its own flags, each dying on its own when a pod restarts or a connection drops.
tunl puts all of that in one file. Start it once and every port is up. If a target goes away, tunl reconnects when it comes back instead of leaving you with a dead tunnel.
You need a Rust toolchain. Build from source:
git clone https://github.com/am-miracle/tunl
cd tunl
cargo install --path .That puts a tunl binary on your PATH. You can also run cargo build --release and use target/release/tunl directly.
Create a config.toml. Each block defines one tunnel:
[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
bind_address = "::1"
target = "kubectl://default/api-0:8080"
[services.internal_db]
local_port = 25432
target = "ssh://deploy@bastion.example.com/db.internal:5432"local_portis the port on your machine thattunllistens on.bind_addressis optional and defaults to127.0.0.1. Use::1for IPv6 loopback.targetis where that traffic goes, written as a URI.
0.0.0.0, ::, and other non-loopback addresses can expose a tunnel to other machines. Tunl rejects these addresses unless the service acknowledges the exposure:
[services.shared_api]
local_port = 8080
bind_address = "::"
allow_remote_connections = true
target = "remote://api.internal:8080":: creates one dual-stack listener that accepts IPv4 and IPv6 connections. The opt-in does not authenticate incoming clients. Use firewall rules to restrict access.
Four target types are supported:
| Scheme | Format | Forwards to |
|---|---|---|
remote |
remote://host:port |
a TCP host and port |
docker |
docker://container:port |
a port inside a running container |
kubectl |
kubectl://namespace/pod:port |
a port on a named pod |
kubectl |
kubectl://namespace/label=value:port |
a port on a pod matched by label |
ssh |
ssh://user@bastion[:port]/host:port[?identity=fingerprint] |
a host reachable through an SSH bastion |
The kubectl target takes either an explicit pod name or a label selector. A selector is anything with an = in it, such as app=api or app=api,tier=web. Use a selector when the pod name is not stable, which is the case for Deployments.
tunl --config config.tomltunl binds every local port up front. If any port is taken, it reports which one and exits without starting a partial set. Once it is running, point your clients at the local ports:
psql -h localhost -p 15432 # reaches the remote Postgres
redis-cli -p 9000 # reaches the container
curl localhost:8080 # reaches the podPress Ctrl+C to stop. tunl lets active connections finish, then exits.
Add --json for structured logs you can pipe into jq or a log collector:
tunl --config config.toml --jsontunl watches the config file and picks up changes without a restart. Add a service block and save, and its tunnel comes up on its own. Remove one and its port is freed. Change a service's target or port and only that service restarts, everything else keeps its connections.
If a save leaves the file broken (invalid TOML, a bad target URI, a duplicate port), tunl logs why and leaves every running service exactly as it was. Nothing gets torn down over a typo.
remote opens a plain TCP connection to the host and port. Use it for anything reachable over the network, including a bastion or an SSH tunnel you already have open.
docker runs nc inside the container and streams its input and output over the Docker socket. This reaches the container's port without publishing it and works the same on macOS and Linux. The container image needs a nc (netcat) binary, so minimal images like distroless and scratch will not work.
kubectl uses the Kubernetes API server's port-forward, the same path kubectl port-forward takes. It reads your current kubeconfig context.
You can target a pod two ways. An explicit pod name (kubectl://default/api-0:8080) forwards to that exact pod. If it is recreated under a new name, as a Deployment does on rollout, tunl keeps trying the configured name and logs that it cannot find it. A label selector (kubectl://default/app=api:8080) resolves to a matching pod on every new connection and picks the first one that is ready, so it follows the current pod behind a Deployment. Use an explicit name for StatefulSet pods, which keep stable names like api-0, and a selector for Deployments.
ssh connects to a bastion and opens a direct TCP channel to the destination. Connections for the same service share one authenticated SSH transport, which is recreated if it closes. The bastion port defaults to 22. Each host key must already be trusted in ~/.ssh/known_hosts. Authentication tries identities from ssh-agent, then unencrypted ~/.ssh/id_ed25519, id_ecdsa, and id_rsa files. Add encrypted keys to ssh-agent; passwords are not accepted in target URIs.
If your agent contains several keys, append ?identity=<fingerprint> to select one public key. Use ssh-add -l -E sha256 to list fingerprints. When a fingerprint is configured, tunl offers only that agent identity or a matching default identity file to the bastion.
When a target is down, tunl retries with a backoff that grows from one second up to fifteen, then connects as soon as the target is back. The retry covers connection setup. An open connection that drops is closed, and the next client connection sets up a fresh tunnel. Restart a pod or a container and the next request goes through once it is ready.
- Non-loopback listeners do not authenticate incoming clients.
- Docker targets need
ncin the container image. - A label selector picks the first ready pod, so it does not spread connections across replicas.
- SSH targets do not read aliases, identity paths, or proxy rules from
~/.ssh/config.
tunl is available under the MIT License. See LICENSE for the full text.
