tnl publish attaches an already-running loopback HTTP service to a route. Use it when tnl should
not start or supervise the local process; use tnl dev for the initialized
project workflow.
Pass a port when the local service listens on loopback:
+--publish port 3000----------------------------------------------------------------------------------------------------------------------------------------------------------------+
local targets: Accepted target forms and the loopback HTTP origin they select.
input
target
3000
http://127.0.0.1:3000
localhost:3000
http://127.0.0.1:3000
http://127.0.0.1:3000
http://127.0.0.1:3000
http://[::1]:3000
http://[::1]:3000
tnl checks the target before creating a route session. Start the local service first and keep the
publisher running while the URL is needed.
Pass a configured service name instead of a target to apply that service's target, team, tunnel, and
root project defaults:
+--publish a configured service----------------------------------------------------------------------------------------------------------------------------------------------------------------+
With one configured service, omitting the argument selects it. With multiple configured services,
tnl reports TNL_SERVICE_AMBIGUOUS with sorted names instead of guessing.
The route belongs to the selected team. A named service team overrides the root project team,
which overrides the team saved by tnl team use. Check the effective choice before publishing an
important hostname:
+--show the selected team----------------------------------------------------------------------------------------------------------------------------------------------------------------+
tnl team current shows the stored fallback. Run tnl config check to validate a root or named
service team override before the tunnel starts.
With no hostname option, tnl derives a hostname from the current Git worktree and named service,
then places it in your member namespace on the team's default domain. Outside Git, it uses the
project directory. Repeating the command from the same worktree and client state selects the same
durable route; another installation derives another hostname.
Choose a stable child label with --subdomain, or an exact authorized hostname with --host:
+--publish an explicit member route----------------------------------------------------------------------------------------------------------------------------------------------------------------+
+--publish an exact hostname----------------------------------------------------------------------------------------------------------------------------------------------------------------+
+--[ hostname selection ]---------------------------------------+| input | result |+-----------------------+---------------------------------------+| --host <hostname> | exact authorized hostname || --subdomain api | api beneath the member namespace || service configuration | configured host or subdomain || no explicit value | worktree and service derived hostname |+-----------------------+---------------------------------------+
hostname selection: Explicit command values take precedence over environment, project configuration, and the derived default.
input
result
--host <hostname>
exact authorized hostname
--subdomain api
api beneath the member namespace
service configuration
configured host or subdomain
no explicit value
worktree and service derived hostname
--host and --subdomain are mutually exclusive. Explicit hostnames still obey team, domain,
membership namespace, and route-scope authorization. Admins and owners can manage shared routes but
cannot publish inside another member's namespace.
To accept visitors from every address, use --public:
+--publish a public route----------------------------------------------------------------------------------------------------------------------------------------------------------------+
--public and --allow-ip are mutually exclusive. Public routes require application-level
authentication when the service is not intended for everyone.
Ingress denies disallowed connections before forwarding them. The publisher emits bounded,
aggregate denied-visitor warnings with counts over an interval; it does not log one line or source
address per denied connection. A warning usually means a visitor is outside the current-IP policy,
not that route TLS or the target failed.
tnl treats the selected hostname, target, and IP policy as desired state. Before opening a route
session, it:
creates the route if it does not exist,
reuses it when the mutable settings already match,
updates an owned route when the target or IP policy changed, or
fails without taking over when ownership or immutable identity does not match.
Reconciliation means a config change does not require deleting the route just to change its target
or visitor policy. The update remains authorized against the route's team and route owner. A
hostname ownership, immutable identity, lifecycle, or concurrent reconciliation conflict reports
TNL_ROUTE_CONFLICT without taking over or deleting the route.
Only one active route session can publish a route. A new continuous run receives a new route
version; changing mutable desired state does not transfer route ownership.
Routes are persistent by default. Pressing Ctrl+C ends the route session and publisher
connections, but the durable route remains for the next reconciliation:
Use --ephemeral for a disposable preview whose route should be deleted after clean shutdown:
+--publish an ephemeral route----------------------------------------------------------------------------------------------------------------------------------------------------------------+
Ephemeral affects the durable route, not just the local process. Do not use it for a hostname whose
ownership or certificate reuse should survive between runs. If cleanup is interrupted, inspect the
route and delete it explicitly when appropriate:
+--delete a remaining route----------------------------------------------------------------------------------------------------------------------------------------------------------------+
Add --open to open the URL after the route becomes ready:
+--publish and open the route----------------------------------------------------------------------------------------------------------------------------------------------------------------+
The publisher reports deterministic lifecycle states such as starting, provisioning, ready,
and draining. Retryable certificate or relay work remains in provisioning rather than resetting
an estimated progress indicator. After two minutes, one TNL_PROVISIONING_STALLED warning is
emitted for that route version while retries continue. Readiness, a replacement route version, or
tunnel shutdown cancels the pending warning.
Each publisher connection starts with QUIC and starts TLS/TCP if QUIC fails or has not completed its
authenticated tunnel handshake after 250 milliseconds. The first authenticated transport wins; a
terminal authentication or stale-assignment rejection stops both attempts. If TLS/TCP wins, tnl
emits one transport fallback warning for the route version and continues normally. The warning
means QUIC did not establish first, not necessarily that QUIC is unavailable.
Status is scoped to the current worktree project and includes the service, hostname, target, route
version, and current lifecycle state when available. Use tnl status --all to include every local
tunnel and its worktree project root. Known failures include a stable diagnostic code and contextual
help URL for the failed boundary.
Automation can consume newline-delimited lifecycle events:
Human lifecycle output goes to stderr; finite status and machine-readable results go to stdout.
In NDJSON, provisioning stalls are structured non-error warning events with code, help_url,
route_version, and retryable: true. Transport fallback is a warning event with
transport: "tls-tcp", route_version, and retryable: false. tnl dev preserves child output and
therefore has no NDJSON lifecycle mode.