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 applyApply 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:
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 migrateThe 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:
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 applyThe 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 applyApply 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: 3600Keep 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:
migrate:
timeout: 20m
id_tokens:
TNL_ID_TOKEN:
aud: https://control.example.com
script:
# use your existing locked dependencies and runner setup
- pnpm run migrateUse 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 source | How it stays current |
|---|---|
| Rotating file | tnl rereads the selected path in the background. |
| Fly Machine API or Google metadata | tnl requests a fresh token in the background. |
| Request callback or Vercel request token | A later SDK getter supplies the current request's token. |
| Environment variable | The 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.