tnl.dev :: docs
private services.
Connect normal clients to managed services through a local listener.
connect to local postgres without sign-in
Use a local PostgreSQL listener without a tnl account or tunnel. This initial
explicit-deployment API needs matching prerelease native and npm builds; it
supports local PostgreSQL only. Remote deployment handles and customer onboarding
are not implemented by createClient yet.
Publish your existing Compose database port to loopback:
services:
postgres:
ports: ["127.0.0.1:15432:5432"]Keep your database's existing image, credentials, and volumes. Then declare the local address in your application:
import { defineConfig } from "@tnldotdev/tnl/config";
export default defineConfig({
services: {
db: {
protocol: "postgres",
target: { type: "address", address: "127.0.0.1:15432" },
},
},
});Start PostgreSQL and generate the application's service facts:
docker compose up -d postgres
pnpm exec tnl generateGeneration writes .tnl/generated/services.ts, containing only service names and
protocols. It includes no target addresses, deployment IDs, or credentials. Commit
it with the application; rerun generation when its services change.
Use your normal pg pool:
import { Pool } from "pg";
import { createClient } from "@tnldotdev/tnl";
import { services } from "./.tnl/generated/services.js";
const local = createClient({ services }).deployment("local");
const database = local.client("db", {
create: (connection) =>
new Pool({
...connection,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
}),
close: (pool) => pool.end(),
});
try {
const pool = await database();
console.log(await pool.query("select 1"));
} finally {
await local.close();
}Keep the deployment handle for the application's lifetime and close it at shutdown, not after each request. The driver factory reuses the pool for the current target binding. Closing the handle joins pending work and closes its driver pools; it does not stop PostgreSQL or sibling handles.
For connection settings alone, use await local.connect("db") and manage your own
driver's lifetime. Without target.tls, the returned settings explicitly disable
TLS for this local listener. A declared TLS configuration is preserved and must
be verified by the database driver. Resolving settings does not test readiness;
open a connection or run a query. Local access still needs the database's own
credentials.
The address must be loopback. A failed local connection or discovery never falls
back to a saved remote service. Existing compose and docker discovery can also
select a local port, but retain their exact
container placement requirements.
Use the address recipe above unless you already manage those labels.
connect a native client
Suppose your project has a PostgreSQL service named orders. From your laptop:
tnl auth login --team shop
tnl connect orderstnl prints connection settings for psql or your database driver. Use them with
your usual database username and password. Keep tnl connect running while you
use the database, or add --detach to keep it running after you close the terminal.
Run tnl connect available to list the services you can use. The same command
works for private and public services, and for HTTP, MySQL, TCP, and Kubernetes.
This local connector handles tnl authentication; your client needs no tnl client
certificate.
Each service has its own hostname, port, and TLS certificate. tnl opens a local
listener and tells your client how to reach it while verifying that hostname.
Making a service public keeps its assigned port, including HTTP; it does not
move HTTP to port 443. --listen changes only the local listener.
| Approved protocol | Normal client through the local listener |
|---|---|
http | curl or an HTTP client using the managed HTTPS hostname and tnl's printed loopback override. |
postgres | psql or a PostgreSQL driver using the managed hostname, local hostaddr, and sslmode=verify-full. |
mysql | mysql or a MySQL driver using the managed hostname and VERIFY_IDENTITY; follow the printed local hostname mapping. |
tcp | nc, OpenSSH, or another TCP client. tnl verifies the outer managed TLS before carrying application bytes. |
kubernetes | kubectl using the context registered by tnl and the cluster's existing bearer-token authentication. |
Use --listen 127.0.0.1:15432 to choose a local port. Both --listen and --detach
work for all five protocols. For a TCP stream over stdin/stdout, use --stdio.
If your service is named list, stop, or another subcommand, select it with
tnl connect start NAME. --protocol checks the service's declared protocol;
it does not change it.
declare and publish a service
Add the database to your project and assign it to publishers in its network:
import { defineConfig } from "@tnldotdev/tnl/config";
export default defineConfig({
team: "shop",
publisherGroups: {
production: { domain: "example.com", public: false },
},
services: {
orders: {
publisherGroup: "production",
protocol: "postgres",
target: {
type: "address",
address: "orders.internal:5432",
tls: { caFile: "certs/database-ca.pem", serverName: "orders.internal" },
},
access: { team: true },
},
},
});Run tnl check, tnl apply, and a publisher group
near the target. For a local service, omit publisherGroup and run tnl publish orders
or start its integrated HTTP app normally. protocol defaults to http.
The key orders is the service's default name; set name to override it. Inside
the project, tnl selects that project's applied service. Outside it, use --team
when names are ambiguous, or select the service ID. available also lists offline
services, so try a connection to check that the database is reachable.
Set the group's public: true if clients need to reach its hostname and port
without a local connector. Public connections go through ingress and a relay;
connector connections bypass ingress. Either way, visitors still need access.
access: { team: true } gives team members that access, while the database keeps
its own login requirements.
Named-group HTTP services also use the group's domain and public settings.
Use publicURL for a local HTTP app's separate public URL, not for these managed
endpoints. See publisher groups.
For a customer installation created from a group template, select the group too:
tnl connect api --publisher-group customer-aThis selects the installation; it does not grant access. See group templates for creation, enrollment, and fleet-wide updates.
To invite one verified email until an explicit deadline, inspect current grants, or revoke access, see service access.
native database and HTTP instructions
Use the actual managed hostname, local listener port, and commands printed
by tnl. Connecting to a loopback address must not replace the server name used
for TLS verification with localhost or an IP address.
For PostgreSQL, commands use the managed hostname with a loopback hostaddr,
sslmode=verify-full, sslrootcert=system,
gssencmode=disable, and channel_binding=disable. TLS terminates at the publisher,
so PostgreSQL channel binding cannot span the separate target TLS hop. Normal
SCRAM authentication remains available.
HTTP commands use curl --connect-to to send the connection to the local listener
while preserving the managed hostname for HTTPS verification and SNI. The URL
retains the allocated service port, even when --listen selects a different local
port. Use the printed command rather than changing the URL to https://localhost.
MySQL commands use --ssl-mode=VERIFY_IDENTITY and an explicit server CA bundle.
tnl selects SSL_CERT_FILE if set, otherwise a supported system PEM bundle.
Missing roots fail before preparing credentials or starting a listener. The server
trust bundle verifies the publisher's server certificate. Follow the printed
mapping of the exact managed hostname to the loopback listener; using
--host=localhost would verify the wrong identity.
tnl connect orders --detach --listen 127.0.0.1:15432 --output json
tnl connect public-api --detach --listen 127.0.0.1:18443--output json returns the ready local address and native client instructions.
tnl does not launch psql, mysql, or curl for you. Sign in and keep the connector
running; an IP match alone is not an identity credential. A managed endpoint's
public endpoint browser sign-in or token access is separate from this local connector.
verify publisher-to-target tls
The tunnel encrypts traffic as far as the publisher. To encrypt the remaining
connection from the publisher to your database or app, set target.tls:
| Protocol | Target handshake with target.tls |
|---|---|
http | Verified HTTPS |
tcp | Direct verified TLS before opaque bytes |
postgres | PostgreSQL SSLRequest, then verified TLS before startup/login |
mysql | Server greeting and SSLRequest, then verified TLS before login |
For a certificate trusted by the publisher's system, use target.tls: {}. For a
private CA, add caFile with a project-relative CA-only PEM file. serverName
sets the hostname to verify; YAML/JSON use ca_file and server_name.
tnl apply saves the CA material, so replicas do not need that file from your
checkout. Apply again after changing it. Certificate checks cannot be disabled.
Omitting target.tls allows plaintext backend transport; it never disables managed
visitor TLS. Address HTTP targets with a TLS block use an https:// origin.
An app target's actual listener must agree with its declared TLS; pre-apply needs
serverName when that hostname is not yet known. Failures do not retry in plaintext.
keep a connector running
The default local address is 127.0.0.1 at the allocated service port. An occupied
port fails rather than silently renumbering it. Override a listener explicitly:
tnl connect orders --listen 127.0.0.1:15432
tnl connect orders --detach
tnl service install
tnl connect orders --install
tnl connect list
tnl connect stop orders
tnl connect remove ordersChoose only one of foreground (default), detached, or installed operation.
Detached work survives terminal closure without automatic restart. Installed
connectors use the user supervisor and saved sign-in. Configure native clients
using the managed hostname and printed TLS instructions; an explicit alternate
listener port must also be reflected in the client command. tnl does not rewrite
database settings or SSH configuration. For the kubernetes protocol, tnl
registers its owned kubeconfig context while preserving unrelated contexts.
use an opaque stream with OpenSSH
An existing sshd is a tcp target, usually without target.tls:
shell: {
publisherGroup: "production",
protocol: "tcp",
target: { type: "address", address: "host.internal:22" },
access: { team: true },
}Host shop-shell
HostName SERVICE_HOSTNAME_FROM_TNL
User deploy
ProxyCommand tnl --no-config connect start svc_REPLACE_WITH_SERVICE_ID --stdio --team shop
HostKeyAlias shop-host.internalUse the actual service ID and verify the sshd host key normally. --stdio is a
generic opaque-TCP mode on both private and public services, not an SSH-only
command and not a native PostgreSQL/MySQL mode. Stdout contains only application
bytes; diagnostics go to stderr. Sign in before running SSH: the helper never
reads SSH stdin to prompt for login. OpenSSH retains user and host authentication,
shells, forwarding permissions, and file transfer. tnl does not run an SSH server.
Interrupting the helper stops its stream and waits for both copy directions to
finish. An ordinary interrupt exits without a connection error; a separate
output or cleanup failure still goes to stderr.
A normal end-of-stream also stops pending permission checks without turning
their cancellation into a connection error.
For an existing end-to-end TLS application carried as opaque TCP, omit
target.tls. The local helper verifies tnl's outer managed TLS; inside it, keep
the application's own certificate validation. Point kubeconfig at the printed
loopback address, keep EKS certificate-authority-data, set tls-server-name to
the original endpoint hostname, and retain normal aws eks get-token and RBAC.
failover and revocation
Your connector prepares two relay paths before reporting ready. Traffic starts over those relays while it tries a direct UDP connection to the publisher. If direct UDP works, packets switch to it using the same application connection. If it stops working, traffic returns to a relay. A network that blocks UDP can keep using relays.
For example, losing one relay need not close your database connection. tnl can repair that path while keeping the encrypted connection and database sockets. Changing networks can briefly pause traffic while control approves the new source IP and relay pair. Both relays must observe the same IP, and your IP policy must allow it. A new network selection replaces the old direct path.
Recovery must finish before the existing permission deadline and the connection's 30-second idle timeout. It needs the same connector, visitor identity, service run, publisher, and trusted service settings. It cannot hand the connection to another publisher, revive an expired session, or approve a new network while control is unavailable.
Active HTTP bodies, WebSockets, and database streams recheck access within 30 seconds. Revocation closes the affected stream; a control outage cannot extend its last accepted deadline. Direct UDP follows the same rules and keeps the relay-observed source IP for policy checks.
If a stream closes, reconnect for later work. tnl never replays a failed request, SQL statement, or transaction. Public endpoint connections have a different retry boundary.
use a long-running native application
Create a database pool once and reuse it across requests. tnl.client supplies
current connection settings to your ordinary driver:
import { tnl } from "@tnldotdev/tnl";
import { Pool } from "pg";
export const getDb = tnl.client("orders", {
create(connection) {
return new Pool({
...connection,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
},
close(client) {
return client.end();
},
});
// inside a request: db is an ordinary, correctly typed pg.Pool
const db = await getDb();
const result = await db.query("select 41 + 1 as answer");This needs a matching prerelease SDK.
Keep the factory and driver imports in server code; browser use reports
sdk.connection_server_only.
await getDb() checks current access and reuses the pool while the connection
settings and identity stay the same. Routine permission renewal keeps the pool.
A different identity or replacement connector gets a separate pool; denied access
throws rather than returning another identity's client. Queries and driver errors
pass through unchanged.
Call await getDb.close() at application shutdown. It waits for any pool still
being created and closes all its clients. If cleanup fails, retry that same close;
only failed closes run again. A create callback that throws must clean up its
own partial setup. Closing a factory does not stop an unrelated shared connector.
One factory caches at most 64 distinct connection settings/identity combinations. It keeps older clients until cleanup, since your app may still be using them. Cached clients still lose access when their permissions expire or are revoked.
The native connection owner renews authorization and connections during its permitted lifetime. Applications use ordinary driver settings with the actual local host/port and verified managed TLS SNI, without a tnl client certificate or private key. For example, after connector SDK preparation:
const database = await tnl.connect("orders");
const settings = {
...database,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
};The database owns these application credentials. A pool must obtain fresh usable connections after renewal and owner recovery; old settings are not permanent authority. Await a handle to prepare/refresh it; synchronous settings reads require prior preparation. Permission removal, expired human proof, lost owner, or an occupied port must surface as connection failures. Sign in again when human proof expires; a background owner cannot manufacture another login.
For a mysql service, use the qualified mysql2 client. Its settings use the
managed hostname for certificate verification, ssl.verifyIdentity: true, and
a stream factory that opens a fresh loopback socket for each ordinary pool
connection. Preserve those settings together; replacing host with 127.0.0.1
can change the driver's verified identity. The older Node mysql package is not
supported for this boundary. The MySQL CLI instructions above are a separate
ordinary-client path.
With Node pg, leave enableChannelBinding: false (the default). Postgres.js
uses ordinary non-PLUS SCRAM with the returned host, port, and ssl; do not
add a connection.channel_binding startup parameter.
For libpq/psql, retain the printed channel_binding=disable and
gssencmode=disable with full TLS verification. Drivers that require PostgreSQL
channel binding across the publisher's separate TLS hops are unsupported. Do not
work around that boundary by disabling certificate or hostname verification.
A synchronous settings read uses prepared local state only. It does not acquire a provider assertion, call control, prepare a sibling service or repair an expired connection. Await preparation for later work. Stable listeners can keep a healthy pool across authorization renewal; connector admission and each original stream's deadline still apply. A request racing a closed old socket can fail. Let the driver reconnect for a later request without replaying the failed statement or transaction.
For CI and deployed apps, use a workload instead of a personal login. The Node function example uses the same connector for HTTP and database access.
use a python client factory
The generated project facade gives the callback its service's exact connection type. For example, a psycopg pool keeps libpq's verified hostname separate from the native loopback dial address:
import os
import tnl as native
from psycopg_pool import AsyncConnectionPool
from tnl_project import tnl
async def create_db(connection: native.PostgresConnection) -> AsyncConnectionPool:
pool = AsyncConnectionPool(
open=False,
kwargs={
"host": connection.server_hostname,
"hostaddr": connection.host,
"port": connection.port,
"sslmode": "verify-full",
"sslrootcert": os.environ.get("DB_SSL_ROOT_CERT", "system"),
"channel_binding": "disable",
"gssencmode": "disable",
"user": os.environ["DB_USER"],
"password": os.environ["DB_PASSWORD"],
"dbname": os.environ["DB_NAME"],
},
)
try:
await pool.open()
except BaseException as setup:
try:
await pool.close()
except BaseException as cleanup:
raise BaseExceptionGroup("pool setup and cleanup failed", [setup, cleanup]) from None
raise
return pool
async def close_db(client: AsyncConnectionPool) -> None:
await client.close()
get_db = tnl.client("orders", create=create_db, close=close_db)
# inside a request: pool is the cached ordinary psycopg pool
pool = await get_db()This recipe requires libpq support for sslrootcert=system. If the returned
connection includes a private CA, put that public CA in an application-managed
file and set DB_SSL_ROOT_CERT to its path instead. Preserve hostname verification;
do not replace host with the loopback address or disable TLS checks.
Visitor TLS ends at the publisher, so libpq channel binding cannot span the
publisher-to-target connection. Disable channel binding, not TLS verification.
Call await get_db.aclose() during application shutdown. Canceling one getter
does not cancel shared creation; canceling a close waiter does not abandon
cleanup. A later close can join or retry it. Create a factory in each asyncio event
loop and create a fresh factory after a fork. A child cannot use an inherited
factory or close its parent's client. Drivers and ORMs remain application
dependencies; tnl supplies one generic callback-based factory, not a wrapper for
each driver.
close python-owned connections
Keep the handle when you need explicit cleanup; awaiting it returns ordinary driver settings, not a new database connection:
import tnl
handle = tnl.connect("orders")
try:
settings = await handle
# configure and use your ordinary database client with these settings
finally:
await handle.aclose()aclose() waits for pending preparation and joins cleanup of the shared native
session selected by that handle. Aliases of that session close together; an old
alias cannot close a replacement or another workload identity. If cleanup fails,
retry aclose() on the same handle. A handle using TNL_CONNECTION_HANDLE borrows
its owner and does not stop that owner when closed. Close your database pool
before closing its tnl handle.