Skip to content

Spec Reference

lok8s uses YAML spec files to define clusters and deployments. These specs double as Kubernetes CRDs when used with the operator.

cluster.lok8s.yaml

Defines a cluster domain. The kind field determines which driver contract handles provisioning.

Common fields (all cluster kinds)

Every cluster.lok8s.yaml shares this base structure regardless of kind:

yaml
apiVersion: cluster.lok8s.dev/v1beta1
kind: Lo | KubeOne | Capi | Kkp         # driver selection
metadata:
  name: my-cluster                        # unique cluster name
spec:
  kubernetes:
    version: "v1.31.12"                   # k8s version (required)
  cluster:
    domain: example.com                   # cluster FQDN (required)
    namespace: default                    # default namespace
  provider:                               # infrastructure provider (optional for Lo)
    name: hetzner                         # provider name (matches .lok8s/providers/<name>/)
    configRef: hetzner.json               # provider config file (relative to cluster dir)
    # OR inline:
    # config: { ... }                     # opaque provider-specific config
  bootstrap:                              # cluster-infra addons (ordered, applied by framework)
    - cilium                              # bare name: .lok8s/addons/<name>/
    - ./targets/networking                # ./path: relative to cluster dir
  kubehz:                                 # kubehz platform integration (optional)
    hosting: self | hosted                # who runs the control plane
    access: none | registered | managed   # kubehz visibility
    apiUrl: https://api.kubehz.cloud      # required when hosting=hosted or access!=none
  build:                                  # build output shape (optional)
    artifacts: single | split             # split = per-resource files under artifacts/
    encrypt:                              # Secret-encryption policy for split mode
      type: sops                          # default (only value); anything else = hard error
      on: change | always                 # change (default) skips re-encrypting unchanged Secrets
  gitops:                                 # GitOps consumption (optional; implies artifacts: split)
    provider: flux                        # reconciler the split output feeds
    age:                                  # sops age PUBLIC keys for Secret.*.sops.yaml
      - age1reconcilerkey…                #   the reconciler's runtime decryption key
      - age1breakglasskey…                #   operator break-glass
FieldRequiredDefaultDescription
apiVersionyesAlways cluster.lok8s.dev/v1beta1
kindyesDriver: Lo, KubeOne, Capi, Kkp
metadata.nameyesUnique cluster name
spec.kubernetes.versionyesKubernetes version
spec.cluster.domainyesCluster FQDN (folder name convention)
spec.cluster.namespacenodefaultDefault namespace
spec.provider.namenoInfrastructure provider
spec.provider.configRefnoProvider config file path
spec.provider.confignoInline provider config (mutually exclusive with configRef)
spec.bootstrapno[cilium]Ordered list of infra addons (default applied by framework bootstrap when omitted)
spec.kubehz.hostingnoselfWho runs the control plane: self (you provision and own it) or hosted (kubehz runs it; with kind: Lo also requires spec.runner)
spec.kubehz.accessnononePlatform visibility: none (no contact), registered (announced + heartbeats → dashboard), or managed (adds the kubehz operator)
spec.kubehz.apiUrlconditionalkubehz API endpoint; required when hosting: hosted or access != none; must be HTTPS
spec.build.artifactsnosinglesplit additionally emits per-resource files under clusters/<domain>/artifacts/<Kind>.<namespace>.<name>.yaml, Secrets as sops-encrypted Secret.<ns>.<name>.sops.yaml (committable; per-object git history for GitOps)
spec.build.encrypt.typenosopsHow split-mode Secrets are encrypted. sops is the only supported value today; anything else is a hard build error (decoupled from the split trigger — artifacts: split shapes every resource, encrypt governs only the Secret twins)
spec.build.encrypt.onnochangeWhen a Secret's committed Secret.*.sops.yaml is re-encrypted. change (default) keeps the existing ciphertext byte-for-byte when the prior decrypts to the same (canonical) plaintext — killing the per-build churn from sops's fresh-per-encrypt data key. always re-encrypts every Secret every build (the original behavior). See the build guide.
spec.gitops.providernoDeclares the reconciler consuming the split output (implies build.artifacts: split unless explicitly single)
spec.gitops.ageconditionalage public keys the split mode encrypts Secrets to; required whenever the render contains a Secret (build fails closed otherwise)

Split-mode notes: artifacts.yaml remains the single source lo deploy applies — the split dir is derived from it after every successful build. Jobs in the split output are GitOps-shaped (ttlSecondsAfterFinished stripped — a TTL-reaped Job would be recreated by the reconciler every interval — and annotated kustomize.toolkit.fluxcd.io/force so fixed-name Jobs recreate on spec changes). lo build --split / --single exist as debug overrides and warn when they contradict the spec.

Secret churn — encrypt.on: change (default). sops mints a fresh data key on every encrypt, so re-encrypting an unchanged Secret rewrites its whole ciphertext and dirties the git diff each build. With on: change, split compares each Secret against its committed twin: if the prior decrypts here (using the ambient sops age key lo build already has — the owner key is a recipient) and its decrypted content canonically equals the fresh render, the existing file is kept verbatim (no re-encrypt). If the prior can't be decrypted (no key / corrupt / missing) it re-encrypts — it can never prove "unchanged", so it fails safe. on: always disables the compare. Chart-minted random secrets must still be pinned (chart existingSecret hooks + the secrets plugin) or the rendered value changes every build (a real change → a re-encrypt) — verify with a double-build diff before pointing a reconciler at the output.

CI render — lo build --no-secrets. For the CI path that has no secrets store and no age key but must still regenerate non-Secret artifacts (e.g. after an image-automation pin bump), lo build --no-secrets splits only non-Secret resources. It never renders, re-encrypts, prunes, or even reads a committed Secret.*.sops.yaml — those are left completely inert — and the secret generators are never invoked (so the cache-first passwd/bash generators can't mint fresh values on a store miss). It achieves this by exporting LOK8S_SECRETS_DISABLE=1 to the kustomize build render (the split shaping alone would not stop the render from hitting the store); the swap's prune is guarded to exclude Secret.*.sops.yaml; a non-Secret dropped from the render is still pruned. See the plugin's env contract.

See the kubehz Platform guide for registration, claiming, and the heartbeat agent.

Provider output (standard schema)

All providers produce the same output JSON. Drivers read this — not the cluster spec — for infrastructure details (IPs, SSH access, node topology).

json
{
  "api": { "endpoint": "<lb_ip>", "port": 6443 },
  "access": [
    { "id": "default", "type": "ssh", "user": "root", "port": 22,
      "privateKey": "~/.ssh/key", "publicKey": "~/.ssh/key.pub" },
    { "id": "bastion", "type": "ssh", "host": "bastion.example.com",
      "user": "jump", "privateKey": "~/.ssh/bastion" },
    { "id": "dedicated", "type": "ssh", "user": "root",
      "privateKey": "~/.ssh/robot_key", "bastion": "bastion" }
  ],
  "nodes": [
    { "name": "cp-0", "role": "control-plane", "group": "cloud-cp",
      "public_ip": "1.2.3.4", "private_ip": "10.0.0.1",
      "access": "default", "ssh_user": "root", "ssh_port": 22 }
  ],
  "network": { "id": "12345", "name": "my-net", "cidr": "10.0.0.0/16" }
}
FieldDescription
access[].idUnique identifier for this access method
access[].typeAccess type: ssh, ssm (AWS), gcloud (GCP)
access[].bastionID of another access entry to use as jump host
nodes[].accessReferences access[].id (default: first entry)
nodes[].ssh_user/ssh_portBack-compat fields (derived from access)

Drivers should read access[] for connection details, not spec.ssh. The number of control-plane nodes is derived from nodes | select(.role == "control-plane") | length, not spec.controlPlane.

Kind-specific fields

Each cluster kind adds its own fields to the common base. Kind-specific fields are documented in the sections below.

Lo Spec

Minimal form — the framework derives everything else from the domain (slot-parsed for *.lok8s.dev) and domain-independent defaults.

yaml
apiVersion: cluster.lok8s.dev/v1beta1
kind: Lo
metadata:
  name: local
spec:
  cluster:
    domain: lok8s.dev

Full form — every field explicit, equivalent to the defaults that the minimal form would produce for slot 125.

yaml
apiVersion: cluster.lok8s.dev/v1beta1
kind: Lo
metadata:
  name: local
spec:
  kubernetes:
    version: "v1.31.4"         # Kubernetes version
  cluster:
    domain: lok8s.dev           # Cluster FQDN (required)
    namespace: default          # Default namespace
  network:                       # Docker bridge network config
    name: local                  # Bridge network name (default: metadata.name)
    cidr: "10.125.125.0/24"     # /24 slot — slot-derived from domain for *.lok8s.dev
  registries:                    # Registry mirror configuration
    tls: true                    # HTTPS registries (cert: generator-minted; default: true)
    shared:
      enabled: true              # Pull-through mirrors on a shared network (default: true)
      network:
        name: lok8s-registries   # Shared network name (default)
        cidr: "10.125.200.0/24"  # Shared network CIDR (default)
    mirrors:                     # Pull-through mirror list (default: io-* set below)
      - name: io-docker
        url: https://registry-1.docker.io
      - name: io-quay
        url: https://quay.io
      - name: io-k8s
        url: https://registry.k8s.io
      - name: io-ghcr
        url: https://ghcr.io
  nodes:                         # Kind cluster node topology
    controlPlane: 1              # Control-plane nodes (default: 1)
    workers: 0                   # Worker nodes (default: 0)
    hostPorts: true              # Bind 80/443/8080 on host (default: true for slot 125, false otherwise)
    maxConcurrentDownloads: 3    # containerd parallel image pulls (default: 3)
  loadBalancer:                  # MetalLB L2 load balancer (optional)
    pool: "10.125.125.125-10.125.125.150"  # 26 IPs; slot-derived by default for *.lok8s.dev
  runtime: kind                  # Runtime: "kind" (default; future: k3s, k0s)
  bootstrap:                     # Cluster-infra addons (ordered; default: [cilium])
    - cilium                     # framework addon: .lok8s/addons/cilium/
    - metallb                    # framework addon: .lok8s/addons/metallb/
  provider:                      # Cloud provider for remote VMs (optional, requires --remote)
    name: hetzner                # Provider name (matches .lok8s/providers/<name>/main)
    configRef: hetzner.json      # Provider config file (relative to cluster dir)
  remote:                        # Remote mode config (optional, requires --remote)
    mode: docker                 # "docker" (local orchestration, remote Docker) | "ci" (everything on VM)
    expose: true                 # Run nginx reverse proxy on remote (default: true when provider is set)
    sync:                        # Repo sync settings (CI mode only)
      path: .                   # What to rsync (default: repo root)
      exclude:                  # Rsync exclusions
        - .git
        - node_modules
        - .secrets
        - .kubeconfig
      dest: /workspace           # Remote destination (default: /workspace)
    tilt: true                   # Start Tilt on the VM after provision (CI mode, default: true)

Provider and remote mode

The spec.provider and spec.remote blocks are only activated when lo provision --remote (or lo up --remote) is used. Without --remote, they are ignored — the same spec works for both local and remote.

Docker mode (default): The provider creates a VM, then the local machine runs kind/registries/bootstrap against the remote Docker via DOCKER_HOST=ssh://<ip>. The API is accessed through an SSH tunnel.

CI mode (mode: ci): The repo is rsynced to the VM and lo provision runs entirely on the remote. The remote handles kind, registries, bootstrap, and optionally Tilt. The local machine sets up an SSH tunnel for kubectl and optionally an nginx proxy for HTTPS access.

See CLI reference — Remote clusters for usage.

Default resolution

When a field is missing, the Lo driver fills it in via two layers:

  1. Domain-independent defaults — apply to any Lo cluster:

    FieldDefault
    registries.shared.enabledtrue
    registries.shared.network.namelok8s-registries
    registries.shared.network.cidr10.125.200.0/24
    registries.mirrorsio-{docker,quay,k8s,ghcr} on the standard upstream URLs
    nodes.controlPlane1
    nodes.workers0
    nodes.maxConcurrentDownloads3
    runtimekind
    bootstrap[cilium]
  2. Slot-derived defaults*.lok8s.dev only. The slot is parsed from spec.cluster.domain: lok8s.dev → 125, <n>.lok8s.dev → n.

    FieldDefault
    network.namemetadata.name
    network.cidr10.125.<slot>.0/24
    loadBalancer.pool10.125.<slot>.125-10.125.<slot>.150
    nodes.hostPortstrue for slot 125, false for every other slot

    Non-*.lok8s.dev domains must supply network.name and network.cidr explicitly.

Build and cache registries are framework-private — they always live on the project subnet at .101 and .102 and must not be listed in spec.registries.mirrors.

Network Configuration

The spec.network section configures the Docker bridge network used by kind clusters. For *.lok8s.dev domains, both fields are derived from the domain and metadata.name; for other domains, both are required. See SUBNETS.md for the 10.125.0.0/16 slot plan.

FieldRequiredDescription
nameno (for *.lok8s.dev)Docker bridge network name (default: metadata.name)
cidrno (for *.lok8s.dev)/24 CIDR for the cluster's slot (default: 10.125.<slot>.0/24)

Example for an alternate slot:

yaml
spec:
  cluster:
    domain: 50.lok8s.dev
  # network.name defaults to metadata.name
  # network.cidr defaults to "10.125.50.0/24"

Or fully explicit:

yaml
spec:
  network:
    name: lok8s-50
    cidr: "10.125.50.0/24"

Registries Configuration

The spec.registries section configures registry mirrors. Every lok8s cluster ships with two framework-private registries plus four default pull-through mirrors:

  • Framework-private (build, cache) — plain filesystem registries owned by the framework. Always live on the project subnet at fixed offsets .101 (build) and .102 (cache), even in shared mode, because they hold per-project content (locally-built images for build, credentialed pre-pulls for cache). They get the canonical hostnames lok8s.local and lok8s.cache via containerd mirror config. Do not list them in spec.registries.mirrors — they ship implicitly.
  • Pull-through mirrors (io-docker, io-quay, io-k8s, io-ghcr by default) — transparent caches for public upstreams. When shared.enabled is true (the default), they live on the shared registry network at 10.125.200.0/24 so multiple kind clusters reuse one cache.
FieldRequiredDescription
tlsno (default true)HTTPS registries with a cert minted by the Secret plugin; set false for plain HTTP (see TLS below)
shared.enabledno (default true)Pull-through mirrors live on the shared network
shared.network.nameno (default lok8s-registries)Shared registry network name
shared.network.cidrno (default 10.125.200.0/24)Shared registry network CIDR
mirrors[].nameyes (per entry)Registry name (must not be build or cache)
mirrors[].urlyes (per entry)Upstream registry URL
prefixno (default lok8s.local)Image registry prefix used for locally-built image references

Registries are reached via raw IP (no host port mapping). The listen port depends on the mode: :80 in the default plain-HTTP mode, or :443 when tls: true (so a bare-IP docker push <ip>/..., which the Docker client resolves to the HTTPS default port, reaches the registry without an explicit port in the ref).

Registry TLS

TLS is the default (spec.registries.tls: true; set false for plain HTTP). Every registry (framework-private and pull-through mirrors) serves HTTPS with a certificate minted by the Secret plugin (the same cert: generator used for application TLS), signed by your shared dev CA at CAROOT. This removes the need for an insecure-registries entry in the host Docker daemon configuration:

  • The cert is minted into .secrets/tls/registries/ with every registry's IP and (for framework registries) hostname (lok8s.local, lok8s.cache) as Subject Alternative Names. It is re-minted automatically if the IP/hostname set changes — no mkcert binary involved (the CA at CAROOT is created on demand).
  • Containerd inside the kind nodes trusts the cert via an explicit CA file: each hosts.toml references a copy of the dev rootCA.pem (CAROOT) mounted into the node's certs.d tree (no skip_verify). This needs no mkcert -install — in-cluster pulls work out of the box.
  • Host docker push validates against the system trust store, so the dev CA must be installed there with lo trust; then pushes to lok8s.local/lok8s.cache (and raw IPs) succeed over HTTPS with no daemon configuration.

Prerequisite: the cert is minted by the Secret plugin (no mkcert), but the host Docker client and curl only trust it once the dev CA is in the system trust store — run lo trust once (it wraps mkcert -install; mkcert is managed by b, b install mkcert). $CAROOT/rootCA.pem (default ~/.local/share/mkcert/rootCA.pem) is the trust anchor. If tls: true but the Secret plugin isn't built, lo provision/lo up fail fast; if the CA isn't trusted, the cluster still comes up but docker push fails cert verification until you run lo trust (or another option — see Host push trust options).

Opting out with tls: false keeps plain-HTTP registries, which instead require the registry IP range in the host's insecure-registries.

Default registry set for slot 125 (the default cluster) with shared.enabled: true:

NameIPHostnamePurpose
build10.125.125.101lok8s.localTilt push target for locally-built images
cache10.125.125.102lok8s.cachePre-pull target for build: false services with a configured registry
io-docker10.125.200.2docker.ioPull-through mirror
io-quay10.125.200.3quay.ioPull-through mirror
io-k8s10.125.200.4registry.k8s.ioPull-through mirror
io-ghcr10.125.200.5ghcr.ioPull-through mirror

For other slots, replace 125.125 with 125.<slot>. In non-shared mode (shared.enabled: false), pull-throughs move to the project subnet at .103+.

Containerd wiring: hostname → IP resolution happens via per-host hosts.toml files written by lo::write_certs_d to clusters/<domain>/.containerd/certs.d/. That directory is bind-mounted into every kind node via extraMounts, so containerd reads it on startup. Each host gets BOTH a hostname-keyed entry AND a raw-IP-keyed entry, covering both naming conventions.

Nodes Configuration

The spec.nodes section controls kind cluster node topology.

FieldDefaultDescription
controlPlane1Number of control-plane nodes (1-9)
workers0Number of worker nodes (0-99)
hostPortstrueBind host ports 80/443/8080 on the first control-plane node
maxConcurrentDownloads3containerd max_concurrent_downloads — parallel image layer pulls per node. Matches containerd's own default. Lower to 1 if a flaky pull-through mirror returns "unexpected commit digest" under concurrent pulls; raise for faster cold starts on a reliable mirror.

When absent, a single control-plane node is created (the kind default).

The first control-plane node gets port mappings (80, 443, 8080), the ingress-ready=true label, and a docker socket mount. Additional control-plane and worker nodes get the base image only.

Load Balancer Configuration

The spec.loadBalancer section installs and configures MetalLB for LoadBalancer service support. When absent, MetalLB is not installed.

FieldDescription
poolIP range for MetalLB L2 pool (e.g. 10.125.125.125-10.125.125.150 for slot 125)

The pool range must be within spec.network.cidr. IP validation is performed at provisioning time.

MetalLB is pinned to Helm chart version 0.15.3. An IPAddressPool and L2Advertisement are created automatically.

Custom DNS (spec.coredns)

spec.coredns injects per-cluster CoreDNS config. The Lo driver renders it into a coredns-custom ConfigMap that the base Corefile imports from /etc/coredns/custom (import custom/*.server + import custom/*.override). Declarative and committed, so it survives a recreate — unlike a runtime kubectl patch. All fields are optional and compose; when spec.coredns is absent the import is a no-op. Full guide: Custom in-cluster DNS.

FieldTypeDescription
hosts[]list of {name, target}Driver generates a name:53 { … } server block resolving the zone name (its apex + every *.name): A → target, AAAA → NODATA, other types forwarded. target: gateway resolves to the first spec.loadBalancer.pool IP (where the gateway pins via the metallb annotation) — no hardcoded IP to keep in sync; otherwise a literal IP.
serversstringRaw CoreDNS server block(s), inline (a *.server file).
overridesstringRaw directives merged into the default .:53 block (a *.override file).
importstringPath (relative to the cluster dir; default ./coredns) to a directory of raw *.server / *.override files.
yaml
spec:
  coredns:
    hosts:
      - name: kubehz.dev      # apex + *.kubehz.dev
        target: gateway       # = loadBalancer.pool[0], or a literal IP

Don't define the same zone via both hosts and a raw servers/import block — CoreDNS rejects duplicate zone definitions.

Bootstrap (Cluster-Infra Addons)

The spec.bootstrap field is an ordered list of addons the framework applies at provision time, before Tilt starts and before any workloads land. This is the framework's one ordering primitive — each entry is applied, then lok8s waits for Deployments/DaemonSets to become ready before moving to the next.

Implementation lives in .lok8s/libs/bootstrap and runs identically across all drivers (Lo, KubeOne, Capi, Kkp).

Use this for anything the cluster needs to be healthy before workloads can run: CNI, CSI, CCM, MetalLB, cert-manager, etc. It is not for application workloads — those live under targets/ and have no framework-level ordering.

yaml
spec:
  bootstrap:
    - cilium            # bare name → framework addon
    - metallb
    - cert-manager
    - ./custom/         # relative path → clusters/<domain>/custom/
    - /shared-base/     # absolute path → repo root /shared-base/

Entry resolution:

FormResolves to
Bare name (cilium).lok8s/addons/<name>/
./path or ../pathRelative to clusters/<domain>/
/pathRelative to the repo root

Each entry must point at a kustomize-buildable directory. Framework addons ship as khelm ChartRenderers (chart.yaml + values.yaml), but any kustomization works. If spec.bootstrap is omitted entirely, the framework defaults to [cilium] (every cluster needs a CNI).

Framework addons support driver- and provider-specific values files plus inline overrides. See the Addons guide for the full precedence chain (base < driver < provider < inline) and authoring guidance.

Failure modes:

  • Missing entry → bootstrap exits non-zero.
  • kustomize build failure → exits non-zero.
  • kubectl apply failure → exits non-zero.
  • Values merge failure → exits non-zero.
  • Health-wait timeout → logged as warning, non-fatal (the addon might not ship Deployments).

Running lo lint validates that every spec.bootstrap entry resolves to an existing directory.

IP Validation

During provisioning, all computed IPs are validated against the subnet:

  • Registry IPs (sequential offsets from the project or shared subnet base)
  • MetalLB pool start and end IPs (if configured)

If any IP falls outside the subnet CIDR, provisioning fails with a clear error message.

Registry Config (.registries.json)

During provisioning, the Lo driver generates a .registries.json file in the cluster directory (e.g. clusters/lok8s.dev/.registries.json). This JSON file is the single source of truth for all registry config — IPs, upstream URLs, domains, container hostnames, and shared/project mode.

json
{
  "shared": true,
  "network": { "name": "lok8s-registries", "cidr": "10.125.200.0/24" },
  "project_network": "local",
  "registries": [
    { "name": "build", "ip": "10.125.125.101", "url": "", "domain": "", "host": "lok8s.local", "type": "framework" },
    { "name": "cache", "ip": "10.125.125.102", "url": "", "domain": "", "host": "lok8s.cache", "type": "framework" },
    { "name": "io-docker", "ip": "10.125.200.2", "url": "https://registry-1.docker.io", "domain": "docker.io", "host": "", "type": "mirror" }
  ]
}

All registry consumers (lo registry status, lo::registries, lo::write_certs_d, lo::registry_configmap) read from this JSON via the registry::* helpers in drivers/lo/utils/config-registry.sh. No global bash arrays are used.

The file is deterministic (derived from the cluster spec + slot) and can be committed to the repo.

Registry ConfigMap

A lok8s-registries ConfigMap is created in kube-system during provisioning. It contains the IP, port, and (for mirrors) remote URL of each registry. This allows in-cluster tooling to discover registry mirrors programmatically.

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: lok8s-registries
  namespace: kube-system
data:
  build.ip: "10.125.125.101"
  build.port: "80"
  cache.ip: "10.125.125.102"
  cache.port: "80"
  io-docker.ip: "10.125.200.2"           # shared mode
  io-docker.port: "80"
  io-docker.url: "https://registry-1.docker.io"
  # ... etc

Additionally, a local-registry-hosting ConfigMap is applied in kube-public per KEP-1755. Tilt reads this to auto-configure default_registry() so docker_build('foo', ...) transparently pushes to the build registry without per-user setup:

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: local-registry-hosting
  namespace: kube-public
data:
  localRegistryHosting.v1: |
    host: "10.125.125.101"
    hostFromClusterNetwork: "lok8s.local"

Lo Status

yaml
status:
  phase: Provisioned            # Pending | Provisioning | Provisioned | Failed
  ready: true
  conditions:
    - type: Ready
      status: "True"
      reason: Provisioned
      message: Cluster is ready
      lastTransitionTime: "2025-01-01T00:00:00Z"

KubeOne Spec

KubeOne clusters use the common fields plus spec.provider for infrastructure. SSH config and node topology are defined in the provider config (e.g. hetzner.json), NOT in the cluster spec. The driver reads them from provider::output.

yaml
apiVersion: cluster.lok8s.dev/v1beta1
kind: KubeOne
metadata:
  name: my-prod
spec:
  kubernetes:
    version: "v1.31.12"
  cluster:
    domain: my-cluster.example.com
  provider:
    name: hetzner
    configRef: hetzner.json
  bootstrap:
    - ./targets/networking
    - ./targets/cnpg-operator

Deprecated fields (use provider config instead):

  • spec.ssh → provider config sshUser, sshPrivateKey, sshPublicKey
  • spec.controlPlane.replicas → derived from provider output node count
  • spec.workers → defined in provider config (hetzner.json server entries)

These fields are still read as fallbacks for backward compatibility but should not be used in new cluster specs.

Capi Spec

Note: Capi keeps controlPlane and workers in the cluster spec (unlike KubeOne, where the provider owns node topology) because CAPI generates the Kubernetes resources (KubeadmControlPlane, MachineDeployments) directly. Manifest generation targets Hetzner Cloud only (spec.provider.name: hetzner). See the CAPI guide and the validated examples/capi / examples/capi-ha specs.

yaml
apiVersion: cluster.lok8s.dev/v1beta1
kind: Capi
metadata:
  name: prod
spec:
  kubernetes:
    version: "v1.31.12"
  cluster:
    domain: prod.example.com        # Cluster FQDN (required)
    namespace: default
  managementCluster:
    domain: prod-mgmt.example.com    # CAPI management cluster domain
    local: true                      # run it as a local kind cluster
  provider:
    name: hetzner                    # only hetzner (CAPH) is generated
    config:
      region: fsn1                   # Hetzner Cloud region
      sshKeyName: my-key             # SSH key registered in Hetzner
      image: ubuntu-24.04            # stock image; k8s installed via cloud-init
      network:
        enabled: false               # true → private hcloud network + CCM networking
      placementGroups: false         # true → spread anti-affinity (≤ 10 servers/group)
    credentials:
      envVars: [HCLOUD_TOKEN]
      secretRef: prod-credentials    # Secret on the mgmt cluster (default <name>-credentials)
  controlPlane:
    replicas: 3                      # odd for etcd quorum (default 1)
    type: cpx22                      # Hetzner server type
  workers:                           # each key = its own MachineDeployment pool
    general:
      replicas: 3
      type: cpx22
    apps:
      replicas: 1
      type: cpx22
  oidc:                              # optional
    enabled: false
    issuerUrl: https://auth.example.com
  etcd:
    encryptionSecretName: etcd-encryption
  bootstrap:                         # cluster-infra addons (ordered)
    - cilium                         # CNI
    - ccm                            # Hetzner cloud-controller-manager
  gitops:                            # (deferred — currently a no-op)
    provider: flux
    repo: https://github.com/myorg/infra.git
    path: clusters/prod.example.com/artifacts

Capi Status

yaml
status:
  phase: Provisioned
  ready: true
  provider: hetzner
  controlPlaneEndpoint:
    host: 10.0.0.1
    port: 6443
  kubeconfig:
    secretRef: prod-kubeconfig
  targets:
    networking: Applied
    app: Applied
  gitops:
    provider: flux
    status: Bootstrapped
  conditions:
    - type: InfrastructureReady
      status: "True"
    - type: ControlPlaneReady
      status: "True"

deploy.lok8s.yaml

Defines a deployment domain that targets an existing cluster.

yaml
apiVersion: cluster.lok8s.dev/v1beta1
kind: Deploy
metadata:
  name: api
spec:
  domain: api.example.com       # Deployment domain FQDN
  clusterRef:
    domain: prod.example.com    # Cluster to deploy to (required)
  namespace: api                # Target namespace on the cluster

Note: Deploy CRD workload selection is being reworked. Target selection for Deploy specs will land alongside the services.yaml targets-map design. Until then, a Deploy spec carries only clusterRef + namespace.

Deploy Status

yaml
status:
  phase: Deployed               # Pending | Deploying | Deployed | Failed
  targets:
    app: Applied
    jobs: Applied
  conditions:
    - type: Ready
      status: "True"

services.yaml

Defines services for Tilt local development. Not a CRD -- consumed by lo env and the Tilt extension.

yaml
registry:
  prefix: lok8s.local           # Image registry prefix
  branch: builds                # Image path component
  tag: latest                   # Image tag

defaults:
  build: true                   # Default per-service build flag (true = build locally + Tilt live-update)
  dockerfile: service           # "service" (lok8s.Dockerfile) or "production" (Dockerfile)

services:
  my-api:
    enabled: true               # Whether this service is active (default true)
    build: true                 # Whether to docker_build in Tilt (default: defaults.build)
    path: ./my-api              # Service source directory (default: ./<name>)
    namespace: api              # Optional: inject namespace
    dockerfile: production      # Optional: override defaults.dockerfile
    registry:                   # Optional: per-service registry override
      branch: pr-1234
      tag: abc123
    image: ghcr.io/org/img:v1   # Optional: pin to a specific image (mutually exclusive with registry:)
    watch:                      # Optional: extra files to watch
      - package.json

Per-Service lok8s.yaml

Per-service Tilt build configuration. Located at <service-path>/lok8s.yaml.

yaml
build:
  dockerfile: lok8s.Dockerfile  # Dockerfile path (relative to service dir)
  context: .                    # Build context (relative to service dir)
  build_args:                   # Env var names to pass as build args
    - NODE_ENV
  live_update:
    fall_back_on:
      files:
        - package.json
        - yarn.lock
    sync:
      - local_path: ./src
        remote_path: /app/src     # NOTE: remote_path (not container_path)
    run:
      cmd: npm run dev            # NOTE: cmd (not command)
    restart_container: {}       # Optional: restart after sync

ports:
  - from: 3000                  # Host port
    to: 3000                    # Container port

links:
  - url: http://localhost:3000
    name: API

workloads:                      # Optional: override detected workload names
  - my-api-deployment

tilt:
  resource_deps:                # Tilt resource dependencies
    - postgres
  labels:                       # Tilt UI labels
    - backend
  extra_resources:              # Additional k8s_resource groupings
    - name: my-api-migrations
      objects:
        - "my-api-migrate:job"
      resource_deps:
        - postgres
      labels:
        - backend

Released under the MIT License.