Explanation
CDK8S: Infrastructure as Code for Kubernetes¶
This document explains why kup6s uses CDK8S (Cloud Development Kit for Kubernetes) instead of raw YAML, Helm, or Kustomize - and how it enables type-safe infrastructure as code.
What is CDK8S?¶
CDK8S is a framework for defining Kubernetes applications using programming languages (TypeScript, Python, Go, Java) instead of YAML.
// Instead of writing YAML...
const deployment = new kplus.Deployment(this, 'webservice', {
replicas: 2,
containers: [{
image: 'myapp:v1.0.0',
port: 8080,
env: { REDIS_HOST: 'redis' },
resources: {
cpu: kplus.Cpu.millis(200),
memory: kplus.Size.gibibytes(1),
},
}],
});
// CDK8S generates the YAML for you
Output → manifests/myapp.k8s.yaml (standard Kubernetes YAML)
apiVersion: apps/v1
kind: Deployment
metadata:
name: webservice
spec:
replicas: 2
template:
spec:
containers:
- name: main
image: myapp:v1.0.0
ports:
- containerPort: 8080
env:
- name: REDIS_HOST
value: redis
resources:
requests:
cpu: 200m
memory: 1Gi
Key insight: CDK8S is a code generator, not a templating engine. It produces standard Kubernetes YAML that works with any tool (kubectl, ArgoCD, Flux, etc.).
Why CDK8S?¶
The Problem with YAML¶
YAML is verbose and error-prone:
# Easy to make mistakes (indentation, typos, wrong types)
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-webservice
namespace: myapp
labels:
app.kubernetes.io/name: myapp
app.kubernetes.io/component: webservice
app.kubernetes.io/instance: myapp-prod
app.kubernetes.io/version: "1.0.0"
app.kubernetes.io/part-of: myapp-platform
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: myapp
app.kubernetes.io/component: webservice
template:
metadata:
labels:
app.kubernetes.io/name: myapp
app.kubernetes.io/component: webservice
spec:
containers:
- name: webservice
image: myapp:v1.0.0
# ... 200 more lines of YAML ...
Problems:
No type checking - Typo in field name? Deploy-time error
No IDE support - No autocomplete, no inline docs
Copy-paste errors - Duplicate labels, mismatched selectors
Hard to refactor - Change label scheme = search/replace nightmare
No reusability - Can’t extract common patterns
Repetition - Same configuration copy-pasted across resources
Alternative: Helm¶
Helm uses Go templates to generate YAML:
# Helm template (still YAML + templating)
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}-webservice
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.webservice.replicas }}
template:
spec:
containers:
- name: webservice
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
{{- if .Values.webservice.resources }}
resources:
{{- toYaml .Values.webservice.resources | nindent 10 }}
{{- end }}
Problems:
Still YAML - All the YAML problems remain
Template debugging - Hard to debug complex template logic
Limited IDE support - Templates break syntax highlighting
Values.yaml hell - Deep nested structures, hard to validate
Whitespace bugs -
nindent,indent,-behavior confusingNo type checking - Type confusion (string vs integer) caught at runtime
Alternative: Kustomize¶
Kustomize uses patches to modify YAML:
# kustomization.yaml
patchesStrategicMerge:
- |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
spec:
containers:
- name: main
resources:
requests:
memory: 2Gi # Forgot to update limits!
Problems:
Patch complexity - JSON patches are hard to read/maintain
Limited logic - No conditionals, loops, or functions
Overlay confusion - Multiple overlays (dev/staging/prod) hard to track
No type safety - Still editing YAML directly
Incomplete patches - Easy to update requests but forget limits
CDK8S Solution¶
Type-safe code with full IDE support:
// TypeScript with full type checking
import * as kplus from 'cdk8s-plus-28';
export class WebserviceConstruct extends Construct {
constructor(scope: Construct, id: string, config: AppConfig) {
super(scope, id);
const deployment = new kplus.Deployment(this, 'webservice', {
metadata: {
annotations: {
'argocd.argoproj.io/sync-wave': '3',
},
},
replicas: config.replicas.webservice, // Type-checked!
containers: [{
image: `myapp:${config.versions.app}`,
portNumber: 8080,
envVariables: {
REDIS_HOST: kplus.EnvValue.fromValue('redis'),
POSTGRES_HOST: kplus.EnvValue.fromSecretValue({
secret: postgresSecret,
key: 'host',
}),
},
resources: {
cpu: kplus.Cpu.millis(200),
memory: kplus.Size.mebibytes(512),
},
}],
serviceAccount: this.serviceAccount,
});
}
}
Benefits:
Compile-time errors - Typo? TypeScript catches it before deploy
IDE autocomplete - IntelliSense shows all available fields
Type safety - Can’t pass string where number expected
Refactoring tools - Rename variable = updates all references
Reusable constructs - Extract common patterns into functions
Unit tests - Test infrastructure with Jest/other test frameworks
CDK8S Comparison Matrix¶
Feature |
Raw YAML |
Helm |
Kustomize |
CDK8S |
|---|---|---|---|---|
Type Safety |
❌ No |
❌ No |
❌ No |
✅ Yes |
IDE Support |
⚠️ Limited |
⚠️ Limited |
⚠️ Limited |
✅ Full |
Reusability |
❌ Copy-paste |
✅ Charts |
⚠️ Bases |
✅ Constructs |
Compile-time Errors |
❌ No |
❌ No |
❌ No |
✅ Yes |
Testability |
❌ No |
⚠️ Limited |
⚠️ Limited |
✅ Full |
Complexity |
⭐ Simple |
⭐⭐ Moderate |
⭐⭐ Moderate |
⭐⭐⭐ Complex |
Learning Curve |
⭐ Easy |
⭐⭐ Moderate |
⭐⭐ Moderate |
⭐⭐⭐ Steep |
Output |
YAML |
YAML |
YAML |
YAML |
Runtime Needed |
❌ No |
✅ Helm |
❌ No |
❌ No |
Logic/Conditionals |
❌ No |
✅ Yes |
❌ No |
✅ Yes |
When to use each:
Raw YAML: Tiny deployments (< 5 resources), one-off tasks
Helm: Using existing charts, team experienced with Helm
Kustomize: Simple overlays (dev/staging/prod), light customization
CDK8S: Complex deployments (>10 resources), need type safety, long-term maintenance
The Construct Pattern¶
What is a Construct?¶
A construct is a reusable component that creates one or more Kubernetes resources.
Anatomy of a construct:
export interface MyComponentProps {
namespace: string;
replicas: number;
storage: string;
}
export class MyComponentConstruct extends Construct {
constructor(scope: Construct, id: string, props: MyComponentProps) {
super(scope, id);
// Create Deployment
new kplus.Deployment(this, 'app', {
metadata: { namespace: props.namespace },
replicas: props.replicas,
// ...
});
// Create Service
new kplus.Service(this, 'service', {
metadata: { namespace: props.namespace },
// ...
});
}
}
Benefits of Constructs¶
1. Single Responsibility Each construct manages one logical component:
Clear separation of concerns
Easy to understand and maintain
Independent testing
2. Composability Constructs can use other constructs:
export class AppChart extends Chart {
constructor(scope: Construct, id: string, config: AppConfig) {
super(scope, id);
// Compose multiple constructs
new NamespaceConstruct(this, 'namespace', config);
new DatabaseConstruct(this, 'database', config);
new CacheConstruct(this, 'cache', config);
new ApplicationConstruct(this, 'app', config);
}
}
3. Encapsulation Internal details hidden from consumers:
// Consumer doesn't need to know about Deployment, Service, PVC
new MyComponentConstruct(this, 'component', {
namespace: 'myapp',
replicas: 2,
storage: '10Gi',
});
// Construct handles all complexity internally
4. Reusability Share constructs across projects:
// Create construct once
export class PostgresConstruct extends Construct {
// ...complex PostgreSQL configuration
}
// Reuse in multiple deployments
new PostgresConstruct(this, 'gitlab-db', { size: '20Gi' });
new PostgresConstruct(this, 'monitoring-db', { size: '5Gi' });
Typical CDK8S Project Structure¶
deployment/
├── charts/
│ ├── constructs/ # Individual construct files
│ │ ├── namespace.ts
│ │ ├── rbac.ts
│ │ ├── database.ts
│ │ ├── cache.ts
│ │ ├── application.ts
│ │ └── ...
│ ├── types.ts # Shared TypeScript interfaces
│ └── main-chart.ts # Main chart (composes constructs)
├── config.yaml # Configuration values
├── main.ts # Entry point (loads config, synthesizes)
├── package.json # NPM dependencies
├── tsconfig.json # TypeScript configuration
├── tests/ # Jest unit tests
│ ├── constructs/
│ │ ├── database.test.ts
│ │ └── ...
│ └── main-chart.test.ts
└── manifests/ # Generated YAML (committed to git)
└── app.k8s.yaml
Configuration Management Pattern¶
Separation of config and code:
# config.yaml (data, safe to commit)
versions:
app: v1.0.0
postgres: '15'
domains:
app: app.example.com
replicas:
webservice: 2
storage:
postgresql: 10Gi
// charts/main-chart.ts (code, references config)
export class AppChart extends Chart {
constructor(scope: Construct, id: string, config: AppConfig) {
super(scope, id);
// Use config values (type-checked!)
const webservice = new kplus.Deployment(this, 'webservice', {
replicas: config.replicas.webservice, // From config.yaml
containers: [{
image: `myapp:${config.versions.app}`, // Type-safe
}],
});
}
}
Benefits:
Config is data - No code in config files
Code is logic - No hardcoded values in code
Type safety - Config must match interface
IDE support - Autocomplete for config fields
Environment-specific - Different configs for dev/staging/prod
Build Process¶
graph LR
A[config.yaml] --> B[main.ts]
C[.env] --> B
D[charts/*.ts] --> B
B -->|TypeScript compile| E[JavaScript]
E -->|cdk8s synth| F[manifests/app.k8s.yaml]
F -->|git commit/push| G[ArgoCD]
G -->|kubectl apply| H[Kubernetes]
style B fill:#6cf
style F fill:#fc6
style H fill:#6f6
Commands:
# Compile TypeScript to JavaScript
npm run compile
# Synthesize K8S manifests from charts
npm run synth
# Run tests
npm test
# Full build (compile + test + synth)
npm run build
package.json scripts:
{
"scripts": {
"compile": "tsc", // TypeScript → JavaScript
"synth": "cdk8s synth", // JavaScript → YAML
"test": "jest", // Run tests
"build": "npm run compile && npm run test && npm run synth"
}
}
Development Workflow¶
1. Make Changes¶
vim charts/constructs/database.ts
# Change PostgreSQL storage from 10Gi to 20Gi
2. Build Manifests¶
npm run build
What happens:
TypeScript code compiles to JavaScript
Tests run (catches errors early)
CDK8S generates
manifests/app.k8s.yaml
3. Review Changes¶
# See what changed in generated YAML
git diff manifests/app.k8s.yaml
Example diff:
- storage: 10Gi
+ storage: 20Gi
4. Commit and Deploy¶
# Commit both code and generated manifests
git add charts/ manifests/
git commit -m "Increase PostgreSQL storage to 20Gi"
git push
# ArgoCD automatically deploys changes
Common Patterns¶
Adding a ConfigMap¶
// charts/constructs/my-construct.ts
import * as kplus from 'cdk8s-plus-28';
export class MyConstruct extends Construct {
constructor(scope: Construct, id: string, config: AppConfig) {
super(scope, id);
// Create ConfigMap
const configMap = new kplus.ConfigMap(this, 'config', {
metadata: { namespace: config.namespace },
data: {
'app.conf': `
host: ${config.domains.app}
port: 8080
debug: false
`,
},
});
// Use in Deployment
const deployment = new kplus.Deployment(this, 'app', {
containers: [{
image: 'myapp:latest',
envFrom: [kplus.Env.fromConfigMap(configMap)],
}],
});
}
}
Referencing Existing Secrets¶
// Reference secret created elsewhere
const deployment = new kplus.Deployment(this, 'app', {
containers: [{
image: 'myapp:latest',
envVariables: {
DB_PASSWORD: kplus.EnvValue.fromSecretValue({
secret: kplus.Secret.fromSecretName(this, 'db-secret', 'postgres-app'),
key: 'password',
}),
},
}],
});
Adding Annotations (Sync Waves)¶
// Set sync wave for ArgoCD ordering
const database = new ApiObject(this, 'postgres', {
metadata: {
namespace: config.namespace,
annotations: {
'argocd.argoproj.io/sync-wave': '3', // After secrets (wave 2)
},
},
// ...
});
Testing Strategy¶
Unit Tests¶
Test individual constructs:
describe('DatabaseConstruct', () => {
it('should create PostgreSQL cluster with correct storage', () => {
const chart = Testing.chart();
const config = createTestConfig({ storage: { postgresql: '20Gi' } });
new DatabaseConstruct(chart, 'test', { config });
const manifests = Testing.synth(chart);
const cluster = findResource(manifests, 'Cluster');
expect(cluster.spec.storage.size).toBe('20Gi');
});
});
Integration Tests¶
Test full chart synthesis:
describe('AppChart', () => {
it('should generate all resources', () => {
const chart = Testing.chart();
const config = createTestConfig();
new AppChart(chart, 'app', config);
const manifests = Testing.synth(chart);
expect(countResourcesByKind(manifests, 'Namespace')).toBe(1);
expect(countResourcesByKind(manifests, 'Deployment')).toBe(3);
expect(countResourcesByKind(manifests, 'Service')).toBe(3);
});
});
Snapshot Tests¶
Detect unexpected changes:
test('database construct matches snapshot', () => {
const chart = Testing.chart();
const config = createTestConfig();
new DatabaseConstruct(chart, 'test', { config });
const manifests = Testing.synth(chart);
expect(manifests).toMatchSnapshot();
});
Trade-Offs & Limitations¶
Advantages¶
✅ Type safety: Catch errors at compile time ✅ IDE support: Autocomplete, refactoring, documentation ✅ Testability: Unit tests with Jest ✅ Reusability: Constructs can be shared/published ✅ Maintainability: Easier refactoring, centralized logic ✅ Debugging: TypeScript stack traces > YAML errors ✅ Logic/Conditionals: Full programming language capabilities ✅ DRY: Extract common patterns into functions
Disadvantages¶
❌ Learning curve: Requires TypeScript knowledge ❌ Build step: Must compile before deploying ❌ Dependencies: Node.js, npm, TypeScript toolchain ❌ Generated YAML: Must commit generated files to git ❌ Diff noise: Small code changes can produce large YAML diffs ❌ Complexity: More moving parts than raw YAML
When NOT to Use CDK8S¶
Use raw YAML if:
One-off deployments (not maintained long-term)
Team unfamiliar with TypeScript
Very simple deployments (<5 resources)
Quick prototyping
Use Helm if:
Need to consume existing Helm charts
Team already experienced with Helm
Simple templating sufficient
Community charts are good enough
Use CDK8S if:
Complex deployments (>10 resources)
Need type safety and validation
Long-term maintenance required
Team comfortable with TypeScript
Infrastructure as code is a priority
Troubleshooting¶
TypeScript Compilation Errors¶
Error:
charts/constructs/database.ts:45:7 - error TS2322: Type 'string' is not assignable to type 'number'.
Solution: Fix type mismatch (caught at compile-time!)
// Wrong
replicas: config.replicas.webservice // string from YAML
// Right
replicas: Number(config.replicas.webservice) // Convert to number
// Or: Use number type in config.yaml parser
Manifest Not Updated¶
Symptoms: Changed code, but manifests/ unchanged
Solution:
# Ensure you ran build command
npm run build
# Check for compilation errors
npm run compile
Missing Environment Variables¶
Symptoms: Secrets in manifest show undefined or null
Solution:
# Verify .env file exists and has correct format
cat .env
# Ensure using bash sourcing for build
bash -c 'source .env && npm run build'