cdk8s (CDK for Kubernetes) applies the CDK construct model to Kubernetes configuration. Instead of writing 300 lines of YAML for a deployment + service + HPA + NetworkPolicy, you write 50 lines of TypeScript and run cdk8s synth to produce the YAML. Same construct library model, same testing approach — just a different target format.
Setup
npm install -g cdk8s-cli
cdk8s init typescript-app
# Or add to an existing project
npm install cdk8s cdk8s-plus-31 # plus-31 = Kubernetes 1.31 API
my-k8s-config/
├── main.ts # entry point
├── dist/ # generated YAML
├── cdk8s.yaml # config
└── package.json
A First Chart
// main.ts
import { App, Chart, ApiObjectMetadata } from 'cdk8s'
import * as kplus from 'cdk8s-plus-31'
import { Construct } from 'constructs'
interface WebAppChartProps {
namespace: string
image: string
replicas: number
tag: string
}
class WebAppChart extends Chart {
constructor(scope: Construct, id: string, props: WebAppChartProps) {
super(scope, id, { namespace: props.namespace })
const labels = { app: 'web', version: props.tag }
const deployment = new kplus.Deployment(this, 'Deployment', {
replicas: props.replicas,
metadata: { labels },
select: false,
podMetadata: { labels },
containers: [{
name: 'web',
image: `${props.image}:${props.tag}`,
ports: [{ number: 8080 }],
resources: {
cpu: { request: kplus.Cpu.millis(100), limit: kplus.Cpu.millis(500) },
memory: {
request: kplus.Size.mebibytes(128),
limit: kplus.Size.mebibytes(512),
},
},
readiness: kplus.Probe.fromHttpGet('/healthz', { port: 8080 }),
liveness: kplus.Probe.fromHttpGet('/healthz', { port: 8080 }),
}],
})
const service = deployment.exposeViaService({
name: 'web',
serviceType: kplus.ServiceType.CLUSTER_IP,
ports: [{ port: 80, targetPort: 8080 }],
})
new kplus.Ingress(this, 'Ingress', {
rules: [{
host: `web.${props.namespace}.acme.io`,
backend: kplus.IngressBackend.fromService(service),
}],
})
new kplus.HorizontalPodAutoscaler(this, 'HPA', {
target: deployment,
minReplicas: props.replicas,
maxReplicas: props.replicas * 5,
metrics: [kplus.Metric.resourceCpu(kplus.MetricTarget.averageUtilization(60))],
})
}
}
const app = new App()
new WebAppChart(app, 'web', {
namespace: 'production',
image: 'ghcr.io/acme/web',
replicas: 3,
tag: process.env.IMAGE_TAG ?? 'latest',
})
app.synth()
Synthesise
cdk8s synth
# Produces: dist/web.k8s.yaml
# One file per Chart; apply with:
kubectl apply -f dist/
The output is standard Kubernetes YAML — no runtime dependency on cdk8s. Any tool that handles YAML (kubectl, Argo CD, Flux, Helm's helm template pipeline) can consume it.
Type Safety in Action
The biggest win over raw YAML is compile-time checking:
// This fails at compile time — 'Deploymnet' is not a valid class
new kplus.Deploymnet(this, 'Typo', { ... })
// This fails — replicas must be a number
replicas: "three"
// This fails — CPU limit can't be less than request
resources: {
cpu: { request: kplus.Cpu.millis(500), limit: kplus.Cpu.millis(100) } // TypeScript will catch this pattern in strongly typed wrappers
}
Your editor autocompletes kplus.ServiceType. to show valid values. Misspelled resource names are caught before kubectl apply.
Raw API Objects — the Escape Hatch
When cdk8s-plus doesn't have a construct (custom CRDs, rare resources), use the raw ApiObject:
import { ApiObject } from 'cdk8s'
// A CRD instance
new ApiObject(this, 'Certificate', {
apiVersion: 'cert-manager.io/v1',
kind: 'Certificate',
metadata: { name: 'web-tls', namespace: props.namespace },
spec: {
secretName: 'web-tls-secret',
dnsNames: [`web.${props.namespace}.acme.io`],
issuerRef: { name: 'letsencrypt-prod', kind: 'ClusterIssuer' },
},
})
// Generated CRD bindings
// cdk8s import generates type-safe wrappers from a CRD URL or cluster:
// cdk8s import cert-manager.io/Certificate@v1 --language typescript
Importing Existing CRDs
# From a URL
cdk8s import https://github.com/cert-manager/cert-manager/releases/download/v1.15.0/cert-manager.crds.yaml
# From a running cluster
cdk8s import k8s --language typescript
# From Helm chart CRDs
helm template cert-manager jetstack/cert-manager --include-crds | cdk8s import /dev/stdin --language typescript
The generated classes expose the CRD schema as TypeScript interfaces — full type safety for custom resources.
Multi-Tenant Patterns
Where cdk8s shines most: generating near-identical workloads for many tenants or namespaces:
interface Tenant { name: string; namespace: string; resources: ResourceQuota }
const tenants: Tenant[] = JSON.parse(fs.readFileSync('tenants.json', 'utf8'))
const app = new App()
for (const tenant of tenants) {
new WebAppChart(app, `web-${tenant.name}`, {
namespace: tenant.namespace,
image: 'ghcr.io/acme/web',
replicas: 2,
tag: process.env.IMAGE_TAG!,
})
}
app.synth()
Producing 100 correct, consistent manifests for 100 tenants is a loop — no copy-paste drift.
Testing cdk8s
Same assertion API as CDK:
import { Testing } from 'cdk8s'
import { WebAppChart } from '../lib/web-app-chart'
describe('WebAppChart', () => {
test('deployment has correct resource limits', () => {
const app = Testing.app()
const chart = new WebAppChart(app, 'test', {
namespace: 'test',
image: 'ghcr.io/acme/web',
replicas: 2,
tag: 'latest',
})
const results = Testing.synth(chart)
const deployment = results.find((r: any) => r.kind === 'Deployment')
expect(deployment).toBeDefined()
const container = deployment.spec.template.spec.containers[0]
expect(container.resources.limits.cpu).toBe('500m')
expect(container.resources.limits.memory).toBe('512Mi')
})
test('has an HPA', () => {
const app = Testing.app()
const chart = new WebAppChart(app, 'test', { /* ... */ })
const results = Testing.synth(chart)
expect(results.some((r: any) => r.kind === 'HorizontalPodAutoscaler')).toBe(true)
})
})
GitOps Integration
cdk8s fits perfectly into a GitOps pipeline:
# .github/workflows/manifests.yml
on: [push]
jobs:
synth:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm }
- run: npm ci
- run: npm test
- run: cdk8s synth
- name: Commit and push synthesised YAML
run: |
git config user.name github-actions
git config user.email actions@github.com
git add dist/
git diff --staged --quiet || git commit -m "chore: synth manifests [skip ci]"
git push
The dist/ directory contains the synthesised YAML that Argo CD or Flux watches.
Where cdk8s Fits
| Use case | Tool |
|---|---|
| Many similar workloads (multi-tenant, per-env) | cdk8s |
| Reusable internal components with type safety | cdk8s |
| Simple single-workload YAML | Raw YAML (simpler) |
| Community sharing / package distribution | Helm (larger ecosystem) |
| Runtime values and secrets | Helm + values.yaml or External Secrets |
The TypeScript IaC Stack — Full Picture
- cdk8s — Kubernetes manifests (TypeScript → YAML)
- AWS CDK — AWS infrastructure (TypeScript → CloudFormation)
- Pulumi — multi-cloud (TypeScript → provider APIs)
- All three share: construct model, TypeScript type system, Jest testing, npm packaging
Skills transfer across all three. A team fluent in CDK picks up cdk8s in a day and Pulumi in a week. The investment in TypeScript for infrastructure pays dividends across the entire cloud-native stack.
Further Reading
- cdk8s documentation
- cdk8s-plus API reference
- Constructs Hub — all three tools' packages
- AWS CDK API reference
- Pulumi documentation
- Pair with the CertQnA Helm & Kubernetes Packaging, Platform Engineering & IDPs, and Cloud FinOps courses.