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

Outputmanifests/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 confusing

  • No 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:

  1. TypeScript code compiles to JavaScript

  2. Tests run (catches errors early)

  3. 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'