Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Infrastructure as Code By James Joyner IV · · 10 min read

cdk8s Constructs: Building a Paved Road to Kubernetes

Replacing YAML with cdk8s code that looks like YAML misses the point. Typed constructs let platform teams ship secure defaults app teams can't easily get wrong.

  • #iac
  • #ai
  • #cdk8s
  • #kubernetes
  • #constructs
  • #typescript

Most cdk8s adoptions plateau at the same place: a team replaces their YAML with TypeScript that generates the same YAML, declares victory, and wonders why the experience isn’t much better. It isn’t better because writing new KubeDeployment(this, 'app', { spec: { ... } }) with the full Kubernetes API surface is just YAML with extra steps and worse autocomplete. The real payoff of cdk8s is one level up: building constructs — typed, opinionated components that encode your platform’s defaults so an application team writes ten lines and gets a Deployment, Service, HPA, PodDisruptionBudget, and NetworkPolicy that already pass policy review.

This is the paved-road idea applied to manifest generation. The platform team owns the construct; app teams consume a small, friendly interface; and the secure, sensible behavior is the default, not something every team has to remember. This guide walks through designing one.

A construct is a typed API, not a wrapper

The unit of value is the props interface. Compare the raw approach with a construct that exposes only what an app team needs to decide:

import { Construct } from 'constructs';
import { KubeDeployment, KubeService, KubePodDisruptionBudget } from './imports/k8s';

export interface WebServiceProps {
  /** Container image, including registry and tag. Must not be :latest. */
  readonly image: string;
  /** Port the container listens on. */
  readonly port: number;
  /** Replica count. Defaults to 3 in production. */
  readonly replicas?: number;
  /** Team that owns this service, applied as a label. */
  readonly team: string;
}

export class WebService extends Construct {
  constructor(scope: Construct, id: string, props: WebServiceProps) {
    super(scope, id);

    if (props.image.endsWith(':latest')) {
      throw new Error(`WebService ${id}: image must be pinned to a tag, not :latest`);
    }

    const labels = { app: id, team: props.team, 'managed-by': 'platform-cdk8s' };
    const replicas = props.replicas ?? 3;

    new KubeDeployment(this, 'deployment', {
      spec: {
        replicas,
        selector: { matchLabels: labels },
        template: {
          metadata: { labels },
          spec: {
            securityContext: { runAsNonRoot: true, runAsUser: 10001 },
            containers: [{
              name: id,
              image: props.image,
              ports: [{ containerPort: props.port }],
              securityContext: { readOnlyRootFilesystem: true, allowPrivilegeEscalation: false },
              resources: { requests: { cpu: '100m', memory: '128Mi' }, limits: { memory: '256Mi' } },
            }],
          },
        },
      },
    });

    new KubeService(this, 'service', {
      spec: { selector: labels, ports: [{ port: 80, targetPort: { value: props.port } }] },
    });

    new KubePodDisruptionBudget(this, 'pdb', {
      spec: { minAvailable: { value: 1 }, selector: { matchLabels: labels } },
    });
  }
}

An app team’s entire usage is now:

new WebService(this, 'checkout', { image: 'registry.internal/checkout:1.4.2', port: 8080, team: 'payments' });

That ten-line call produces a Deployment with a non-root security context, a read-only root filesystem, resource requests, a Service, and a PDB — none of which the app team had to know to ask for. The :latest check throws at synth time, long before an admission webhook would reject it.

Defaults are the contract

The security context, the resource requests, the PDB: these are on by default, and that’s the entire design. Left to write raw manifests, application teams ship root containers with no limits more often than not — not from malice, but because the secure fields are easy to forget. By baking them into the construct and making the opt-out explicit, you flip the default. Doing the right thing is free; doing the risky thing requires a visible override that shows up in code review.

This is also where the responsibility lands on the platform team. Because the construct’s defaults become every consumer’s defaults the moment they adopt it, a sloppy default propagates silently. That’s an argument for reviewing security defaults with your security team and version-pinning the library, not against the pattern — the centralization is exactly what makes a fleet-wide fix a single PR.

Drafting constructs with AI, verifying the defaults yourself

Constructs are repetitive enough that AI drafts them well, and consequential enough that you verify every default. A prompt:

You are a platform engineer. Write a cdk8s construct in TypeScript for a stateless web service. It must emit a Deployment, Service, and PodDisruptionBudget with secure defaults (non-root, read-only root FS, resource requests). Expose only image, port, replicas, and team. Throw at construct time if the image is :latest. Show the synthesized YAML.

The model produced a construct close to the one above, and added:

One thing to decide: I set runAsUser: 10001 as a default, but if your base images don’t have a user at that UID, the container will fail to start. You may want to make this a required prop or confirm your images support it.

That caveat is the human-in-the-loop value. The model can’t know your base images’ UID conventions, so it flagged the assumption rather than burying it. Synthesize the construct with cdk8s synth and read the emitted YAML before anyone depends on it — the generated manifest is the real contract, and it’s the thing your policy gates will actually evaluate.

Keeping the library healthy

Three practices keep a construct library from becoming a liability:

  • Version it like an API. Consumers pin a version; a default change ships as a major bump with a changelog. A silent default change is a fleet-wide incident.
  • Unit-test the synthesis. Snapshot-test the emitted YAML so a refactor that changes output fails in CI, the same discipline we apply to AWS CDK snapshot testing.
  • Make opt-outs loud. If a team needs to run as root, that should be an explicit, reviewable prop — never a silent default.

For generating constructs, see our cdk8s construct library design prompt, and compare the approach with the Jsonnet and Tanka prompt for teams weighing manifest-generation tools. The full Infrastructure as Code category covers the rest of the Kubernetes config landscape. The point of cdk8s isn’t to write YAML in TypeScript — it’s to stop app teams writing YAML at all.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.