The single source of truth for how the builder deploys your service. Every deploy reads this file. The builder generates Dockerfiles, provisions databases, configures OAuth, registers routes, and sets up DNS — all from this one file.
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: my-svc
description: My API service
annotations:
insureco.io/framework: express
spec:
type: service
lifecycle: production
owner: my-org # your org slug from Bio-ID JWT
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: my-svc
annotations:
insureco.io/framework: express # REQUIRED
insureco.io/catalog-version: "0.5.0" # required for spec.tests
insureco.io/pod-tier: nano # nano|small|medium|large|xlarge
insureco.io/health-endpoint: /health # REQUIRED (0.2.0+)
spec:
type: service
lifecycle: production
owner: my-org # REQUIRED (0.2.0+), not 'unknown'
routes:
- path: /api/my-svc/data
methods: [GET, POST]
auth: required
gas: 1
charge: service # who pays gas — service (the service's own org pays) is what applies when omitted; set to callerOrg to bill the caller instead
databases:
- type: mongodb # → MONGODB_URI
- type: redis # → REDIS_URL
# Core platform services are auto-injected on every deploy — do NOT declare them:
# BIO_ID_URL, KOKO_URL, JANUS_URL, IEC_WALLET_URL, SEPTOR_URL, IEC_QUEUE_URL
# For septor and iec-queue the builder also auto-applies their NetworkPolicy
# direct-dep label every deploy, so those calls connect with no declaration.
#
# Declare only the NON-platform services you call (relay, docman, …):
dependencies:
- service: docman # → DOCMAN_URL + DOCMAN_CHARGE_MODE
charge: service # default — your org pays. Use callerOrg to bill the caller's org (forward their JWT as X-Forward-User)
tests:
smoke:
- path: /health
expect: 200
Set insureco.io/catalog-version to the highest version whose features you use. Each version is additive — newer versions include all earlier features.
| Version | Adds |
|---|---|
0.6.0 | spec.userAccess.roles (user-facing roles, shape { id, label, orgs: [] } where id matches namespace:name or namespace_name); spec.registration (storefront metadata: displayName, shortDescription, publisher (must equal spec.owner), icon?, category?, modules?); spec.auth.passport |
0.5.0 | spec.tests; spec.userAccess |
0.4.0 | spec.dependencies (with scopes + transport) |
0.3.0 | spec.storage |
| Framework | Start Command | Port | Health |
|---|---|---|---|
express | npm start | 3000 | /health |
nextjs | npm start | 3000 | /api/health |
hono | npm start | 3000 | /health |
fastify | npm start | 3000 | /health |
worker | node dist/worker.js | 3000 | none |
static | nginx | 8080 | /health |
The default start command is
npm startfor all Node frameworks — define astartscript inpackage.json(e.g.node dist/index.js). Override per-service withinsureco.io/start-command.
spec.auth)To enable Bio-ID login (user-facing OAuth) for your service, declare spec.auth in your catalog:
spec:
auth:
mode: sso # provisions BIO_CLIENT_ID and BIO_CLIENT_SECRET on every deploy
| Mode | What it does | When to use |
|---|---|---|
sso | User-facing OAuth. Provisions BIO_CLIENT_ID, BIO_CLIENT_SECRET, registers /api/auth/callback | Services with a login flow |
service-only | Client credentials only. No redirect URI registered | Service-to-service, no user login |
none | No OAuth client provisioned | Default. Builder skips OAuth entirely |
If you don't declare spec.auth, no OAuth credentials are injected. Your pod will not have BIO_CLIENT_ID or BIO_CLIENT_SECRET, and any code that calls BioAuth.fromEnv() will throw.
BIO_ID_URL is always injected by the builder as a core platform variable — no declaration needed.
Common mistake: Adding
bio-idtointernalDependenciesdoes NOT enable OAuth. It only injectsBIO_ID_URL(which is already injected). Usespec.auth: mode: ssoinstead.
See Authentication for the full login implementation guide.
Enrich the JWT with org branding, iecHash, and permissions at mint time by adding passport under spec.auth:
spec:
auth:
mode: sso
passport:
includeBranding: true # fetch vault branding and include in JWT (default: false)
includeIecHash: false # include entity iecHash in JWT (default: false)
headless: true # enable /api/embed/* endpoints (default: false)
allowedEmbedOrigins: # CORS origins for browser-SDK embed calls (optional)
- https://my-service.tawa.pro
| Field | Type | Default | Effect |
|---|---|---|---|
includeBranding | boolean | false | Bio-ID fetches vault branding at mint time (fail-open, 3s timeout) and bundles it into the JWT branding claim |
includeIecHash | boolean | false | Includes the entity's iecHash (chain address) in the JWT |
headless | boolean | false | Enables /api/embed/* endpoints (login, signup, magic-link, provision-org) for this client. Required for EmbedClient.* calls from the @insureco/bio SDK |
allowedEmbedOrigins | string[] | [] | CORS origins allowed to call /api/embed/* with this client's credentials. Only needed when calling from a browser SDK — server-to-server calls using X-Client-Id/X-Client-Secret headers don't require this |
On deploy, the builder writes includeBranding/includeIecHash into the OAuth client's passportConfig, and writes headless/allowedEmbedOrigins as top-level headlessEnabled/allowedEmbedOrigins fields on the same client. Services read req.user.branding from the decoded JWT — no additional API calls needed.
Migration note: The shorthand
branding: trueis still accepted and maps toincludeBranding: true. The explicit form is preferred for new services.
See Passport & Branding for the full branding claim shape and consumption patterns.
There are two kinds of service connectivity, and you only declare one of them.
The builder injects these URLs into every pod on every deploy. You do not list them anywhere — if you declare one, the builder warns and ignores the entry.
| Env var | Service | Notes |
|---|---|---|
BIO_ID_URL | Bio-ID (https://bio.tawa.pro) | Identity / OAuth — always production |
KOKO_URL | koko | Service registry |
JANUS_URL | janus | Gateway / proxy |
IEC_WALLET_URL | iec-wallet | Gas / wallet |
SEPTOR_URL | septor | Audit trail |
IEC_QUEUE_URL | iec-queue | Job queue |
These are injected as direct cluster URLs (http://{svc}.{svc}-{env}.svc.cluster.local:{port}), not Janus /i/ URLs. For septor and iec-queue the builder also auto-applies their NetworkPolicy direct-dep label on every deploy, so calls to them connect without any declaration. (SEPTOR_URL, IEC_QUEUE_URL, etc. are free — no gas, no scopes.)
spec.dependenciesFor any other service you call (relay, docman, raterspot, another team's API…), declare it in a single spec.dependencies array. transport defaults to janus, and scopes are optional — so a bare entry is all most services need.
spec:
dependencies:
# Bare entry — routes through Janus, injects DOCMAN_URL + DOCMAN_CHARGE_MODE.
- service: docman # registered (Koko) name
charge: service # optional — 'service' (your org pays) when omitted
# Scoped entry — add scopes only when the target defines Bio-ID scope grants.
- service: raterspot
scopes: [raterspot:rate] # optional
charge: callerOrg # optional
# transport omitted → defaults to janus
| Field | Required | Default | Meaning |
|---|---|---|---|
service | yes | — | The registered Koko service name (e.g. relay, not iec-relay) |
scopes | no | [] | Bio-ID scopes to request — only when the target defines them (see Scopes) |
transport | no | janus | How {SERVICE}_URL is routed (see below) |
charge | no | service | Who pays gas: service (your org) or callerOrg (the caller's org) |
transport | Injects {SERVICE}_URL as | Use for |
|---|---|---|
janus (default) | {JANUS_URL}/i/{service} (Janus proxy) | Almost everything — JWT-verified and gas-metered |
direct | raw K8s DNS http://{svc}.{svc}-{env}.svc.cluster.local:{port} | Same-org own-service UI/API splits only. Cross-org is silently forced back through Janus. Also applies the target's direct-dep NetworkPolicy label |
gateway | (nothing — no URL is injected) | The consumer calls the Janus public gateway directly; no URL env var needed |
Scopes are optional. Declare them only when the target service defines Bio-ID scope grants (e.g. relay:send, raterspot:rate). A scoped dependency does two extra things beyond injecting the URL:
BIO_CLIENT_ID / BIO_CLIENT_SECRET are injected (this is how SDKs like @insureco/relay authenticate).A dependency with no scopes skips both — it just gets its {SERVICE}_URL injected.
For each declared dependency (that isn't a gateway transport and isn't an auto-injected platform service), the builder injects two env vars:
| Declared | Env var | Example value |
|---|---|---|
service: docman (default janus) | DOCMAN_URL | http://janus.janus-prod.svc.cluster.local:3000/i/docman |
service: my-api + transport: direct (same-org) | MY_API_URL | http://my-api.my-api-prod.svc.cluster.local:3000 |
charge: callerOrg | DOCMAN_CHARGE_MODE | callerOrg |
charge: service (or omitted) | DOCMAN_CHARGE_MODE | service |
Service name → env var name: uppercase, hyphens become underscores. iec-wallet → IEC_WALLET_URL.
internalDependencies and dependenciesYou may declare both blocks in the same catalog — the builder merges them into one resolved list. A service that appears in both is deduped by name, and the scoped dependencies entry wins.
spec:
internalDependencies:
- service: legacy-api # → LEGACY_API_URL (Janus proxy), no scopes
charge: service
dependencies:
- service: docman
transport: janus
scopes: [docman:generate]
charge: service
# Both legacy-api AND docman get their *_URL injected.
Deprecated:
internalDependencies/externalDependenciesare legacy. Whenever either appears, the builder emits a deprecation warning intawa logsand merges them intodependenciesanyway. New services should put everything under a singledependenciesarray. The mapping: aninternalDependenciesentry becomestransport: janus(ordirectif it settransport: direct) with empty scopes; anexternalDependenciesentry becomestransport: gateway. Becausetransportnow defaults tojanusand scopes are optional, a baredependenciesentry behaves exactly like the oldinternalDependenciesentry — no workaround needed.Historical bug (fixed): Before the merge fix, declaring
dependencieson a 0.4.0+ catalog silently dropped everyinternalDependenciesentry — no*_URLwas injected for them. If a service relied onrelay/other internal deps while also using scopeddependencies, itsRELAY_URL(etc.) was missing. See iec-builder#11.
Common mistakes:
transport: gateway expecting a URL env var — gateway does not inject a URLscopes are required on dependencies — they are optional; omit them unless the target defines Bio-ID scope grantshttp://docman.docman-prod.svc.cluster.local:3000) in env vars — blocked by NetworkPolicy, will fail with connection refusedroutes:
- path: /api/my-svc/screen
methods: [POST]
auth: required # required | optional | none | service | public
gas: 5 # tokens per successful call. 0=free. omit=default(1)
charge: service # service applies when omitted; the override is callerOrg
| Value | Who can call | Notes |
|---|---|---|
required | Any authenticated user | Janus verifies user JWT |
optional | Anyone | JWT verified and forwarded if present, but not required |
service | Service-to-service only | Requires service credentials JWT |
public | Anyone | No auth, but still metered |
none | Anyone | No auth, not metered — use for health checks |
charge values| Value | Who pays gas | When to use |
|---|---|---|
service | Default. The service owner's org pays | Subsidised/freemium routes, internal tooling, and the default for everything |
callerOrg | Override. The calling user's org (orgSlug from their JWT) pays — requires forwarding the user's JWT as X-Forward-User | User-facing APIs where the caller's org should absorb the gas cost |
spec:
queues:
- name: process-claim
endpoint: /internal/jobs/process-claim
concurrency: 5
retries: 3
retryDelayMs: 5000
timeoutMs: 30000
schedules:
- name: nightly-sync
cron: "0 2 * * *"
endpoint: /internal/cron/nightly-sync
timezone: America/Denver
timeoutMs: 60000
Queue and schedule endpoints must NOT be listed under routes:. They are internal-only.
iec-cron and iec-queue call your service through Janus using platform service credentials. Your /internal/* endpoints are protected — only platform services can reach them.
Cron callbacks (/internal/cron/*):
| Header | Value |
|---|---|
Authorization | Bearer {platform-service-jwt} |
X-Schedule-Name | Schedule name from catalog-info.yaml |
X-Cron-Expression | The cron pattern that fired |
X-Fired-At | ISO 8601 timestamp |
Content-Type | application/json |
Body: { scheduleName, namespace, firedAt }. Both req.headers['x-schedule-name'] and
req.body.scheduleName carry the schedule name — prefer the header for consistency.
Queue callbacks (/internal/jobs/*):
| Header | Value |
|---|---|
Authorization | Bearer {platform-service-jwt} |
X-Queue-Name | Queue name from catalog-info.yaml |
X-Job-Id | Unique job identifier |
X-Job-Attempt | Attempt number (1-indexed) |
Content-Type | application/json |
Body: { jobId, queue, attempt, data }.
| Trigger | Production | Sandbox |
|---|---|---|
| Cron schedule fires | 5 tokens (charged upfront) | 0 tokens (free) |
| Queue job executed | 0 tokens (platform cost) | 0 tokens (free) |
Cron gas is charged to the namespace owner's wallet when the schedule fires — before your endpoint is even called. Queue execution carries no gas cost because the charge was already applied when the user triggered the enqueue via a public API route.
annotations:
insureco.io/framework: express # REQUIRED
insureco.io/catalog-version: "0.5.0" # required for spec.tests
insureco.io/node-version: "20" # default: 20
insureco.io/pod-tier: nano # default: nano
insureco.io/health-endpoint: /health # REQUIRED (0.2.0+)
insureco.io/port: "3000" # default: 3000
insureco.io/build-command: npm run build
insureco.io/start-command: node dist/index.js
insureco.io/output-dir: dist
insureco.io/openapi: openapi.yaml # PLANNED — not yet read by the builder; auto-generated UI tile is upcoming
insureco.io/copy-paths: "dist,data,openapi.yaml" # extra paths to copy into runtime image
copy-paths — Including Extra Files in the Runtime ImageIf your service needs files beyond dist/ at runtime (seed data, static assets, config files), list them with copy-paths instead of writing a custom Dockerfile:
annotations:
insureco.io/copy-paths: "dist,data,openapi.yaml"
openapi.yaml) and directories (data/)Common use cases:
| Need | copy-paths value |
|---|---|
| TypeScript output + seed data | "dist,data" |
| TypeScript output + OpenAPI spec | "dist,openapi.yaml" |
| Built UI assets alongside API | "dist,ui/dist" |
| Source + pre-built dashboard | "api,dash/dist" |
You do NOT need a custom Dockerfile just to include extra files — use
copy-paths.
spec.egress — Outbound IP RoutingServices that call external APIs with fixed IP allowlists (e.g. payment processors, banking partners) can join an egress group so their outbound HTTPS traffic is routed through a dedicated VPC proxy.
spec:
egress:
group: payments # name of the egress group (created by a platform admin)
On deploy, the builder:
HTTPS_PROXY=http://<proxyIp>:<proxyPort> and HTTP_PROXY=http://<proxyIp>:<proxyPort> into the pod's env vars.services list so the bound DO Cloud Firewall allows its traffic.If the group does not exist yet, the builder logs a warning and continues — proxy vars are not injected:
[warn] Egress group 'payments' not found — HTTP_PROXY/HTTPS_PROXY will not be injected
The default proxy port is 3128 if the group does not specify one.
Egress groups are created and managed by platform admins in the tawa-web Console or via the builder API (
POST /egress/groups). Contact your platform admin to have a group created before you deploy.
NO_PROXY after adding spec.egressHTTP_PROXY is set in addition to HTTPS_PROXY. Node.js native fetch (undici) respects HTTP_PROXY, which means all outbound HTTP traffic — including internal K8s calls to Janus, relay, septor, wallet — is routed through the external egress proxy. The egress proxy cannot resolve .svc.cluster.local hostnames, so all platform SDK calls silently fail with NETWORK_ERROR.
Always set NO_PROXY immediately after adding spec.egress:
# IMPORTANT: Do NOT include a leading dot (e.g. .svc.cluster.local).
# Node.js undici checks hostname.endsWith('.' + entry). A leading dot creates a
# double-dot match (endsWith('..svc.cluster.local')) that never passes.
# Use cluster.local without a leading dot to match all K8s-internal hostnames.
tawa config set NO_PROXY="cluster.local,127.0.0.1,localhost"
tawa deploy --prod
Symptom if NO_PROXY is missing or uses a leading dot: all relay, docman, septor calls return fetch failed / NETWORK_ERROR with statusCode: 0.
spec.analytics — Matomo Tracking (frontend only)Auto-provisions a per-environment Matomo site and injects tracking config. Only acts on nextjs / static frameworks.
spec:
analytics:
enabled: true
On deploy (frontend services only), the builder finds-or-creates a Matomo site for the service's environment hostname and injects two runtime env vars:
| Var | Example | Notes |
|---|---|---|
MATOMO_URL | https://engage.insureco.io | Matomo instance base URL |
MATOMO_SITE_ID | 218 | Provisioned site ID (public by design) |
Add the @insureco/analytics SDK's <MatomoAnalytics /> to your root layout (a Server Component) and you're tracking. The site ID is read server-side at request time — not as a NEXT_PUBLIC_* build-time var — so it survives the build → deploy boundary. Each environment gets its own site; traffic never mixes. The Matomo admin token lives only on the builder and is never injected into pods.
Backend frameworks (express/hono/fastify/worker) that declare spec.analytics get a build warning and no injection.
Full guide: analytics.md.
spec.owner — Immutable After Registrationspec.owner is your organization slug (e.g. acme-corp). It is set once when you first deploy and cannot be changed afterwards.
The builder enforces this with an owner tamper guard: if spec.owner in your catalog-info.yaml does not match the registered owner of the service in the platform, the deploy is rejected:
Deploy gate: catalog spec.owner "attacker-org" does not match the registered service owner "acme-corp".
Why this matters: the deploy gate charges hosting costs to the wallet of the org in spec.owner. Without this check, a bad actor could change spec.owner to another org's slug and charge deploys to their wallet.
If you legitimately need to transfer ownership of a service (e.g., rebrand or org merge), contact the platform team — this requires an admin operation, not a YAML edit.
metadata.name becomes your hostname: my-svc.tawa.insureco.ionano — start here, upgrade when you have data{SERVICE}_URL vars (default transport: janus) point at Janus /i/{service} — never hardcode K8s DNS URLs for thoseBIO_CLIENT_ID / BIO_CLIENT_SECRET injected, you must declare spec.auth: mode: sso (or a scoped dependencies entry, which also provisions client-credentials)BIO_ID_URL, JANUS_URL, KOKO_URL, IEC_WALLET_URL, SEPTOR_URL, and IEC_QUEUE_URL are auto-injected on every deploy (as direct cluster URLs) — do not declare themseptor and iec-queue are auto-labeled — as of iec-builder 9e0a2d0 (2026-06-22) the builder applies their tawa.pro/direct-dep.{svc}=true NetworkPolicy label on every deploy (ALWAYS_DIRECT_DEP_SERVICES), so audit/queue calls connect with no declaration needed. (Older services deployed before that date pick up the label on their next deploy.)RELAY_URL is not auto-injected — declare relay under dependencies with scopes: [relay:send] and transport: janus (this provisions both RELAY_URL and the BIO_CLIENT_ID/BIO_CLIENT_SECRET the SDK needs; metered per send, charge defaults to service)service — the service owner's org pays unless you explicitly set charge: callerOrgNODE_ENV is always set to production by the builder for all deployments (including sandbox) — do not set it yourselfspec.env is not parsed — hardcoded env vars belong in tawa config set KEY=VALUE, not catalog-info.yamlLast updated: July 15, 2026