tnl.dev :: docs
publisher groups.
Run independent publisher replicas for applied public and private services.
run publishers near your targets
A named publisher group lets independently provisioned tnl replicas serve the
same applied services. You run the replicas; control stores desired configuration,
grants, leases, health, and audit history. Hosted and self-hosted control use the
same project and publisher contract.
import { defineConfig } from "@tnldotdev/tnl/config";
export default defineConfig({
team: "shop",
publisherGroups: {
production: {
description: "services in the production network",
domain: "example.com",
public: false,
},
},
services: {
web: { publicURL: {} },
api: {
publisherGroup: "production",
target: { type: "address", address: "https://api.internal:8443", tls: {} },
access: { team: true },
},
orders: {
publisherGroup: "production",
protocol: "postgres",
target: { type: "address", address: "orders.internal:5432" },
access: { team: true },
},
},
});Group settings are description, ready team domain, public (default false),
and template (default false).
local is reserved; omitting a service's publisher selects it. Remote groups use
reachable address targets, not app/container discovery on the applying machine.
Replica counts and deployment values belong to your own infrastructure
configuration, not the project. See AWS publishers.
Managed endpoints retain their control-assigned hostname and port across private/
public exposure changes, including HTTP. Private DNS resolves to loopback;
public DNS resolves to ingress. public: true does not remove visitor identity
checks or move HTTP to port 443. The local web service above has a separate
worktree public URL. Named-group HTTP always uses its service endpoint. An
optional http block on api can add restricted access/IP filters through
access and request limits through limits. It cannot allow anonymous access
or set traffic limits; named groups reject publicURL blocks. With an explicit
http block, apply also rejects incompatible inherited HTTP defaults. Without
that block, root public URL defaults do not apply.
repeat one definition across customer installations
Declare a publisher group with template: true when each customer installation
needs independently addressable services. The groups belong to your SaaS team;
your application authenticates customers and selects their saved group IDs.
Each customer's publisher runs near its targets, such as inside its VPC, and
connects outbound to relays. tnl apply registers the definition
without allocating a base group, endpoints, or publisher identities:
import { defineConfig } from "@tnldotdev/tnl/config";
export default defineConfig({
team: "vendor",
publisherGroups: {
customer: { template: true },
},
services: {
api: {
publisherGroup: "customer",
target: { type: "address", address: "http://api.internal:9080" },
access: { team: true },
},
orders: {
publisherGroup: "customer",
protocol: "postgres",
target: { type: "address", address: "orders.internal:5432" },
},
},
});Create the concrete groups after applying the definition:
tnl apply
tnl publisher create customer-a --from customer
tnl publisher create customer-b --from customer
tnl publisher enroll --group customer-aEach group belongs to the project's selected team and has its own service IDs,
namespace, publisher identities, and grants. Repeating creation with the same
name and template returns the existing group. Another template cannot adopt it,
and retired group names remain reserved. --project-key selects an applied
project explicitly when running outside its checkout.
Enrollment is unchanged: in the customer's network, run tnl --no-config publisher serve
with the control --server, applied --project-key, concrete --group, and
private persistent state. Use --enroll-key - for stdin or
TNL_GROUP_ENROLL_KEY for the first bootstrap. Each computer creates its own
signing key. Supervise the process; later starts use its saved identity. Do not
share one identity or state directory between computers.
An enrollment with several members adds interchangeable publishers to one
group. It does not create separately addressable installations. Give different
customers different concrete groups, not different --instance names in one group.
The fixed targets above resolve from each publisher's network. For different
destination addresses or target TLS trust, replace target with
targetBinding: "group" on the template service and supply its exact destination
when creating the concrete group. Bindings cannot override fixed targets or
choose a different protocol. Control encrypts bindings separately from the
declaration. See target bindings.
API creation accepts an idempotency key scoped to the team and project. Repeating the same normalized request returns the same active group; changed inputs or reuse after retirement conflict. Omitting the name generates a selector. Store the immutable group ID in your application's customer record rather than using the customer's name as a DNS label.
Connect using the logical service key and exact group:
tnl connect api --publisher-group customer-a
tnl connect orders --publisher-group customer-a
tnl status --scope=server --publisher-group customer-aThe connector uses the current project's source identity. Group names and IDs are selectors, not permission: the caller still needs current service access. The target application or database still enforces its own authentication.
apply rolls out to every derived group
Edit the targets, services, HTTP settings, or access declaration and run
tnl apply. Control reconciles all groups created from that template in the
same transaction. Existing groups and retained service IDs stay stable. Publishers
pull the new assignments; an offline publisher receives the current assignments
when it returns. Apply reports saved desired state, not successful application
startup on every machine. Inspect publisher observations with tnl status.
This updates tnl configuration, not your application binaries. A changed target can close affected publications; existing traffic is never replayed. Removing a service removes it from every derived group and revokes its access.
Adding a required bound service or changing a bound service's protocol needs
explicit replacement bindings for every affected group. Use the human-authorized
control.projects.configuration.apply(...) API: submit the declaration and
revision with group_bindings, containing complete replacement binding sets keyed
by immutable group ID. Omitted groups retain their saved bindings and approved
protocols. tnl apply currently has no replacement-binding input, and a scoped
provisioning grant cannot apply templates. Missing required bindings or endpoint
capacity failure rolls back the whole change. Target-only updates retain group
IDs, service IDs, hostnames, and reserved ports while closing the affected old runs.
Concrete groups need not be enumerated in Git. They remain until explicitly retired:
tnl publisher remove customer-aRetirement removes its services and revokes publisher identities and enrollments. Removing or converting a template with dependent groups is rejected. Retire its groups first. Derived group selector/namespace rename is not supported; keep the external customer label in your application when it needs to change.
Existing endpoint port inventory and project enrollment limits still apply; a project supports at most 1,024 active derived groups and 256 active enrolled publishers. Endpoint allocation can reject creation much sooner: every managed service reserves one port, including connector-only services. An ingress pool defaults to 16 ports and supports at most 256 configured ports, shared by its projects. Held and quarantined ports are unavailable for new services. Port exhaustion rejects creation atomically. See self-hosting.
For example, API and database services use two ports per customer group. If nothing else uses those ports, the default inventory fits eight groups; one 256-port pool fits 128. Configure additional pools to give the same team's fleet more room. New groups choose an enabled pool with room for all their services, preferring the primary pool, then pool IDs in alphabetical order. A group is never split across pools.
Control saves the chosen pool on the group; tnl status --scope=server --output=json
includes its ingress_pool_id. Private/public switches, target changes, and app
restarts retain that pool, hostname, and port. Stopping a publisher does not free
its saved services' ports. Adding services to an existing group needs free ports
in that group's original pool: expand that inventory rather than expecting the
group to move automatically. A failed fleet update changes none of its groups.
select groups independently in a backend
Approve the backend identity separately for provisioning and visiting. A
provisioning permission cannot authorize service traffic. Declare and apply a
workload for its selected services, then run tnl generate and
include the generated project types. They infer protocols from the declaration.
Also ship .tnl/generated/connections.json with the backend, at that path relative
to the process working directory. The client reads this runtime manifest to
validate the project, template, and protocol; types alone are not sufficient.
Keep one client for the application and acquire a handle for each request's selected customer group and service:
import { tnl } from "@tnldotdev/tnl";
const auth = tnl.auth.oidc({ token: getIdentityToken });
await using connections = tnl.client({
server: controlUrl,
auth,
workload: "backend",
});
await using api = await connections.connect({
group: customerRecord.groupId,
service: "api",
signal: requestSignal,
});
const response = await api.fetch("/health");getIdentityToken is your provider integration. It returns a current assertion,
not a developer login or a provisioning token. A registered signing key can be
used instead. The client resolves the group through the authority API without
manager credentials or an application-managed connection descriptor.
Generated types make api an HTTP handle; callers do not supply a protocol or
inspect the handle to find fetch. Runtime validation still checks the project,
template, and protocol. Dispose each request handle and close the client when the
application stops. Native process and loopback support are required.
These clients do not change global workload configuration, environment variables,
the working directory, or development tnl.services. Keep the existing framework
registration and generated metadata for ordinary development apps.
Workload sessions capture the selected group's exact service IDs. Creating a new group or adding a service does not expand an existing session. Fresh preparation establishes new authority within current policy; revocation, identity expiry, and configured hard session limits remain in effect. Regenerate project types and the runtime manifest together when protocols or service declarations change; target-only rollout needs no descriptor replacement. GitHub's existing one-job/one-scope execution binding still applies: one job cannot exchange the same execution for multiple independent group scopes. Deployed provider lifecycle qualification remains separate from local SDK and control tests.
provision groups from a backend
Register the backend's OIDC identity or signing key, then approve a provisioning grant for the exact project, templates, operations, and enrollment budget. The backend supplies that grant ID; control derives the team, project, and current revision. Verifying the same identity for visitor access does not approve provisioning automatically.
For a template whose api service uses targetBinding: "group":
const control = tnl.control({ server: controlUrl, auth, grant: provisioningGrantId });
const group = await control.publisherGroups.create({
from: "customer",
idempotency_key: customerRecord.provisioningKey,
bindings: { api: { target: "http://customer-api.internal:9080" } },
});
const enrollment = await control.publisherGroups.enrollments.create({
group_name: group.id,
max_members: 1,
expiresInSeconds: 600,
});Save group.id in the customer record. Deliver enrollment.enrollment_key
privately to its publisher, never in logs or browser metadata.
enrollment.publisher supplies the server, project, and canonical group
selector used by the existing CLI. Continue selecting the immutable group ID in
backend requests.
Python exposes the same generated management operations:
import tnl
auth = tnl.auth.oidc(token=get_identity_token)
bindings = tnl.models.PublisherGroupBindings()
bindings["api"] = tnl.models.PublisherGroupTargetBinding(
target="http://customer-api.internal:9080"
)
async with tnl.control(server=control_url, auth=auth, grant=provisioning_grant_id) as control:
group = await control.publisher_groups.create(
from_="customer",
idempotency_key=customer_record.provisioning_key,
bindings=bindings,
)
enrollment = await control.publisher_groups.enrollments.create(
group_name=group.id,
max_members=1,
expires_in_seconds=600,
)Inputs retain canonical snake_case names, except the enrollment lifetime shown above. Human control sessions can use broader management methods for identities, teams, domains, public URLs, and shares. A scoped provisioning grant cannot call those human-only operations or change project templates.
Each mutation checks current authority inside its transaction, including idempotent retries. Conflicts are returned to the caller; the SDK does not replay a mutation automatically. Read current state and decide explicitly whether to retry an update.
current client limits
A JavaScript backend process can normally keep 32 group contexts prepared at once. Other SDK contexts in that process share the allowance. Reuse the client and dispose request handles when their work ends; idle contexts can be reclaimed for later groups, while active handles stay isolated. If no context can be reclaimed, preparation fails instead of taking a sibling handle's context. This is not a way to keep thousands of group connections open at once.
A group can contain 128 services, but one workload can select only 32 of them. Project configuration files currently allow 32 service declarations; the control API accepts up to 128.
The separate limits of 64 do not count customer groups:
- One SDK context allows 64 distinct connection preparations in progress. Its
private connection to
tnlalso allows 64 pending requests. - One managed HTTP fetch helper has a budget of 64 active or queued request work items. This is not 64 requests per second or a promise of 64 simultaneous transfers; the current HTTP pool uses eight connections.
- One local connector listener serves 64 application connections by default. Extra accepted connections are closed until capacity is available.
- One
tnl.client(service, { create, close })factory caches up to 64 ordinary driver clients or pools for different prepared connections. That is not a limit on the connections inside each pool. Close the factory when finished.
The SDK limits are currently hardcoded, not public tuning settings. They are
implementation bounds, not fleet-capacity or throughput measurements.
Overlapping preparation or HTTP work beyond its budget fails with
sdk.connection_unavailable. A full driver-client factory fails with
sdk.client_capacity when asked for another distinct binding; it does not evict
an older client automatically. These are separate from server pool capacity.
apply and provision
tnl auth login --team shop
tnl check
tnl apply
tnl status --scope=server
tnl publisher provision --group production --instance replica-1 --output-file /private/tnl/replica-1.jsonChoose an existing private directory outside the repository. Provisioning writes a private file without overwriting another path. It retains the replica's signing key and exact control/team/project/group/member scope. Retrying the same instance recovers the saved replica identity, including after a selector rename; it does not create a new key each time. Each independent replica needs a different identity.
Control stores the public key. Signed assertions acquire short-lived group leases;
the portable JSON contains neither a person's control session nor a runtime lease.
Members pull only their group's assignments and acknowledge the exact revision.
A reconciled revision or an ECS RUNNING task does not prove target reachability:
check a native client through tnl connect NAME too.
tnl --no-config publisher serve --server https://control.example.com --project-key PROJECT_KEY --group production --instance replica-1 --identity-file /run/tnl/replica-1.json --identity-dir /identity --state-dir /stateFirst-time bootstrap can instead use tnl publisher enroll --group production
with TNL_GROUP_ENROLL_KEY or foreground --enroll-key -. The default is one
member and ten minutes; --members accepts 1–256 and --expires-in accepts whole
seconds from 1m to 24h. Replacement of an existing replica proves its saved
key; it needs no new enrollment or human session. Invalid or revoked identity
input fails without bootstrap fallback.
publisher runtime http calls
Each comma-separated action below names a separate endpoint:
| Method | Path |
|---|---|
| POST | /v1/publisher/runtimes/{publisher_identity_id}/{inspect,start,renew,stop} |
| POST | /v1/publisher/groups/{enroll,verify,lease,configuration-acknowledgements} |
| GET | /v1/publisher/groups/assignments |
| POST | /v1/publish-runs/{publish_run_id}/renew |
| POST | /v1/services/{service_id}/runs/{service_run_id}/renew |
Runtime actions and group verify and lease requests carry signed assertions
in the body. Their audience is the full endpoint URL, not just the control URL.
Runtime bodies include publisher_identity_id and action; both must match the
path.
Publisher access tokens (pta_) authorize publisher-access operations. Group
leases (pgl_) authorize assignment reads and configuration acknowledgements.
Run renewal requires the corresponding publish run or service run token. A
control session cannot replace these credentials.
run a fixed list of groups
One foreground worker can serve a fixed list of independently provisioned group identities, including groups with the same selector in different teams:
tnl --no-config publisher serve --identity-file /private/tnl/staging.json --identity-file /private/tnl/production.json --identity-dir /identity --state-dir /stateEach participating user or team authorizes and provisions its own group identity. The operator explicitly chooses that fixed list; serving it grants no shared human administrator or visitor role. Assignments, leases and signing identities remain bound to the individual identity's immutable group scope.
Supply 1–256 private identity files. Each file fixes its control origin, immutable
team/project/group/member and instance; do not add --server, --team, --group,
--project-key, enrollment or instance overrides, or TNL_GROUP_IDENTITY.
Run it under your supervisor; fixed-list mode rejects detach/install flags.
The worker validates local scope and duplicate publisher/replica ownership before contacting control. Each group keeps independent signing, leases, assignment reconciliation and certificate state beneath the durable identity root. A group's remote denial or reconciliation failure must not stop another group's serving work. Do not share that root with another active worker.
Optional repeated --binding-file inputs supply exact group-scoped AWS target
discovery and deployment/runtime provenance only; at most one may match each
identity and unmatched/duplicate bindings fail. Changing the fixed identity list
requires a supervised process restart, not a dynamic multi-customer enrollment
API. Preserve the whole identity root
across replacement. Qualify group-local stop/start, apply, revocation and process
restart with a real visitor before relying on this for staging or production.
separate durable identity from local state
| Directory | Ownership |
|---|---|
/identity | Replica signing identity, TLS private keys, pending CSRs, staged/current certificates, renewal metadata, and writer lock. Preserve it. |
/state | Task-local SQLite, its wrapping key, events, and caches. It may be rebuilt. |
The local default is identities beneath the client state directory. Remote tasks
can select --identity-dir /identity. Atomic file replacement, sync, and advisory
locking must work on that filesystem. One replica holds its writer lock for its
lifetime; independent replicas must not share it. Losing recoverable TLS material
after issuance starts fails closed rather than silently replacing keys. Control
does not retain publisher TLS private keys.
For AWS, run publishers with your own infrastructure tools:
- One nonoverlapping ECS service per replica: desired count zero or one, minimum healthy percent zero, maximum percent 100, and availability-zone rebalancing off.
- Store the portable JSON in a replica-private Secrets Manager secret and pin its
exact version ID. ECS injects
TNL_GROUP_IDENTITYon every launch, including replacements. tnl callsUnsetenvbefore starting children so they do not inherit it. That is not secure erasure of process memory or platform environment storage. - Mount an encrypted replica-private EFS access point at
/identity; retain and back it up. Keep SQLite/WAL on task-local/state, never EFS or NFS. - Permit outbound DNS, control HTTPS, relay TCP/UDP, target traffic, EFS, and normal image/secret/log access. Publishers need no inbound public listener.
An exported signing credential alone cannot recover already-issued TLS keys. Preserve the complete identity directory. Another independently provisioned replica can serve new visitors during replacement; existing streams are not replayed.
rename a selector or a DNS namespace
tnl publisher rename production liveThis changes the configuration selector, not the group ID, service IDs, DNS namespace, ports, grants, or replica keys. Update the publisher key and service references in Git before the next apply. Historical selectors remain tied to that group; old exported credentials still verify against its immutable identity. Installed/detached jobs use the same identity rather than enrolling another member.
To change the actual managed DNS namespace:
tnl status --scope=server
tnl publisher namespace rename live primary --expected-revision 4 --request-id namespace-primary-1Use the actual applied revision. After a lost reply, retry the exact request ID,
revision, and arguments. Namespace rename keeps IDs, grants, ports, and replica
signing identities, but closes old runs and replaces certificate/publication plans.
Wait for publishers to reconcile DNS and certificates, then rerun tnl connect
and update clients with the new hostname. Independently declared public URLs keep
their own hostname scope. Retired DNS claims cannot be reused until certificate,
issuance, DNS cleanup, and quarantine conditions permit it.
revoke and observe
Running remote publishers automatically report their control-observed public egress address through their authenticated runtime lease. Inspect the result:
tnl status --scope=server
tnl publisher list --team shopNamed groups report once per running replica, not once per assigned service. Non-group installed publishers and credential-scoped foreground publishers also report after starting their runtime. Ordinary app-owned local publishers do not opt in. Provisioning, enrollment, importing a key, and verifying a portable identity do not report the operator machine's IP.
Control observes the connection's source address; publishers do not claim an IP
or contact an IP lookup service. Behind the configured control proxy, only the
validated, overwritten source header is used. Each process has a run ID and
fenced epoch: a replacement cannot let its old process continue renewing the
record. The two-minute lease and current identity/key/scope authority determine
active. Stop, crash expiry, or revocation leaves last-seen evidence marked
offline. An address is absent before a public source is observed; private and
non-routable sources report address_family: unknown and clear an older public
address. Only the IPv4 or IPv6 family actually seen is reported.
These are team-scoped management records, not browser project metadata or visitor grants. Group status requires a current team manager; publisher listing also allows a current member to inspect their own member-owned publisher under its existing management permission. The source IP a database sees may differ because of destination routing, NAT, a proxy, or address-family choice. An active record does not promise a static IP or successful target traffic. Database-provider allowlist synchronization is not implemented.
Stopping infrastructure does not revoke a publisher. Stop a replica, wait for its task to finish, revoke its member through control, and provision a new instance when replacing its identity. Update the pinned secret version before restarting. Removing an applied group revokes its enrollments, members, and publication grants.
Team-owned unattended publishers survive the departure of their creator. Member-owned credentials and runs stop when that member loses access. These are explicit ownership boundaries, not a side effect of who last applied the config. Use server status, real client traffic, and service audit to inspect the result.