Cloud-Init
lok8s uses cloud-init to bootstrap remote VMs before Docker and kind run on them. The Hetzner provider generates cloud-init user-data from a config directory and passes it to hcloud server create --user-data-from-file.
Cloud-init is used by Lo remote clusters (lo up --remote) and by production drivers (CAPI, KubeOne) that provision via the Hetzner provider. See CLI reference — Remote clusters and Specs — Provider and remote mode.
How it works
When the Hetzner provider creates a server, it:
- Reads
provider.config.cloudInitfrom the cluster spec - Sources the cloud-config generator at
.lok8s/providers/hetzner/cloud-config - Renders the config directory into a
#cloud-configYAML - Passes it to hcloud — the VM boots with everything pre-installed
The VM is ready when it comes up. No SSH-based post-boot installation.
Where it's applied: cloud VM vs bare metal
The same generated #cloud-config user-data is applied two ways:
- Cloud VMs — passed to
hcloud server create --user-data-from-file. Hetzner's image ships cloud-init, so it runs natively on first boot. - Bare metal (Robot) — the installimage base image has no cloud-init, so the provider's
installimage -xpost-install installs cloud-init and seeds the same user-data into the NoCloud datasource. The node then self-bootstraps on first boot, exactly like a cloud VM.
The design principle: cloud-init everywhere, bare metal included — one config, one mechanism, whether the node is a cloud VM or a dedicated server. See Bare Metal Servers for the full flow.
Built-in default
When cloudInit.path is not set, the provider uses its built-in config at .lok8s/providers/hetzner/cloud-init/:
.lok8s/providers/hetzner/cloud-init/
├── packages # docker.io, curl, jq
├── nameservers # 1.1.1.1, 8.8.8.8
└── write_files/
└── etc/docker/
├── daemon.json # insecure-registries for 10.125.0.0/16, log rotation
└── daemon.json.stat # file permissionsThis is enough for Lo remote clusters — Docker is installed and configured for the lok8s registry bridge at boot time.
Custom cloud-init
Create a cloud-init/ directory next to your cluster spec:
clusters/ci.lok8s.dev/
├── cluster.lok8s.yaml
└── cloud-init/
├── packages # one package per line
├── nameservers # one IP per line
├── sources.list.d/ # apt source definitions
│ └── docker.list # custom Docker repo
├── write_files/ # files synced to the VM
│ └── etc/
│ └── docker/
│ └── daemon.json
└── cloud.d/ # composable sub-configurations
└── monitoring/
├── packages # prometheus-node-exporter, ...
└── write_files/
└── etc/...Reference it in the cluster spec:
spec:
provider:
name: hetzner
config:
cloudInit:
path: ./cloud-init # relative to clusters/<domain>/
modules: "monitoring" # colon-separated cloud.d sub-configsConfig directory structure
| Path | Purpose |
|---|---|
packages | One package name per line. Installed via apt-get install. |
nameservers | One IP per line. Written to /etc/resolv.conf. |
apt | Raw cloud-init apt configuration YAML. |
sources.list.d/<name> | Apt source definitions (YAML with source: and key:). |
write_files/<path> | Files synced to the VM at the exact path. |
write_files/<path>.stat | Optional companion file controlling owner, permissions, execution. |
cloud.d/<module>/ | Sub-configuration with its own packages, write_files/, etc. |
write_files .stat companion
Each file under write_files/ can have a .stat companion that controls:
owner: root:root # override; default: CLOUD_USER:CLOUD_GROUP
permissions: 0644 # override; default: 0655
execute: true # run the file content LOCALLY (at generate time); its stdout becomes the file
execute: remote # run the file ON THE TARGET (in runcmd); its stdout becomes the file
envsubst: true # substitute environment variables in the content
envsubst: $HOME # substitute only specific variables
runcmd: true # execute the file on first boot (must be executable)execute: true vs execute: remote
Both treat the file as a script whose stdout becomes the file content — the difference is where and when it runs:
| Directive | Runs | Sees |
|---|---|---|
execute: true | locally, when the cloud-config is generated | the provisioning host's environment + CLOUD_ENV_* |
execute: remote | on the target node, during cloud-init runcmd (after the CLOUD_ENV_* exports, before runcmd: true scripts) | the node's actual runtime state (interface names, disks, the live CLOUD_ENV_*) |
Use execute: remote when the content depends on facts only known on the node — the classic case is a netplan whose VLAN parent interface name can change between hardware or boots:
#!/bin/bash
# 60-vswitch.yaml (.stat: execute: remote)
# Detect the default-route interface instead of hardcoding e.g. enp41s0.
set -euo pipefail
link="$(ip -o route get 1.1.1.1 | grep -oP 'dev \K\S+' | head -1)"
cat <<EOF
network:
version: 2
vlans:
${link}.4001:
id: 4001
link: ${link}
mtu: 1400
addresses: [10.0.1.10/24]
EOFHow it works under the hood: an execute: remote file is written to the node under a <path>.lok8s-gen name (so e.g. netplan, which only reads *.yaml, ignores the staged script at boot). A generated runcmd entry then runs the script and writes its stdout to the real <path>, applying the .stat owner/permissions, before any runcmd: true executables run.
Sub-configurations (modules)
The cloud.d/ directory holds composable modules. Select them via cloudInit.modules (colon-separated):
cloudInit:
path: ./cloud-init
modules: "docker:monitoring:security"The generator walks each module's directory first, then the root config. First occurrence of a file wins — modules can override the default.
Full cloudInit config
spec:
provider:
name: hetzner
config:
cloudInit:
path: ./cloud-init # config dir (default: built-in)
modules: "" # colon-separated cloud.d sub-configs
user: root # VM user (default: root)
group: root # VM group (default: root)
sshPubPath: ~/.ssh # dir with *.pub keys to inject (default: ~/.ssh)All fields are optional. Omitting cloudInit entirely uses the built-in default.
Environment variables
Server parameters from the provider config are available as CLOUD_ENV_* variables in write_files with envsubst: true and in runcmd scripts:
# In provider config:
config:
cluster_name: my-cluster
region: fsn1These are accessible as ${CLOUD_ENV_CLUSTER_NAME} and ${CLOUD_ENV_REGION} in write_files templates.
Preview
To preview what cloud-init will generate without provisioning:
# Source the generator
source .lok8s/providers/hetzner/cloud-config
# Set the config dir and generate
CLOUD_PATH=clusters/ci.lok8s.dev/cloud-init cloud-config::generateThis outputs the full #cloud-config YAML to stdout.