Self-hosted service
omrikiei/ktunnel avatar
omrikiei/ktunnel

ktunnel: exposing a laptop to a Kubernetes cluster over a reverse tunnel

A cli that exposes your local resources to kubernetes

1,057 stars76 forksGoGPL-3.0

At a glance

What is it?
ktunnel is a Go CLI that opens a reverse tunnel between your machine and a Kubernetes cluster, so a pod can reach a local web app or a local database port through the cluster. It injects sidecar containers into Deployments and StatefulSets, and it prints its plan before it writes anything.
Who is it for?
Adopt ktunnel if you develop against a local service while your application runs in a cluster, and you want the tunnel and the Kubernetes objects it creates to be described by one command whose plan you can read first. Do not adopt it as a production ingress path, since it is a development tool that needs the cluster to accept injected pods and a local process to stay alive.
Can I use it commercially?
Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
Is it still maintained?
Yes. The repository last received commits 51 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem it was born from, and the two directions it tunnels

ktunnel is a CLI tool that establishes a reverse tunnel between a Kubernetes cluster and your local machine. The repository description says it exposes your local resources to Kubernetes, and the README's subtitle says the same thing.

The origin story in the README is specific and worth repeating because it defines the scope. Ktunnel was born out of the need to access the author's development host when running applications on Kubernetes. The aim is to be a holistic solution to that specific problem, accessing the local machine from a Kubernetes pod. And the author says plainly that if the tool is useful in other scenarios, they would like to hear about it.

There are two directions of use, and the README separates them clearly. You can expose your machine as a service in the cluster, or expose it to a specific deployment. And the client and server can be used without the orchestration part, which is the important escape hatch: although ktunnel is identified with Kubernetes, it can also be used as a reverse tunnel on any other remote system. The Kubernetes integration is the packaging, the tunnel is the product.

The audience is a developer who runs their application in a cluster but develops the service locally. The classic case is a web app on port 8000 on the laptop, and a pod that needs to talk to it. Without a tunnel, you either deploy to the cluster on every change, or you run the app locally and struggle with the pod's network reaching back to your machine, which fails because the pod is behind NAT and your laptop is not addressable from the cluster.

The licence is GPL-3.0, the primary language is Go, and the release history is active, with v2.5.0 on 2026-09-01 following v2.4.1 and v2.4.0 at the end of August 2026. The last push was on 2026-09-02, and the repository is not archived.

expose, inject, and the per-replica problem

The two commands do different things and the distinction matters.

expose makes your machine reachable as a service in the cluster:

bash
ktunnel expose myapp 80:8000
ktunnel expose myapp 80:8000 -r # use an existing deployment & service as they are, or create them

The mapping is cluster port to local port, and the effect is that pods in the cluster can access your local web app, so a Kubernetes application can send requests to myapp:80.

inject is the opposite direction of reach. It opens a listening port inside the deployment's own pods, tunnelled to your local machine, so the application reaches your laptop at localhost:3306:

bash
ktunnel inject deployment mydeployment 3306

The inject case has a subtlety that the README explains at length, and it is the kind of thing that would otherwise be discovered the hard way. The sidecar's listeners are pod-local: only containers in an injected pod reach your machine through them. So every replica is injected and every replica is tunnelled, because forwarding to one arbitrary pod of several would leave the rest with the port closed and nothing to say which pod was the working one. A deployment with three replicas takes three local ports, counting up from --port, and opens three streams to your machine.

For a StatefulSet the reasoning is different but the outcome is the same. Every ordinal is injected and every ordinal is tunnelled, with local ports handed out in ordinal order: with --port 28688, owl-app-0 is on 28688 and owl-app-1 on 28689, and it stays that way across reconnects. The README gives the reason, and it is a good one, a StatefulSet's pods are identities rather than interchangeable replicas, so the port a pod is reached on has to keep meaning that pod. The example given is a PHP app with xdebug, which is where the feature was asked for.

One more StatefulSet detail: a StatefulSet whose updateStrategy is OnDelete does not restart its pods when its template changes, so ktunnel says so before it patches anything, and then waits for you to delete them.

And a detail that catches people out with Deployments: replicas added while the tunnel is up are picked up the next time it is rebuilt, since the set of pods is resolved once per connection attempt.

The plan is printed before anything is written

The most unusual thing about ktunnel's design is that it tells you what it is about to do before it does it, and the README shows the format.

code
In namespace team-a, ktunnel will:
  use the existing deployment team-a/myapp as it is (2 replica(s), image nexus.corp.example/ktunnel:v2.1.0); it will be neither modified nor deleted
  create service team-a/myapp (ClusterIP, port(s) 80->8080)
On exit it will remove service myapp, and leave deployment myapp as it was.

Both commands print their plan before the first write, and say what happens when you stop them. The reason to care about this is that the tool writes into a cluster you may share, and that it mutates Deployments by adding a sidecar. A tool that reports its intent in that form, with the namespace, the replica count, the image, the port mapping, and the cleanup behaviour, is a tool you can run in front of a colleague.

The reuse, force and create decisions are also spelled out. With --reuse, if the Deployment and Service are already there, because you wrote them yourself for a private registry or a security context your cluster admits, ktunnel uses them exactly as they stand, does not modify them, and does not delete them on exit. If they are not there, it creates them and removes what it created when you stop it. --force deletes and recreates instead.

Namespace resolution follows the same principle of not guessing. --namespace if you pass it, otherwise the namespace of your kubeconfig context, with --context selecting which context, otherwise default. It is resolved once at startup and printed with its source:

code
Using namespace team-a (kubeconfig context "dev")

And there is a mode where it does not touch the cluster at all. --print-manifests writes the Deployment and Service ktunnel would create, as YAML, and exits without contacting the cluster:

bash
ktunnel expose myapp 80:8000 --print-manifests > ktunnel.yaml
ktunnel expose myapp 80:8000 --print-manifests | kubectl apply -f -

The README explains the value of that: the manifests come from the same code the command runs, so they cannot drift from what ktunnel expose actually creates, and everything else goes to stderr so the pipe stays clean. Checking the manifests into a repo, adapting them for a private registry, or applying them yourself are all workflows this mode supports, and the no-drift property is what makes the checked-in copy trustworthy.

Installation: three distributions, and a static Go binary

There are three ways to get ktunnel, and the table in the README is the whole installation story.

Pre-built binaries for macOS, Linux and Windows are on the GitHub releases page. Homebrew covers macOS and Linux with `brew tap omrikiei/ktunnel && brew install omrikiei/ktunnel/ktunnel`. And for Kubernetes users specifically there is Krew, the kubectl plugin manager, with `kubectl krew install tunnel`.

Building from source is three steps. Clone the repository, build the binary, and move it onto your path:

bash
git clone https://github.com/omrikiei/ktunnel; cd ktunnel
CGO_ENABLED=0 go build -ldflags="-s -w"
sudo mv ./ktunnel /usr/local/bin/ktunnel

Then `ktunnel -h` to check it works. CGO_ENABLED=0 and the -s -w ldflags produce a statically linked, stripped binary, which is the right choice for a tool that has to run in a container and on a developer machine with a different libc.

The repository layout explains the rest of the project. There is cmd/ for the command tree, pkg/ for the library code, api/ for the protobuf definitions, with buf.gen.yaml for the protobuf generation and a .krew.yaml plus a krew/ directory for the kubectl plugin packaging. main.go and ktunnel.rb are the entry points, the latter being the Homebrew formula. There is a Dockerfile, a Makefile, a .goreleaser.yml for the release builds, build_releases.sh and compress_binaries.sh, a CHANGELOG.md, a RELEASING.md and a ROADMAP.md.

The documentation is MkDocs-based, with mkdocs.yml, readthedocs.yml, a docs/ directory and a docs/security.md that the table of contents links as the security model, plus an examples/ directory containing a pycharm-remote-debugging-on-k8s example, which is a concrete instance of the development-host problem the project was built for.

The Go module requires go 1.27.0, and the dependency list shows the shape of the tool: cobra for the command line, grpc and protobuf for the tunnel protocol, client-go and the k8s api and apimachinery packages for the cluster side, logrus for logging, sigs.k8s.io/yaml for manifest rendering and golang.org/x/net for the networking.

The sidecar image, and why it declares USER 1000

The Dockerfile in the repository is short and the comment in it explains the most interesting decision in the project.

The build stage is golang:1.27-alpine with upx installed, and the binary is built with CGO_ENABLED=0, GOOS=linux, -ldflags="-s -w" and then compressed with upx. The runtime stage is FROM scratch, so the final image contains the binary and nothing else, with USER 1000, EXPOSE 28688 and ENTRYPOINT ["/ktunnel/ktunnel"] with CMD ["server"].

The comment explains why the non-root guarantee is on the image rather than in the pod spec. It used to be RunAsUser: 1000 on the container ktunnel creates, which OpenShift rejects: its SCCs assign a UID from a per-namespace range and refuse a pod that demands its own, so ktunnel expose did not work on OpenShift at all. Declared on the image instead, a vanilla cluster runs as 1000 exactly as before and OpenShift overrides it, which is what it wants to do. And the UID is numeric because FROM scratch has no /etc/passwd for a name to resolve against.

That is a genuinely instructive piece of engineering. The failure mode was not an error message, it was a tool that simply did not work on OpenShift at all, and the fix was to move the constraint from the thing that creates the pod to the thing the pod runs, so that a platform with its own opinion about UIDs can impose it.

The exposed port 28688 is the same number that appears in the StatefulSet port allocation example, where --port 28688 gives owl-app-0 that port and owl-app-1 28689, so the sidecar's default listening port and the local port allocation base are the same value.

The Makefile shows how the project tests itself, and the reasoning is a strong argument for integration testing over mocks. The integration tests need a real kubelet, because they assert that the pod starts, that it can read the credentials ktunnel generated for it, and that traffic actually crosses the tunnel. A fake client can see none of that, which is how v2.4.0 shipped a crash loop past a green suite. The target creates a kind cluster, loads the built image into it, and runs the integration tests with a 20 minute timeout, and there is a matching clean target that deletes the cluster.

Editorial conclusion

Adopt ktunnel if you develop against a local service while your application runs in a cluster, and you want the tunnel and the Kubernetes objects it creates to be described by one command whose plan you can read first. Do not adopt it as a production ingress path, since it is a development tool that needs the cluster to accept injected pods and a local process to stay alive. Verify first that your cluster will admit the sidecar it injects, especially on OpenShift where the security context constrains the user, and that the port range it takes on your machine does not collide with anything else you run.

Frequently asked questions

What does ktunnel actually do in a Kubernetes cluster?

It opens a reverse tunnel between your local machine and the cluster, so pods can reach a local web app or a local database port. The expose command makes your machine reachable as a cluster service, and the inject command opens a listening port inside the pods of a Deployment or StatefulSet that forwards to your machine.

How do I install ktunnel?

Pre-built binaries for macOS, Linux and Windows are on the GitHub releases page, Homebrew covers macOS and Linux with `brew tap omrikiei/ktunnel && brew install omrikiei/ktunnel/ktunnel`, and Kubernetes users can use Krew with `kubectl krew install tunnel`. From source, clone the repository and run `CGO_ENABLED=0 go build -ldflags="-s -w"`.

Why does ktunnel inject every replica of a Deployment?

The README explains that the sidecar's listeners are pod-local, so only containers in an injected pod reach your machine. Forwarding to one arbitrary pod of several would leave the rest with the port closed, so every replica is injected and every replica is tunnelled, taking three local ports for three replicas.

Can I see what ktunnel will do before it touches the cluster?

Yes. Both commands print their plan before the first write, including the namespace, the objects they will create or reuse and the cleanup on exit, and `--print-manifests` writes the Deployment and Service as YAML and exits without contacting the cluster, with everything else going to stderr so the pipe stays clean.

Official sources

  1. Issues
  2. License: GPL-3.0
  3. omrikiei/ktunnel on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/omrikiei-ktunnel.svg)](https://hysenlabs.com/projects/omrikiei-ktunnel)

Was this useful?