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 --helpThe 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-browserThen reuse tunnelctl-config on every up. The relevant paths in the image are:
| Path (in container) | Holds |
|---|---|
/home/nonroot/.config/tunnelctl | OIDC tokens + per-tunnel metadata |
/home/nonroot/.local/state/tunnelctl | runtime 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 8080myapp.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:8080The 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:8080tunnelctl resolves web over appnet and forwards myapp.tunnelctl.eu → web: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 (nosh -c). Debug a running container withpodman 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:latestfor reproducible deployments. - Long-running tunnels — wrap the foreground
upin a managed container (podman run -d, a Quadlet.containerunit, or compose) rather than the-ddaemon flag.