tunnelctl
Concepts

Run as a systemd service

Supervise the native tunnelctl binary as a systemd .service unit — no container.

If tunnelctl is installed natively (from a package — see Quickstart), you can run a tunnel directly as a systemd service unit (.service), with no container involved. As with Quadlet, the trick is to run tunnelctl up in the foreground and let systemd supervise it — never -d. And because it runs on the host, there's no networking to set up: localhost:8080 is simply the host's service.

User service vs. system service

Run it as a user service (systemctl --user) so it reuses the token from your own tunnelctl login. A system service runs as root or a dedicated account, which must have logged in itself — see the last section.

Paths below assume the binary is at /usr/local/bin/tunnelctl (where the packages install it); check yours with command -v tunnelctl.

1. Log in once

tunnelctl login            # or: tunnelctl login --no-browser

The token lands in ~/.config/tunnelctl and is refreshed automatically; the service reuses it.

2. A single-tunnel unit

Save as ~/.config/systemd/user/tunnelctl-myapp.service:

[Unit]
Description=tunnelctl tunnel — myapp
Wants=network-online.target
After=network-online.target

[Service]
ExecStart=/usr/local/bin/tunnelctl up myapp 8080
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

Enable and start:

systemctl --user daemon-reload
systemctl --user enable --now tunnelctl-myapp.service
loginctl enable-linger "$USER"     # start on boot without an active login session

(Unlike a generated Quadlet unit, a hand-written unit can be systemctl enabled directly.)

3. Many tunnels: a template unit

One file for any slug — a template unit tunnelctl@.service, instantiated as tunnelctl@<slug>.service. %i is the instance name (the slug):

[Unit]
Description=tunnelctl tunnel — %i
Wants=network-online.target
After=network-online.target

[Service]
ExecStart=/usr/local/bin/tunnelctl up %i
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

tunnelctl up %i reuses the target you saved the first time you ran tunnelctl up <slug> <target>. Start one instance per slug:

tunnelctl up myapp 8080                          # once, to save the target
systemctl --user enable --now tunnelctl@myapp.service
systemctl --user enable --now tunnelctl@api.service

Prefer to keep the target explicit (no reliance on saved state)? Use a per-instance EnvironmentFile:

# in [Service]
EnvironmentFile=%h/.config/tunnelctl/units/%i.env
ExecStart=/usr/local/bin/tunnelctl up %i ${TARGET}
# %h/.config/tunnelctl/units/myapp.env
TARGET=8080

Operate

systemctl --user status tunnelctl@myapp.service
journalctl --user -u tunnelctl@myapp.service -f   # live logs
systemctl --user restart tunnelctl@myapp.service
systemctl --user stop tunnelctl@myapp.service

System-wide service

To run without any user session, install to /etc/systemd/system/ and run as a dedicated account that has logged in once:

[Service]
User=tunnelctl
ExecStart=/usr/local/bin/tunnelctl up myapp 8080
Restart=on-failure
RestartSec=5
# Optional hardening — keep the config/state dir readable/writable:
NoNewPrivileges=true
ProtectSystem=strict
sudo -u tunnelctl tunnelctl login --no-browser    # once
sudo systemctl enable --now tunnelctl-myapp.service

Non-default environment

To point a unit at another environment, set the profile vars, e.g. Environment=TUNNELCTL_API_URL=… TUNNELCTL_OIDC_ISSUER=…. See env.

Token lifetime

A running tunnel stays up via the server-issued connection token, which doesn't expire — the stored OIDC token is only needed when the unit (re)starts. If your identity provider expires the refresh token, log in again (step 1).

On this page