Skip to content

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:

  1. Reads provider.config.cloudInit from the cluster spec
  2. Sources the cloud-config generator at .lok8s/providers/hetzner/cloud-config
  3. Renders the config directory into a #cloud-config YAML
  4. 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 -x post-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 permissions

This 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:

yaml
spec:
  provider:
    name: hetzner
    config:
      cloudInit:
        path: ./cloud-init            # relative to clusters/<domain>/
        modules: "monitoring"          # colon-separated cloud.d sub-configs

Config directory structure

PathPurpose
packagesOne package name per line. Installed via apt-get install.
nameserversOne IP per line. Written to /etc/resolv.conf.
aptRaw 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>.statOptional 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:

yaml
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:

DirectiveRunsSees
execute: truelocally, when the cloud-config is generatedthe provisioning host's environment + CLOUD_ENV_*
execute: remoteon 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:

bash
#!/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]
EOF

How 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):

yaml
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

yaml
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:

yaml
# In provider config:
config:
  cluster_name: my-cluster
  region: fsn1

These 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:

bash
# 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::generate

This outputs the full #cloud-config YAML to stdout.

Released under the MIT License.