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:

compose.yaml (service fragment)
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:

tnl.config.ts
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 generate

Generation 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 orders

tnl 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 protocolNormal client through the local listener
httpcurl or an HTTP client using the managed HTTPS hostname and tnl's printed loopback override.
postgrespsql or a PostgreSQL driver using the managed hostname, local hostaddr, and sslmode=verify-full.
mysqlmysql or a MySQL driver using the managed hostname and VERIFY_IDENTITY; follow the printed local hostname mapping.
tcpnc, OpenSSH, or another TCP client. tnl verifies the outer managed TLS before carrying application bytes.
kuberneteskubectl 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:

tnl.config.ts
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-a

This 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:

ProtocolTarget handshake with target.tls
httpVerified HTTPS
tcpDirect verified TLS before opaque bytes
postgresPostgreSQL SSLRequest, then verified TLS before startup/login
mysqlServer 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 orders

Choose 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 },
}
~/.ssh/config
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.internal

Use 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.