Skip to content

Caddy

A config-driven Caddy reverse proxy for TypeKro. Unlike the Caddy ingress-controller (which watches Ingress resources) or a Helm bootstrap, this runs the official caddy image with a Caddyfile you supply and emits a ConfigMap (the Caddyfile) + Deployment + Service + a /data volume — a PVC by default, or an emptyDir in ephemeral mode.

Its headline feature is tls internal: Caddy's built-in local CA issues valid certificates for any name — including private TLDs like *.acme.internal — with no cert-manager, no ACME, and no etcd. By default a PVC persists /data so the CA root is stable across restarts (trust that root once and every site is green-locked); in ephemeral mode /data is an emptyDir and the CA regenerates per pod.

ts
import { caddyIngress, makeCaddyIngress, renderCaddyfile } from 'typekro/caddy';

What gets created

ResourcePurpose
ConfigMapholds the Caddyfile (spec.caddyfile)
PersistentVolumeClaimpersists /data — the tls internal CA root + issued certs. Default mode only (omitted when ephemeral)
Deploymentruns caddy:<version> mounting the Caddyfile + the /data volume — single replica, Recreate strategy
Serviceexposes the proxy (ClusterIP by default)
Namespacethe Caddy workload namespace

Single replica by design. Caddy keeps its tls internal CA in /data, and exactly one pod should own it — in the default mode that is a ReadWriteOnce PVC (multiple pods would need ReadWriteMany or an externalized CA); in ephemeral mode each pod would otherwise mint a different CA. So there is no replicaCount knob (the schema rejects it), the Deployment is pinned to one replica, and it uses the Recreate update strategy (a RollingUpdate would surge a second pod that can't co-mount the RWO PVC and wedge the rollout). HA is deliberately out of scope.

Storage: persistent vs ephemeral

caddyIngress is the default, PVC-backed composition. For a plane that tolerates node/AZ changes, build an ephemeral variant with makeCaddyIngress({ ephemeral: true }):

ts
import { makeCaddyIngress } from 'typekro/caddy';

// emptyDir-backed /data — no PVC; the tls-internal CA regenerates per pod.
const factory = makeCaddyIngress({ ephemeral: true }).factory('kro', {
  namespace: 'typekro-system',
});
Default (caddyIngress)Ephemeral (makeCaddyIngress({ ephemeral: true }))
/data volumePersistentVolumeClaim (RWO)emptyDir
CA rootpersists across restartsregenerates per pod (clients re-trust)
persistence configsize / storageClass honoredrejected (no PVC to size)
Survives a node/AZ change❌ pod can strand on the AZ-locked volume✅ reschedules anywhere

Choose ephemeral when the CA's stability matters less than resilience — e.g. an internal access plane where a stranded Pending pod (PV node-affinity mismatch after a node recycle) would take the ingress down. The storage choice is build-time (a constructor option, not a spec field), so it selects the resource set statically and never needs a runtime conditional. makeCaddyIngress() with no options is identical to caddyIngress.

Quick example

ts
import { caddyIngress, renderCaddyfile } from 'typekro/caddy';

// Build the Caddyfile from concrete host -> upstream routes.
const caddyfile = renderCaddyfile([
  { host: 'dagster-dev.acme.internal', upstream: 'dagster.dagster-platform-dev.svc:80' },
  { host: 'signoz.acme.internal', upstream: 'signoz.observability.svc:3301' },
]);
// =>
//   dagster-dev.acme.internal {
//       tls internal
//       reverse_proxy dagster.dagster-platform-dev.svc:80
//   }
//   ...

const factory = caddyIngress.factory('direct', { namespace: 'caddy-system' });
await factory.deploy({ name: 'caddy', namespace: 'caddy-system', caddyfile });

renderCaddyfile(routes, options?)

A pure helper that turns host→upstream routes into a Caddyfile string. Kept separate from the composition so the composition stays a string passthrough (KRO-safe — see below).

  • tls: 'internal' (default) — each site uses Caddy's local CA (tls internal).
  • tls: 'off' — sites are addressed as http://<host> (plain HTTP, no auto-HTTPS).

Configuration

FieldDefaultNotes
namerequired
caddyfilerequired; the full Caddyfile content (a string)
namespacecaddy-systemCaddy workload namespace
imagecaddy:2.11.2full container image ref including the tag (one field — not image:version — so the KRO default applies cleanly)
version2.11.2version label/status hint (app.kubernetes.io/version + status.version); cosmetic — set it to match your image tag
httpPort / httpsPort80 / 443Service + container ports
serviceTypeClusterIPreach it via a tunnel; no public LB by default
persistence.size1Gisize of the /data PVC. Default mode only — rejected in ephemeral mode
persistence.storageClasscluster defaultstorage class for the PVC. Default mode only — rejected in ephemeral mode
resourcescontainer requests/limits

Why the Caddyfile is a string (KRO safety)

The composition takes the Caddyfile as a string, not a structured routes array. In KRO mode a routes array would be a graph proxy that can't be .map()-ed into a string at graph-generation time. Passing the rendered string keeps the composition identical in direct and KRO modes. Build the string with renderCaddyfile() wherever you have concrete routes (e.g. a generator that knows its services), then pass it as caddyfile.

Readiness & status

FieldMeaning
readythe Deployment's one pod is ready (readyReplicas >= spec.replicas); cannot report ready before that pod exists
versionthe deploy-time version label (static, not runtime)

Factory modes

caddyIngress.factory('kro', …) generates a ResourceGraphDefinition; caddyIngress.factory('direct', …) applies the resources directly. toYaml() renders the RGD for GitOps.

Prerequisites

  • In the default (PVC) mode, a default StorageClass (or set persistence.storageClass) so the /data PVC binds. Ephemeral mode needs no StorageClass.
  • To get a green lock in a browser/client, trust Caddy's internal CA root once (Caddy serves it; it's also under /data/caddy/pki/authorities/local/root.crt). In ephemeral mode the CA changes whenever the pod restarts, so expect to re-trust then.

Next steps

Released under the Apache 2.0 License.