tnl.dev :: docs
run your own server.
Run the complete tnl server yourself.
prepare the server
Start with one tnld process and an external PostgreSQL database. The standalone role runs control, ingress, and two logical relay services in one process. Those two services fill separate connection slots, but they share a host and failure boundary; use separate relay processes on separate hosts when you need independent failures.
Provision PostgreSQL outside Compose. Give TNLD_DATABASE_DIRECT_URL a direct database connection for tnld migrate and TNLD_DATABASE_URL a pooled connection for control or standalone at runtime. Retain both credentials privately; the migrator must be able to change the schema. Run tnld migrate before updating control or standalone to an image that requires a newer schema.
Choose distinct DNS purposes: TNLD_SERVER_DOMAIN=example.com is the infrastructure suffix for control.example.com, ingress.example.com, and relay.example.com (or relay-a.example.com and relay-b.example.com in split mode). TNLD_MANAGED_DEPLOYMENT_DOMAIN=routes.example.com is where team namespaces and public URL hostnames live. Point the control hostname and relay hostname(s) at their public listeners; send ingress.example.com and public URL DNS to the ingress address. Without managed DNS automation, manually point managed-domain public URL hostnames to ingress; built-in claimed-domain creation is unavailable in that mode. With Route 53 automation, set TNLD_ROUTE53_MANAGED_ZONE_ID to the managed deployment domain's zone and TNLD_INGRESS_IPV4_ADDRESSES and/or TNLD_INGRESS_IPV6_ADDRESSES to stable ingress addresses. The server-domain zone is a separate concern: split relays need TNLD_ROUTE53_SERVER_ZONE_ID (set via TNL_SERVER_ZONE_ID in the split Compose file) for relay transport certificate DNS-01 challenges. Grant control access to the matching hosted zone and make sure its delegation is public.
Allow public 443/tcp for control, ingress, and relay TLS/TCP, and 443/udp for relay QUIC. Standalone shares one public TCP address and one UDP address; it selects control, ingress, or relay by hostname. Public 443/tcp must also carry TLS-ALPN-01 validation for automatically issued control certificates, a standalone relay using control's automatic TLS, and public URL certificate plans using TLS-ALPN-01. With managed DNS automation, public URL certificate plans can use DNS-01; split relay certificates use Route 53 DNS-01. Set TNLD_ACME_EMAIL, TNLD_ACME_DIRECTORY_URL, and TNLD_ACME_ACCEPT_TERMS=true. For a test installation, use the ACME staging directory before requesting production certificates. Keep the metrics listener private.
With Route 53 automation, control publishes a shared A/AAAA wildcard under each member namespace used for public URLs on the managed deployment domain or a claimed domain. It verifies the wildcard on authoritative nameservers before a publish run is ready. Member namespace apexes and shared public URLs keep exact address records. On claimed-domain release, control removes its owned namespace wildcards before deleting the Route 53 zone, after public URLs and certificate work have settled.
run standalone
From a checkout of tnldotdev/tnl, copy deploy/compose.yaml and deploy/.env.example to a private deployment directory:
mkdir -p "$HOME/tnl-server"
cp deploy/compose.yaml "$HOME/tnl-server/compose.yaml"
cp deploy/.env.example "$HOME/tnl-server/.env"
chmod 600 "$HOME/tnl-server/.env"Edit that private .env: replace every placeholder with your values. Set TNL_IMAGE to a verified, digest-pinned image, both PostgreSQL URLs, both domains, and an ACME contact email. Once TNL_IMAGE is set, run these commands in a private terminal; copy their outputs into TNLD_LOGIN_TOKEN and TNLD_STORAGE_KEY respectively:
docker compose --env-file "$HOME/tnl-server/.env" -f "$HOME/tnl-server/compose.yaml" run --rm --no-deps tnld login-token
openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n'The storage key is 32 random bytes encoded as unpadded base64url; keep it with your database backups, since losing it makes encrypted stored secrets unrecoverable. Do not commit .env or paste the token or key into logs. The standalone Compose file clears the example's TNLD_CLUSTER_SECRET because standalone communicates in process. If sending usage reports, set TNLD_PUBLIC_URL_USAGE_URL and TNLD_PUBLIC_URL_USAGE_TOKEN together.
From any directory, validate the Compose substitutions and start the migrator and daemon. up runs the migrate service to completion before starting tnld:
docker compose --env-file "$HOME/tnl-server/.env" -f "$HOME/tnl-server/compose.yaml" config --quiet
docker compose --env-file "$HOME/tnl-server/.env" -f "$HOME/tnl-server/compose.yaml" up -d
docker compose --env-file "$HOME/tnl-server/.env" -f "$HOME/tnl-server/compose.yaml" ps
curl --fail https://control.example.com/v1/readyReplace the example hostname with yours. /v1/ready checks the database and, when Route 53 is configured, credential retrieval; a successful request is not a visitor traffic test. Once DNS and TLS are working, connect the client and make a real request through a published local service:
tnl login https://control.example.com --token
tnl admin server status --server https://control.example.com
tnl publish 3000 --server https://control.example.com --allow-all-ipsEnter the login token at the terminal prompt. Run a local HTTP service on port 3000 before publishing, then request the public URL printed by tnl publish from another connection. --allow-all-ips is for an intentionally public test; use --allow-ip to restrict visitors. tnl publish remains running while the tunnel is active.
visitor -- public URL DNS --> tnld standalone :443/tcp
ingress --> relay a or b --> tnl publish --> local service
tnl login --> control.example.com :443/tcp --> control --> pooled PostgreSQL
tnl publish --> relay.example.com :443/tcp or udp (publisher connections)
tnld migrate -------------------------------------> direct PostgreSQLThe complete checked-in standalone Compose file follows; TNL_IMAGE must resolve to a verified digest before starting it.
name: tnl
x-tnld: &tnld
image: ${TNL_IMAGE:?set TNL_IMAGE to a verified digest-pinned image}
read_only: true
cap_add:
- NET_BIND_SERVICE
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file:
- ${TNL_ENV_FILE:-.env}
tmpfs:
- /tmp:size=16m,mode=1777
services:
migrate:
<<: *tnld
command: migrate
restart: "no"
tnld:
<<: *tnld
restart: unless-stopped
depends_on:
migrate:
condition: service_completed_successfully
stop_grace_period: 45s
environment:
TNLD_CLUSTER_SECRET: ""
TNLD_CLUSTER_SECRET_PREVIOUS: ""
TNLD_CONTROL_LISTEN: :8443
TNLD_INGRESS_LISTEN: :8443
TNLD_METRICS_LISTEN: 0.0.0.0:9090
TNLD_ROLE: standalone
TNLD_RELAY_UDP_LISTEN: :8443
ports:
- "${TNL_PUBLIC_BIND:-0.0.0.0}:443:8443/tcp"
- "${TNL_PUBLIC_BIND:-0.0.0.0}:443:8443/udp"
expose:
- "9090"split roles on one host
Use deploy/compose.split.yaml as a single-host split-role template. It runs control, ingress, and two relays as separate containers, but control:9443 and relay-a:9445 / relay-b:9445 are Compose-network addresses. TNL_CONTROL_BIND, TNL_INGRESS_BIND, TNL_RELAY_A_BIND, and TNL_RELAY_B_BIND select interfaces on that host; changing them alone does not distribute the roles. For independent failure boundaries, deploy roles on separate hosts with cross-host reachable private control and internal relay addresses, restricted 9443/9445 access, a health-aware public ingress address, and stable public relay addresses. Set TNL_CONTROL_HOSTNAME=control.example.com, TNL_SERVER_ZONE_ID to the Route 53 server-domain hosted zone, and a high-entropy TNLD_CLUSTER_SECRET in Compose's private .env. Only migrate and control receive the .env as a container env file; ingress and relays receive only the explicitly listed variables. TNLD_SERVER_DOMAIN is interpolated into the public relay addresses.
Keep 9443/tcp (private control API) reachable only from ingress and relays, and 9445/tcp (internal relay forwarding) reachable only from ingress and the corresponding relay process. Compose publishes 9443 on TNL_CONTROL_BIND; firewall it from the public Internet. Relay 9445 is only exposed to the Compose network; never publish it publicly. Control needs its pooled PostgreSQL connection; migrate alone uses the direct URL. Allow public 443/tcp on control, ingress, and relays, plus 443/udp on both relays. Route public URL DNS to ingress, not control or relays.
Copy the split YAML alongside your private .env, replace the example values, then start and inspect it with the same sequence:
cp deploy/compose.split.yaml "$HOME/tnl-server/compose.split.yaml"
docker compose --env-file "$HOME/tnl-server/.env" -f "$HOME/tnl-server/compose.split.yaml" config --quiet
docker compose --env-file "$HOME/tnl-server/.env" -f "$HOME/tnl-server/compose.split.yaml" up -d
docker compose --env-file "$HOME/tnl-server/.env" -f "$HOME/tnl-server/compose.split.yaml" ps
tnl admin relays list --server https://control.example.comvisitor -- public URL DNS --> ingress :443/tcp -- 9445/tcp --> relay-a or relay-b
| visitor stream
v
tnl publish -- relay-a/b :443/tcp or udp (publisher connection) --> local service
ingress + relays -- private 9443/tcp --> control --> pooled PostgreSQL
tnl login -------- public 443/tcp ----> control
tnld migrate -------------------------------> direct PostgreSQLThe complete checked-in split Compose file follows. The example publishes control's private port on its configured bind address; enforce network isolation before deploying.
name: tnl-split
x-tnld: &tnld
image: ${TNL_IMAGE:?set TNL_IMAGE to a verified digest-pinned image}
read_only: true
cap_add:
- NET_BIND_SERVICE
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=16m,mode=1777
services:
migrate:
<<: *tnld
command: migrate
env_file:
- ${TNL_ENV_FILE:-.env}
restart: "no"
control:
<<: *tnld
restart: unless-stopped
depends_on:
migrate:
condition: service_completed_successfully
env_file:
- ${TNL_ENV_FILE:-.env}
environment:
TNLD_CONTROL_LISTEN: :8443
TNLD_METRICS_LISTEN: 0.0.0.0:9090
TNLD_ROLE: control
TNLD_PRIVATE_CONTROL_LISTEN: :9443
TNLD_ROUTE53_SERVER_ZONE_ID: ${TNL_SERVER_ZONE_ID:?set TNL_SERVER_ZONE_ID}
ports:
- "${TNL_CONTROL_BIND:?set TNL_CONTROL_BIND}:443:8443/tcp"
- "${TNL_CONTROL_BIND}:9443:9443/tcp"
expose:
- "9090"
ingress:
<<: *tnld
restart: unless-stopped
depends_on:
- control
environment:
TNLD_CONTROL_HOSTNAME: ${TNL_CONTROL_HOSTNAME:?set TNL_CONTROL_HOSTNAME}
TNLD_PRIVATE_CONTROL_ADDRESS: control:9443
TNLD_CLUSTER_SECRET: ${TNLD_CLUSTER_SECRET:?set TNLD_CLUSTER_SECRET}
TNLD_CLUSTER_SECRET_PREVIOUS: ${TNLD_CLUSTER_SECRET_PREVIOUS:-}
TNLD_INGRESS_ID: ingress-a
TNLD_INGRESS_LISTEN: :8443
TNLD_METRICS_LISTEN: 0.0.0.0:9090
TNLD_ROLE: ingress
ports:
- "${TNL_INGRESS_BIND:?set TNL_INGRESS_BIND}:443:8443/tcp"
expose:
- "9090"
relay-a:
<<: *tnld
restart: unless-stopped
depends_on:
- control
environment:
TNLD_CONTROL_HOSTNAME: ${TNL_CONTROL_HOSTNAME:?set TNL_CONTROL_HOSTNAME}
TNLD_PRIVATE_CONTROL_ADDRESS: control:9443
TNLD_CLUSTER_SECRET: ${TNLD_CLUSTER_SECRET:?set TNLD_CLUSTER_SECRET}
TNLD_CLUSTER_SECRET_PREVIOUS: ${TNLD_CLUSTER_SECRET_PREVIOUS:-}
TNLD_INTERNAL_RELAY_ADDRESS: relay-a:9445
TNLD_INTERNAL_RELAY_LISTEN: :9445
TNLD_METRICS_LISTEN: 0.0.0.0:9090
TNLD_ROLE: relay
TNLD_RELAY_ADDRESS: relay-a.${TNLD_SERVER_DOMAIN}:443
TNLD_RELAY_ID: relay-a-1
TNLD_RELAY_SERVICE_ID: relay-a
TNLD_RELAY_TCP_LISTEN: :8443
TNLD_RELAY_UDP_LISTEN: :8443
ports:
- "${TNL_RELAY_A_BIND:?set TNL_RELAY_A_BIND}:443:8443/tcp"
- "${TNL_RELAY_A_BIND}:443:8443/udp"
expose:
- "9090"
- "9445"
relay-b:
<<: *tnld
restart: unless-stopped
depends_on:
- control
environment:
TNLD_CONTROL_HOSTNAME: ${TNL_CONTROL_HOSTNAME:?set TNL_CONTROL_HOSTNAME}
TNLD_PRIVATE_CONTROL_ADDRESS: control:9443
TNLD_CLUSTER_SECRET: ${TNLD_CLUSTER_SECRET:?set TNLD_CLUSTER_SECRET}
TNLD_CLUSTER_SECRET_PREVIOUS: ${TNLD_CLUSTER_SECRET_PREVIOUS:-}
TNLD_INTERNAL_RELAY_ADDRESS: relay-b:9445
TNLD_INTERNAL_RELAY_LISTEN: :9445
TNLD_METRICS_LISTEN: 0.0.0.0:9090
TNLD_ROLE: relay
TNLD_RELAY_ADDRESS: relay-b.${TNLD_SERVER_DOMAIN}:443
TNLD_RELAY_ID: relay-b-1
TNLD_RELAY_SERVICE_ID: relay-b
TNLD_RELAY_TCP_LISTEN: :8443
TNLD_RELAY_UDP_LISTEN: :8443
ports:
- "${TNL_RELAY_B_BIND:?set TNL_RELAY_B_BIND}:443:8443/tcp"
- "${TNL_RELAY_B_BIND}:443:8443/udp"
expose:
- "9090"
- "9445"For backups, schema upgrades, maintenance controls, and a relay drain, see operations. For all process flags and secret boundaries, see tnld reference.