tunnelctl
Concepts

Running in a container

Run tunnelctl from a rootless Podman/Docker container — reaching a service on the host or in a sibling container.

The CLI is published as a distroless multi-arch image, oci.piblade.net/tunnelctl/tunnelctl-cli. The entrypoint is tunnelctl and it runs as a non-root user (uid 65532), so CLI arguments go straight through:

podman run --rm oci.piblade.net/tunnelctl/tunnelctl-cli:latest --help

The one thing to get right is reachability: the tunnel forwards to a target that is resolved from inside the container, so the container must be able to reach your service. The two common setups are below. Examples use rootless podman; for docker swap the command name (and see the host.docker.internal note).

Run in the foreground, not -d

Inside a container, run tunnelctl up <slug> <target> in the foreground — the container itself is the supervisor and its lifecycle is the tunnel's. Don't use the -d daemon flag in a container; manage the container instead (e.g. podman run -d, a Quadlet/systemd unit, or compose).

Authentication & state

tunnelctl needs a token, and stores per-tunnel metadata, under ~/.config/tunnelctl. Persist that across container runs with a volume, and log in once using the device-code flow (no browser in the container):

# one-time login — writes tokens to the persisted config volume
podman run --rm -it \
  -v tunnelctl-config:/home/nonroot/.config/tunnelctl \
  oci.piblade.net/tunnelctl/tunnelctl-cli:latest \
  login --no-browser

Then reuse tunnelctl-config on every up. The relevant paths in the image are:

Path (in container)Holds
/home/nonroot/.config/tunnelctlOIDC tokens + per-tunnel metadata
/home/nonroot/.local/state/tunnelctlruntime state (logs, locks) — optional for foreground use

Scenario A — expose a service running on the host

A1. Host network namespace (simplest)

Share the host's network, so localhost inside the container is the host:

podman run --rm \
  --network=host \
  -v tunnelctl-config:/home/nonroot/.config/tunnelctl \
  oci.piblade.net/tunnelctl/tunnelctl-cli:latest \
  up myapp 8080

myapp.tunnelctl.eu now forwards to the host's 127.0.0.1:8080. With rootless --network=host the container reaches any host-local port, including services bound only to loopback.

A2. host.containers.internal (no host networking)

Keep the container on its own network and address the host explicitly. Podman injects a host.containers.internal name that resolves to the host gateway:

podman run --rm \
  -v tunnelctl-config:/home/nonroot/.config/tunnelctl \
  oci.piblade.net/tunnelctl/tunnelctl-cli:latest \
  up myapp host.containers.internal:8080

The host service must listen on an interface the container can reach — bind it to 0.0.0.0:8080 (not only 127.0.0.1). On Docker use host.docker.internal instead (and add --add-host=host.docker.internal:host-gateway on Linux).

Scenario B — expose a service in a sibling container

Put tunnelctl and the service on the same user-defined network and address the service by its container name:

# 1. create a shared network
podman network create appnet

# 2. run your service on it, with a name (listens on :8080)
podman run -d --name web --network appnet myorg/web:latest

# 3. run tunnelctl on the same network; target the service by name
podman run --rm \
  --network appnet \
  -v tunnelctl-config:/home/nonroot/.config/tunnelctl \
  oci.piblade.net/tunnelctl/tunnelctl-cli:latest \
  up myapp web:8080

tunnelctl resolves web over appnet and forwards myapp.tunnelctl.euweb:8080. The same idea applies to a podman pod or a compose project: share the network and use the service name as the target host.

Slug vs. target

Remember up <slug> <target> takes two different things: myapp is the public slug (myapp.tunnelctl.eu), while web:8080 is the target the traffic is forwarded to. They're independent — name them however is clearest.

Rootless notes

  • Distroless image — there's no shell; the entrypoint is tunnelctl, so pass CLI args directly (no sh -c). Debug a running container with podman debug/kubectl debug.
  • Volume ownership — mounted paths must be writable by uid 65532. Named volumes handle this automatically; for bind mounts use :U (e.g. -v ./cfg:/home/nonroot/.config/tunnelctl:U).
  • Pin a version — prefer an explicit tag (:0.8.4) over :latest for reproducible deployments.
  • Long-running tunnels — wrap the foreground up in a managed container (podman run -d, a Quadlet .container unit, or compose) rather than the -d daemon flag.

On this page