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.

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

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

Each 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-a

The 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-a

Retirement 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 tnl also 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.json

Choose 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 /state

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

MethodPath
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 /state

Each 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

DirectoryOwnership
/identityReplica signing identity, TLS private keys, pending CSRs, staged/current certificates, renewal metadata, and writer lock. Preserve it.
/stateTask-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_IDENTITY on every launch, including replacements. tnl calls Unsetenv before 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 live

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

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

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