tnl.dev :: docs

workloads.

Approve exact services for GitHub, Vercel, Fly, and configured OIDC workloads.

let a deployment reach selected services

Let your deployment connect to a private database or API using its existing platform identity. For example, you can let a GitHub Actions deploy job reach orders without giving it your personal tnl login.

A workload names that deployment and the services it can use. Add it to tnl.config.ts, then run tnl apply as a team owner or admin. Your running app proves its identity with a platform token and gets only the access you approved.

Prerelease: these examples need matching SDK and server builds newer than rc.40. See release status for the remaining package and live-platform checks.

Each workload has one provider block and explicit grants. The examples below go inside defineConfig alongside your existing services. YAML and JSON put these declarations under tnl.workloads.

connect a github actions job

Suppose your deploy job needs to run migrations against an existing orders service. Add this workload, using your repository and workflow path:

workloads: {
  deploy: {
    github: {
      repository: "shop/storefront",
      workflow: ".github/workflows/deploy.yml",
      branch: "main",
      events: ["push"],
    },
    grants: { visit: [{ service: "orders" }] },
    maxDuration: "1h",
  },
}

Generate the runtime metadata and apply the policy from your checkout:

tnl generate
tnl apply

Apply saves the repository and owner IDs. For a private repository, sign in with gh first so it can look them up. A new repository with the same name does not inherit the old repository's access.

Then add the action to your deploy job. Replace the placeholder with the full SHA of a released action that matches your locked tnl package:

.github/workflows/deploy.yml
name: deploy
on: push
permissions:
  contents: read
  id-token: write
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      # set up your package manager and install locked dependencies here
      - uses: tnldotdev/tnl/connect@<full-published-commit-sha>
        with:
          workload: deploy
          services: orders
      - run: pnpm exec drizzle-kit migrate

The action opens local connections for later steps and closes them when the job ends. Configure your migration tool with tnl's connection settings and your normal database username and password. See database clients.

For a script that manages its own connection, select the workload explicitly:

import { createTnl } from "@tnldotdev/tnl";
 
const app = createTnl({ credential: { workload: "deploy" } });
try {
  const orders = await app.connect("orders");
  // configure your database driver with orders and its usual login
  // close the driver before leaving this block
} finally {
  await app.close();
}

To narrow the GitHub policy, set github.environment or github.reusableWorkflow. Omit repository to use the GitHub origin and branch to save the repository's default branch. Use tag instead of branch for an exact tag; events defaults to ["push"].

The action's services input narrows access further, with one service per line. It also accepts working-directory, timeout (1–300 seconds), and either an exact npm version or cli-path. Otherwise it uses the locked project package. Each job keeps its original service selection and deadline, including after a lost-reply retry. Linux GitHub-hosted x64/arm64 jobs are the intended runtime; container jobs and shared self-hosted runners still need live checks.

approve a vercel project and environment

For example, let your production storefront call an inventory API running in your private network:

tnl.config.ts
import { defineConfig } from "@tnldotdev/tnl/config";
 
export default defineConfig({
  team: "shop",
  publisherGroups: { production: {} },
  services: {
    inventory: {
      publisherGroup: "production",
      target: { type: "address", address: "http://inventory.internal:8080" },
    },
  },
  workloads: {
    storefront: {
      vercel: { team: "shop", project: "storefront", environment: "production" },
      grants: { visit: [{ service: "inventory" }] },
    },
  },
});

Run a publisher where it can reach inventory.internal. Then, from your checkout:

tnl check
tnl generate
tnl apply

The first apply needs VERCEL_TOKEN to look up the project and team. It saves their IDs; later applies reuse them while the selection stays the same. Deleting and recreating a project with the same name does not transfer its access.

Choose production, preview, or development for environment. Omit project to use .vercel/project.json and team to use its owner. That file helps setup; runtime access requires a signed Vercel token for the saved project and environment.

See calling from a Node function for the handler and packaging steps. The native connector can reach this private service. The Fetch-only /web SDK instead needs a publicly reachable HTTP endpoint; enable the group's public: true only if you need that access path. Public reachability still requires visitor permission.

authenticate a fly machine

Let the stock-worker app visit inventory:

workloads: {
  worker: {
    fly: { org: "shop", app: "stock-worker" },
    grants: { visit: [{ service: "inventory" }] },
  },
}

Run tnl generate and tnl apply with your existing FLY_API_TOKEN or flyctl login. Apply saves the app and organization IDs. Omit app to select fly.toml through flyctl config show --local; org can require a particular organization, and machineId can restrict access to one Machine.

On the Machine, native tnl gets a signed token from Fly's local API. The token's audience defaults to your tnl control URL. tnl checks the signed organization, app, and Machine identity; setting FLY_APP_NAME or FLY_MACHINE_ID alone cannot grant access. A Fly deployment API token is not a runtime identity token.

If you supply your own token callback, Fly's OIDC guide and token API describe POST http://localhost/v1/tokens/oidc over /.fly/api. Send {"aud":"https://control.example.com"} with your control URL. The response is a text JWT, not a JSON wrapper. Keep it private.

authenticate a google cloud service account

workloads: {
  app: {
    google: { serviceAccount: "orders@my-project.iam.gserviceaccount.com" },
    grants: { visit: [{ service: "orders" }] },
  },
}

Run tnl generate and tnl apply with your existing gcloud login. Apply saves the service account's numeric ID. You can specify that ID directly to skip the name lookup. Recreating an account with the same email does not transfer access; use a new workload name to approve the replacement.

On Cloud Run, K_SERVICE selects the single matching Google workload. On another Google runtime with an attached service account, set TNL_WORKLOAD=app. Native tnl gets and refreshes tokens from Google's fixed local metadata endpoint. The audience defaults to the control URL; use google.audience to change it.

The metadata request bypasses proxies, refuses redirects, and requires Google's response header. You cannot point it at another host. Your app does not need a saved Google access token. See Google's ID token guide for runtimes with metadata identity support.

authenticate a kubernetes or eks pod

Declare the service account your Pod runs as:

workloads: {
  app: {
    kubernetes: { serviceAccount: "orders", namespace: "production" },
    grants: { visit: [{ service: "orders" }] },
  },
}

Use your normal authenticated kubectl context. For EKS, for example:

aws eks update-kubeconfig --name development --region us-west-2
tnl generate
tnl apply

Apply saves the account UID and the cluster's issuer and signing-key URL. Setup needs permission to read the service account and /.well-known/openid-configuration. Control must be able to reach the signing keys over publicly trusted HTTPS. An unchanged apply reuses the saved identity. Recreating the account requires approval under a new workload name.

Set kubernetes.context if you manage several clusters. Without an explicit namespace, tnl uses that context's namespace, then default. Your running app does not need kubeconfig or your cluster credentials.

Mount a projected token in the application container. Use your control URL for audience, or the value of kubernetes.audience if you override it:

apiVersion: v1
kind: Pod
metadata:
  name: orders
  namespace: production
spec:
  serviceAccountName: orders
  automountServiceAccountToken: false
  containers:
    - name: app
      image: your-existing-application-image
      volumeMounts:
        - name: tnl-identity
          mountPath: /var/run/secrets/tnl
          readOnly: true
  volumes:
    - name: tnl-identity
      projected:
        sources:
          - serviceAccountToken:
              path: token
              audience: https://control.example.com
              expirationSeconds: 3600

Keep your real image and application settings. The orders service account must already exist. tnl reads /var/run/secrets/tnl/token by default; token.file selects another path. Mount the directory, not a subPath, so kubelet's token rotation reaches the app. No controller, webhook, or token-creation permission is needed.

KUBERNETES_SERVICE_HOST selects the single matching Kubernetes workload. Set TNL_WORKLOAD=app when selection is ambiguous or service environment variables are disabled. tnl rereads the token in the background, even when your app is idle.

Use Pod-bound tokens lasting at most 12 hours. tnl checks the signed account UID, Pod UID, issuer, audience, and expiry. Rotation for the same Pod keeps its session; a new Pod gets a new session. Unbound TokenRequest and legacy secret tokens are unsupported.

Deleting a Pod does not immediately revoke a token already signed for it: tnl checks signing keys rather than calling TokenReview. Remove the workload policy to revoke tnl access without waiting for token expiry. See Kubernetes' token projection guide.

connect a gitlab ci job

workloads: {
  deploy: {
    gitlab: { project: "shop/storefront" },
    grants: { visit: [{ service: "orders" }] },
  },
}

Run tnl generate and tnl apply with an existing glab login that can read the project. Apply saves its project ID, namespace ID, and default branch. Omit project to use the GitLab.com origin path; gitlab.server selects a self-managed HTTPS origin. A recreated or transferred project needs a new workload approval.

By default, only push pipelines on the saved default branch can connect. gitlab.branch or gitlab.tag selects another exact ref. You can also set gitlab.pipelineSource and gitlab.environment. These selectors do not accept wildcards.

Request an ID token in your existing job:

.gitlab-ci.yml
migrate:
  timeout: 20m
  id_tokens:
    TNL_ID_TOKEN:
      aud: https://control.example.com
  script:
    # use your existing locked dependencies and runner setup
    - pnpm run migrate

Use your control URL for aud, matching gitlab.audience if you set it. The migration script needs tnl connection settings and its usual database login; setting a token alone does not tunnel the command. Keep the token out of logs and artifacts.

The default token variable is TNL_ID_TOKEN; token: { env: "ANOTHER_ID_TOKEN" } changes it. GITLAB_CI=true selects the single matching GitLab workload, or you can set TNL_WORKLOAD=deploy.

This preset requires GitLab 18.4 or newer. tnl checks signed project and namespace IDs, the ref, pipeline source, optional environment, and job identity. A fork job cannot use its parent's grant. Standard path-based and numeric-ID subjects work; custom layouts are rejected. Self-managed GitLab must expose /oauth/discovery/keys over publicly trusted HTTPS reachable by control.

Set a job timeout of at most 12 hours. GitLab uses it for token expiry, or five minutes if no timeout is set. Another job gets a separate session. An environment token cannot refresh itself; see GitLab's ID token guide.

use a rotating oidc token file or callback

For another OIDC provider, specify its issuer and the claims your app must have:

workloads: {
  worker: {
    oidc: {
      issuer: "https://identity.example.com",
      claims: { sub: "inventory-worker", environment: "production" },
    },
    token: { file: "/run/identity/token" },
    grants: { visit: [{ service: "inventory" }] },
  },
}

Apply discovers the signing-key URL. Set oidc.jwksUrl to override it and oidc.audience to use an audience other than the control URL. The issuer must use HTTPS, and claims must contain exact string matches.

tnl rereads the file as your provider rotates it. You can instead supply a callback in server code:

import { createTnl } from "@tnldotdev/tnl";
 
const app = createTnl({
  credential: { workload: "worker", token: async () => acquireCurrentAssertion() },
});

acquireCurrentAssertion is your provider integration. Read the current request's token each time; do not capture one at startup. Use tokenFile instead of token to select a runtime file. Close app when its work ends.

Every provider supports a project-level token: { file: "/run/current.jwt" } or token: { env: "JOB_ID_TOKEN" } override. Select one source. Missing or unreadable tokens fail instead of falling back to your personal login. Keep token values out of project configuration and generated files. Workload selectors use the native SDK, not the Fetch-only /web entry point.

package the generated workload manifest

Run tnl generate and include .tnl/generated/workloads.json in your app's build. Generate it from the same configuration you apply. Keep .tnl/generated/ in Git and ignore .tnl/local/.

The manifest contains your project's identity, workload names, providers, grants, token sources, and service protocols. It contains no private keys, tokens, target addresses, or database passwords. Editing it cannot grant access; control checks it against the applied project. Do not copy another project's manifest or fill in service IDs yourself.

At runtime, tnl reads the manifest from the process working directory and selects one matching provider workload. TNL_WORKLOAD or credential: { workload: "deploy" } selects one explicitly. Runtime does not need Git or TypeScript config evaluation. See function packaging.

understand renewal and removal

tnl keeps a connection authorized with short permission leases. A lease lasts at most 90 seconds and renews after 30 seconds, but cannot outlive the latest platform token. Active streams recheck permission within 30 seconds. If control is down, they close by the last accepted deadline.

Set maxDuration: "1h" to cap a session at one hour from its original start. Leave it out for no additional hard limit. Any positive whole-second duration is allowed, including more than six hours. Fresh tokens still cannot extend that hard limit. Removing the setting does not extend an existing session; shortening it counts from the original start, not the later apply.

Token sourceHow it stays current
Rotating filetnl rereads the selected path in the background.
Fly Machine API or Google metadatatnl requests a fresh token in the background.
Request callback or Vercel request tokenA later SDK getter supplies the current request's token.
Environment variableThe app must supply a current value; a static token cannot refresh itself.

An idle app needs no dummy requests for background refresh. GitHub's request bearer is not retained for background use. Closing the runtime waits for its refresh work to stop.

Renewal uses the original signing key and an ordered counter. Retrying after a lost reply returns the same lease rather than extending it twice. Fresh proof for the same identity can keep healthy sockets open before expiry. It cannot revive an expired session, add grants, or replace a stream's original identity. Key-binding certificates also expire; they do not replace a current lease.

Removing a workload or changing its identity rules revokes its access. Removing a grant removes that right from existing sessions; adding it back does not restore those sessions. Failed HTTP requests, uploads, and SQL are never replayed. Prepare a new connection for later work.

declare application grants

A workload can visit, publish, or allocate, with 1–64 grants in total. For example:

workloads: {
  deploy: {
    github: {
      repository: "shop/storefront",
      workflow: ".github/workflows/deploy.yml",
      branch: "main",
      events: ["push"],
    },
    grants: {
      visit: [{ service: "orders" }],
      publish: [{ publicURL: "preview.example.com" }],
      allocate: [{ domain: "previews.example.com" }],
    },
  },
}

Here, orders must be a declared service in a named publisher group. The public URL must already exist, and the domain must be ready for the team. Apply saves their exact resource IDs.

visit and publish each accept { service } or { publicURL }. One does not include the other. allocate selects the approved namespace on a ready domain, not every hostname on that domain. SDK namespace allocation and non-HTTP publication still need implementation.

Ownership defaults to member, so the approving member must remain eligible. An owner or admin can set ownership: "team" for team-owned resources, letting the app run independently of that person's membership. Changing ownership replaces the application and revokes its old sessions.

native application authority

Visiting and publishing in one SDK instance use the same native session and signing key. Control returns only the application's approved grants. Renewals can remove grants or shorten deadlines, but cannot add grants or swap the key.

Native tnl keeps the temporary application credential in memory and passes it privately to the publisher. You do not need a credential file, and it never goes into generated project metadata. Publish-only and allocation-only workloads can also have a generated manifest.

publish with a workload identity

Add grants.publish, apply the configuration, and package the generated manifest. Then publish from the same instance you use for any visitor connections:

import { createTnl } from "@tnldotdev/tnl";
 
const app = createTnl({ credential: { workload: "deploy" } });
try {
  const publication = await app.publish(() => new Response("preview"));
  await publication.wait();
  await publication.finished(); // until shutdown or terminal failure
} finally {
  await app.close();
}

You can omit resource when there is exactly one publish grant. Otherwise pass an exact url_… or svc_… ID in { resource }. To prepare before opening your own listener, use app.prepare({ resource }), then register the bound server. The saved resource supplies its access policy and limits.

For a Python ASGI app:

import asyncio
import tnl
from myapp import application
 
async def main():
    async with tnl.create_tnl(credential=tnl.Workload("deploy")) as app:
        async with app.publish(application, resource="url_4f90D13A42bKs7pQ8tM2Uv") as publication:
            await publication.wait()
            await publication.finished()
 
asyncio.run(main())

Python also supports await app.prepare(resource=...) for your own listener. Pass callbacks or token files inside tnl.Workload(...).

Publication keeps using its original workload identity. If refresh fails, it can serve only until its last confirmed deadline, then drains. Another login or session cannot take over that run. Closing the instance drains publications before closing clients and releasing the native session. If cleanup fails, retry the close on that instance. See SDK publication for listener ownership and readiness.