Kubernetes manifests as TypeScript components. Write TSX, get production-grade YAML.
Stop writing YAML. Start composing infrastructure.
// k8s/r8s.tsx
import { App } from '@r8s/recipes'
export default () => (
<App name="api" image="myapp/api:v1.2.3" host="api.example.com" />
)$ npx r8s render
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
---
apiVersion: v1
kind: Service
metadata:
name: api
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-endpoint
# 3 resources from 1 componentr8s is a compiler: TSX source in, Kubernetes YAML out. No runtime, no cluster state, no reconciliation loop — the output is plain YAML you commit and sync with Flux/ArgoCD (or kubectl apply). Same artifacts Helm or Kustomize would give you, minus the templating language. Typed props mean misspelled containerPort is caught by TypeScript before kubectl — which also makes r8s an unusually safe target for AI-generated infrastructure: hallucinated config fails to compile. Components are ordinary functions, so ten microservices share one <Database /> import instead of ten copies of hand-tuned YAML.
npx r8s init my-infra # scaffold a project (TS + GitHub Actions workflow)
npx r8s render --out k8s/manifest.yaml
npx r8s explain App # what resources + operators does a component create?
npx r8s validate k8s/r8s.tsx # type-check + reference-check rendered output
npx r8s list # the full component catalog
npx r8s context # compact context blob, made for LLM prompts
npx r8s flux k8s/r8s.tsx --out . --name my-stack # two-Kustomization Flux recipeProviders nest around your apps and decide how everything below them is wired — certificates, secrets, object storage, DNS, routing — and each child automatically consumes the whole stack:
import { CertProvider, SecretProvider, OpenBao, S3Provider, MinIO, DnsProvider, EndpointProvider, EnvoyGateway, Namespace, App } from '@r8s/recipes'
import { Supabase } from '@r8s/supabase'
export default (
<CertProvider provider="cert-manager">
<SecretProvider provider={<OpenBao mount="secret" path="infra" />}>
<S3Provider provider={<MinIO endpoint="https://s3.example.com" bucket="infra" credentialsSecret="infra-s3-creds" />}>
<DnsProvider provider="external-dns">
<EndpointProvider provider={<EnvoyGateway tls={{ clusterIssuer: 'letsencrypt' }} />}>
<Namespace name="apps">
<App name="web" image="myorg/web:v2" host="app.example.com" />
{ /* Supabase: backups, storage, credentials all derive from the S3Provider above */ }
<Supabase name="supabase" host="db.example.com" />
</Namespace>
</EndpointProvider>
</DnsProvider>
</S3Provider>
</SecretProvider>
</CertProvider>
)One render → all Deployments, Services, Gateway listeners + HTTPRoutes, cert-manager wiring, DNSEndpoint records, OpenBao-provisioned Secrets, CNPG clusters with scheduled backups, and the Namespace resource itself — plus the pinned install manifests for every operator involved, emitted by r8s operators. Swap EnvoyGateway for Nginx on the EndpointProvider line and every endpoint reroutes; nothing below changes.
Complete real-world applications as packages — pinned versions, derived from our own production manifests and regression-tested against that reference output:
| Package | Stack |
|---|---|
@r8s/n8n |
Workflow automation |
@r8s/open-webui |
Model frontend |
@r8s/librechat |
Multi-provider chat UI |
@r8s/eneo |
AI platform |
@r8s/paperclip |
Agent orchestration |
@r8s/forgejo |
Git forge (Actions runners, LFS on S3) |
@r8s/chromadb |
Vector database (Chroma) |
@r8s/supabase |
Postgres backend-as-a-service |
@r8s/outline |
Knowledge base |
@r8s/nextcloud |
File sync & groupware |
@r8s/eurooffice |
Browser office suite |
@r8s/matrix |
Full Matrix stack + SFU |
@r8s/element |
Matrix web client |
@r8s/odoo |
Odoo ERP |
@r8s/harbor |
OCI container registry |
@r8s/rustfs |
S3-compatible object store |
@r8s/netbird |
Mesh VPN (Netbird) |
@r8s/umami |
Analytics |
@r8s/grafana |
Metrics & dashboards |
@r8s/superset |
BI dashboards |
Core primitives (@r8s/recipes): <Platform>, <Namespace> (cluster partitioning), <App>, <Database> (CNPG PostgreSQL), <Endpoint>, <WebService>, <Auth> (Keycloak), <Monitoring>, <Backup> (Velero), <StaticSecret>, and the providers shown in the example above.
Secrets via capability hooks — not identity switches. One lookup at the top of recipes; everything below speaks provision(req):
import { jsx } from '@r8s/core'
const myProvider = {
provision(req: StaticSecretRequest) {
// Any element with apiVersion + kind renders as a raw resource —
// here an External Secrets Operator target.
return jsx('ExternalSecret', {
apiVersion: 'external-secrets.io/v1beta1',
kind: 'ExternalSecret',
metadata: { name: req.name, namespace: req.namespace },
spec: {
refreshInterval: '1h',
secretStoreRef: { name: 'cluster-store', kind: 'ClusterSecretStore' },
data: Object.entries(req.keys).map(([dest, source]) => ({
secretKey: dest,
remoteRef: { key: `${req.path}/${source}`, property: source },
})),
},
})
},
}
// Every recipe in every package consumes your provider without knowing what it is.Same seam for routing: RoutingConfig.route lets you emit IngressRoute/HTTPRoute/whatever while <Endpoint> keeps the contract.
Plaintext credentials don't compile. Every render scans Secrets, ConfigMaps, env vars, and nested CRD fields for credential values — and understands values vs references (secretKeyRef, $(VAR), existingSecret*, _FROM_FILE are fine). Hard fail in CI with a suggested fix. Also bundled: network policies, resource limits, required labels, TLS on Ingress, no-root-containers.
Operators are explicit dependencies. Declaring components, deduplicated at render, versions pinned in operators.yaml, install manifests via r8s operators. Also: useOperators() + declareCnpg(shared) (from @r8s/operator-cnpg) for "declare only if the Platform doesn't already run it."
import { render } from '@r8s/core'
import { App } from '@r8s/recipes'
it('creates a 3-replica deployment', () => {
const result = render(<App name="api" image="myapp/api:v1" host="api.example.com" replicas={3} />)
expect(result.resources.find((r) => r.kind === 'Deployment').spec.replicas).toBe(3)
})The repo runs ~1,050 such tests across 64 suites — including a provider-matrix suite rendering every package under every secrets-backend × routing-mode combination.
Two strategies, scaffolded by r8s init:
github-actions(default): CI renders TSX → committed YAML → Flux/ArgoCD syncs. Reviewable diffs.flux-controller: push TSX; render in-cluster via the source controller. No CI build step.
The stateless promise as an artifact: r8s flux <entry.tsx> --out <dir> emits the full install recipe — operators first (HelmRepository + HelmRelease per declared operator), then the stack resources, wired as two Flux Kustomizations with dependsOn, wait and workload healthChecks:
npx r8s flux k8s/r8s.tsx --out . --name my-stackstacks/my-stack/operators/ # HelmRepository + HelmRelease per declared operator
stacks/my-stack/stack/ # your rendered resources
clusters/default/stacks/ # the two Kustomization CRs (operators → stack)
Commit it, push, and point a fresh Flux-bootstrapped cluster at the repo:
kubectl apply -f clusters/default/stacks/my-stack.yamlFlux installs every operator, waits until each is Ready (CRDs included), then reconciles the stack — no CI, no manual kubectl ordering, no cluster state beyond what Flux holds.
A full catalog means ~20 package stacks — and per-stack operators layers collide: the same operator (CNPG, redis, paperclip) would render the same HelmRelease names in several layers, fighting over prune ownership. Emit one shared operators stack, then point every package stack at it:
# Once: a synthetic entry declaring every shared operator, no resources
npx r8s flux operators.tsx --out . --name catalog --operators-only
# Each package stack: resources only, depending on the shared layer
npx r8s flux cnpg.tsx --out . --name cnpg --shared-operators catalog-operators
npx r8s flux redis.tsx --out . --name redis --shared-operators catalog-operators--operators-only emits stacks/catalog/operators/ plus a single catalog-operators Kustomization CR; --shared-operators emits just stacks/<name>/stack/ plus a single stack CR whose dependsOn references it (name + namespace made explicit). Emit the shared layer and the package stacks with the same --source-namespace (default flux-system) — Flux resolves dependsOn among Kustomizations in that namespace. Flux reconciles the operators once, and every package stack only after the shared layer is Ready. Without either flag, r8s flux emits the full per-stack pair as before.
| Raw YAML | Helm | Kustomize | Pulumi | r8s | |
|---|---|---|---|---|---|
| Composition | ❌ copy-paste | ❌ patches | ✅ code | ✅ typed components | |
| Type safety | ❌ | ❌ | ❌ | ✅ | ✅ full TS |
| Operator tracking | ❌ | ❌ | ✅ | ✅ explicit + renderable | |
| Credential linting | ❌ | ❌ | ❌ | ✅ built-in guardrails | |
| Learning curve | low | medium | low | high | low |
| Output | YAML | YAML | YAML | API | YAML (GitOps-native) |
v0.3.1 — core, CLI and the recipes are stable and in daily production use at Berget AI. 0.3.1 ships <Namespace> (composable cluster partitioning) and turns database backups on by default whenever an <S3Provider> is in scope. The 0.3.0 line switched operators to npm-resolved packages (one @r8s/operator-* per operator, version-mirrored, peer-contract deduplicated). Migrations: CHANGELOG.md.
Missing a component? It's a typed function + (optionally) a line in operators.yaml. Copy @r8s/netbird as a skeleton, add tests, open a PR — AI review + CI within minutes, human review within 24h. Details: CONTRIBUTING.md · Code of Conduct · SECURITY.md
MIT — © Berget AI AB