Module library
Two reusable modules build on the shc provider:
core— the platform-agnostic layer. Takes a node list (from any substrate) plus bootstrap/topology config and creates everyshc_*resource. No cloud provider inrequired_providers.shc/<cloud>— 14 per-substrate front doors (aws, azure, gcp, hetzner, proxmox, digitalocean, linode, scaleway, vultr, exoscale, alibaba, cloudstack, openstack, hostinger). Each builds the cloud infra (instances, network, firewall, keys), assembles thenodeslist, and callscore.hostingeris the outlier: the substrate has no network, firewall or volume tier to build at all, so it builds only nodes + keys + a prep script.
Reference a published module by its Pages tarball URL (the version lives IN
the URL — go-getter archive fetch, no version argument), or by a local
relative path when developing in-tree:
module "shc" { source = "https://<PAGES-URL>/modules/shc-hetzner-0.3.0.tgz" # published # source = "../../modules/shc/hetzner" # in-tree # ...}The core module
Section titled “The core module”source = ".../modules/core" (or the core-shc-<version>.tgz tarball).
Turns a node list ANY substrate already provisioned into a running cluster.
The node model is symmetric — every node is an shc_node:
shc_node.bootstrap (on nodes[0], runs shc init and emits the cluster
creds) plus one shc_node.workers per remaining node (join) — alongside a
pure-config shc_cluster genesis record and the topology resources
(shc_connection / shc_config / shc_mesh / shc_stack_ports). Everything
applies over the provider’s SSH transport (no bearer token, no
driver.sh). required_providers is shc only.
nodes[0] is always the bootstrap node — order the list yourself (the front
doors put the bootstrap node first: the first node of the alphabetically-first
node_groups key).
Two gated layers
Section titled “Two gated layers”| Layer | Gate | What it creates |
|---|---|---|
| bootstrap | bootstrap != null | the pure-config shc_cluster, shc_node.bootstrap (SSH init) on nodes[0], one shc_node.workers (SSH join) per remaining node. (A podman bootstrap node must be single-node — a check block enforces it.) |
| topology | topology != null (requires bootstrap) | dispatched by topology.kind: "lan" → optional internal DNS shc_connection/shc_config; "wan" → shc_mesh; "lb" → proxy shc_connection + dns.provider/ACME + optional volume config + shc_stack_ports (traefik LB attach). |
Inputs
Section titled “Inputs”| Name | Type | Default | Required | Description |
|---|---|---|---|---|
nodes | list(object) | — | yes | Every node, bootstrap-first. Each: {name, public_ipv4, private_ip?, runtime, group, index, nic, public_address?}. private_ip is null on a public-plane cluster. |
ssh | object | null | with bootstrap | The install/join/admin credential — one key for every node. Fields: user (default admin), private_key, private_key_path, port, host_key. Set exactly ONE of private_key (material) or private_key_path (local file). |
bootstrap | object | null | no | Bootstrap config (see below). null skips the bootstrap layer (nodes provisioned but no shc_* resource touched). |
topology | object | null | no | Platform/topology config, dispatched by kind (see below). Requires bootstrap != null. |
bootstrap object:
| Field | Type | Default | Description |
|---|---|---|---|
package | object {deb_url, version?} | — (required) | The SHC package; flows to shc_cluster and every worker. Changing version/deb_url on a live cluster does an in-place rolling upgrade, one node at a time. |
vault_password | string | — (required) | Seals the system vault; the SSH engine hard-errors when empty. Treated as sensitive. |
nebula | bool | false | shc init --nebula on the bootstrap node (needs nebula binaries on PATH — the substrate stages them). |
fabric | string | "" | The cluster’s cross-node data plane (shc init --fabric): "swarm" (docker overlay — today’s model, made explicit), "shc" (routed WireGuard), or "none". "" omits the flag entirely, so every pre-fabric config plans byte-identically. "shc" EXCLUDES the swarm runtime — a node group running runtime = "swarm" is refused at plan time, so pick compose/podman for every group. |
fabric_cidr | string | "" | fabric = "shc" only: the supernet per-node /24s are carved from (--fabric-cidr; daemon default 10.84.0.0/16). Pick one that avoids the k3s/docker ranges on shared hosts — renumbering later re-renders the whole cluster. |
host | string | "" | The public domain seed (shc init --host) — NOT an address. |
acme | object | — | Named ACME providers + a cluster default: providers (map of {directory, email, eab_kid, eab_hmac}), default, email. Blank directory = Let’s Encrypt production. |
modules | map(object) | — | System-app selection + per-app overrides ({host?, base_path?, vars?}). Key set = install selection: unset = daemon default, {} = none, populated = exactly those. |
topology object:
| Field | Type | Default | Description |
|---|---|---|---|
kind | string | — (required) | "lan", "wan", or "lb". |
dns | object | — | {name, type, zones?, credentials?}. Required when kind == "lb" (type becomes the LB connection’s provider_type); optional for “lan”; unused for “wan”. Give the LB a project-unique name (the shared VIP is shc-<name>). |
public_address | string | "" | Cluster public entrypoint address (lan/lb DNS). |
mesh_enabled | bool | true | For “wan”: whether the mesh is enabled. |
lb_ports | map(object) | — | For “lb”: extra LB port attaches, keyed by "external[/udp]", value {container?, service?} (service default traefik). |
volume_provider | object | — | For “lb”: {provider, config?} — the block-volume storage provider config. |
volume_secrets | map(string) | {} | For “lb”: secret keys for the volume provider. |
Outputs
Section titled “Outputs”| Name | Description |
|---|---|
cluster_endpoint | The daemon API endpoint. null unless bootstrap != null. |
cluster_ca_pem | The cluster’s global-root CA PEM. null unless bootstrap != null. |
module "core" { source = "../../modules/core"
nodes = [ { name = "n1", public_ipv4 = "198.51.100.10", private_ip = null, runtime = "compose", group = "control", index = 0, nic = "eth0" }, { name = "n2", public_ipv4 = "198.51.100.11", private_ip = null, runtime = "compose", group = "control", index = 1, nic = "eth0" }, ]
ssh = { user = "admin", private_key_path = "~/.ssh/id_ed25519" }
bootstrap = { package = { deb_url = "https://.../shc_..._linux_amd64.deb" } vault_password = var.vault_pw host = "cloud.example.com" }
topology = { kind = "lb" dns = { name = "hetzner", type = "hetzner", zones = ["example.com"], credentials = { token = var.hcloud_token } } acme = { } # ACME is configured via bootstrap.acme }}Real deployments usually drive core through a substrate front door rather
than calling it directly. Call it directly for the mixed-substrate shape:
call each front door with manage_platform = false, concat() their nodes
outputs, and call one core at the root over the combined list (see the
mixed-proxmox-hetzner example).
Ownership boundary.
corenever manages DNS records for the cluster’s own public hostname, and the mesh always designates the current raft leader as the Nebula lighthouse — bootstrap on the node you want to be leader.
The shc/<cloud> front doors
Section titled “The shc/<cloud> front doors”source = ".../modules/shc/<cloud>" — one module per substrate. Each creates
the cloud’s infra (instances, network/VPC, firewall/security groups, SSH
keys), builds the platform-agnostic nodes list, and calls core (unless
manage_platform = false — infra-only). They share a common contract;
the infra-shaping inputs differ per cloud (instance sizing, region/zone
naming, root volume, IP assignment).
Documented below in full for shc/hetzner (the canonical front door); the
cross-substrate variance table lists what changes
elsewhere.
shc/hetzner inputs
Section titled “shc/hetzner inputs”| Name | Type | Default | Required | Description |
|---|---|---|---|---|
cluster_name | string | — | yes | Cluster name; every node is "<role><NN>.<cluster_name>"; also prefixes and labels every hcloud resource. |
region | string | "fsn1" | no | Default Hetzner location. Per-group override: node_groups.<key>.region. |
image | string | "debian-13" | no | Default OS image (keep a Debian image). Per-group override available. |
create_ssh_key | bool | false | no | Mint the cluster keypair IN Terraform (ED25519) instead of bringing one; the private key feeds core’s SSH transport and lands in state (read it via ssh_private_key). Flipping on an existing cluster REPLACES every node. |
ssh_public_key | string | null | no* | SSH public key. create_ssh_key = false: required (BYO). create_ssh_key = true: optional break-glass key. |
extra_authorized_keys | list(string) | [] | no | Extra public keys appended to the admin user via cloud-init. Create-time on hcloud_server — changing later REPLACES servers. |
nic_name | string | null | no | Override the auto-derived plane NIC hint (private ? "enp7s0" : "eth0"). Per-group override available. |
labels | map(string) | {} | no | Extra labels merged onto every hcloud resource. |
estate | string | null | no | Org-estate marker stamped as estate=<name> on every resource. |
install_nebula | bool | null | no | Force nebula binaries into cloud-init regardless of bootstrap/topology. null derives it from bootstrap.nebula / topology.kind == "wan". Set explicitly with manage_platform = false. |
ssh | object | null | with bootstrap | Passed through to core (install+join + bootstrap-node platform transport). Fields: user (default admin), private_key, private_key_path, port, host_key. Ignored when create_ssh_key = true. |
node_groups | map(object) | — | yes | Node pools → one cluster. Per entry: count, server_type, runtime (compose/swarm/podman), and optional node_names, region, image, nic_name, firewall ({public_ports}). Bootstrap node = index 0 of the alphabetically-first key. |
networking | object | {} | no | ONE shared private network: private (default true), ip_range (default 10.10.0.0/16), subnet (set = create mode; unset = link an existing network), zone (default eu-central). |
placement_group | bool | true | no | Own an hcloud “spread” placement group (anti-affinity) named cluster_name. |
nebula_version | string | "1.9.5" | no | slackhq/nebula release staged by cloud-init when a node needs it. |
bootstrap | object | null | no | Forwarded to core unchanged (see the core table). One hcloud-side effect: bootstrap.fabric = "shc" ALSO opens UDP 4243 (the fabric’s WireGuard listener) node-to-node on the plane firewall — scoped to the cluster’s own node addresses, never 0.0.0.0/0. |
topology | object | null | no | Forwarded to core unchanged. Requires bootstrap != null. |
manage_platform | bool | true | no | true: call core (full front door). false: INFRA-ONLY — build servers/network/firewall, expose nodes, touch no shc_* resource (mixed-cluster split). |
protected | bool | false | no | Delete/termination protection for the estate’s iron. Flip true once live; tofu destroy fails until reverted. (No-op on some clouds.) |
* ssh_public_key is required only when create_ssh_key = false.
shc/hetzner outputs
Section titled “shc/hetzner outputs”| Name | Description |
|---|---|
cluster_name | Echoes var.cluster_name. |
bootstrap_node | The shc init node: {name, public_ipv4, private_ip}. |
nodes | Every node across all groups, ordered bootstrap-first — the core nodes input verbatim (concat for mixed clusters). |
node_names / node_public_ipv4s / node_private_ips / node_runtimes | Flat per-node lists, same order as nodes. |
nic_name | Plane NIC hint for --nic. |
cluster_endpoint | Daemon API endpoint. null unless manage_platform = true AND bootstrap != null. |
cluster_ca_pem | Cluster global-root CA PEM (same null condition). |
region | The default location. |
network_id | The cluster network id (or null when private = false). |
firewall_ids | {cluster, groups} firewall ids. |
ssh_public_key | The cluster key’s public half (generated or echoed BYO). |
ssh_private_key | The GENERATED private key (sensitive; null for BYO). |
shc/hetzner usage
Section titled “shc/hetzner usage”module "shc" { source = "../../modules/shc/hetzner"
cluster_name = "cloud" ssh_public_key = var.ssh_public_key ssh = { user = "admin", private_key_path = var.ssh_private_key_path }
node_groups = { control = { count = 3, server_type = "cx33", runtime = "compose" } workers = { count = 2, server_type = "cx43", runtime = "swarm", region = "nbg1" } }
networking = { private = true, ip_range = "10.10.0.0/16", subnet = "10.10.1.0/24" }
bootstrap = { package = { deb_url = var.deb_url } vault_password = var.vault_pw host = "cloud.example.com" acme = { providers = { prod = { email = "ops@example.com" } }, default = "prod" } }
topology = { kind = "lb" dns = { name = "hetzner", type = "hetzner", zones = ["example.com"], credentials = { token = var.hcloud_token } } }}Cross-substrate variance
Section titled “Cross-substrate variance”Every front door shares these inputs: cluster_name, create_ssh_key,
ssh_public_key, extra_authorized_keys, nic_name, estate, protected,
install_nebula, ssh, node_groups, networking (where a private plane
exists), firewall, nebula_version, bootstrap, topology,
manage_platform — and the same outputs (nodes, bootstrap_node,
cluster_endpoint, cluster_ca_pem, node-list outputs, ssh_*).
What differs is the infra shaping:
| Cloud | Placement input | Instance size key | Extra infra inputs |
|---|---|---|---|
hetzner | region | server_type | labels, placement_group, networking.zone/subnet |
aws | availability_zone | instance_type | ami/ami_name_filter/ami_owner, root_volume, assign_eips, iam_instance_profile, tags |
gcp | zone | machine_type* | image/image_family/image_project, root_volume, assign_static_ips, service_account, labels |
azure | resource group / region | VM size* | image reference, VNet/subnet |
digitalocean | region | droplet size* | image, assign_reserved_ips, tags |
linode | region | instance type* | image, region-scoped VPC |
scaleway / vultr / exoscale / alibaba / cloudstack / openstack | zone/region | provider-native size | provider-native network + IP assignment |
hostinger | data_center (placement only) | plan — a PURCHASE SKU encoding the machine AND the billing term (hostingercom-vps-kvm1-usd-1m) | template_name/template_id, payment_method_id, accept_monthly_billing. NO network, firewall, volume or LB inputs — the substrate has none |
proxmox | proxmox_node | cores / memory / disk_size | template_name, storage, network_bridge, gateway, nameservers, vmid_base, ssh_user — no cloud-init user-data (the SSH engine installs the .deb) |
* The exact input name for instance sizing is declared inside each
node_groups object per cloud (e.g. server_type, instance_type,
machine_type). Read that module’s variables.tf (or its README) for the
precise per-cloud shape.
Each front door’s README documents its cloud-specific deltas and any
“unverified-live” caveats. The shared cloud-init contract (an admin user
with NOPASSWD sudo, host-firewall disable, extra-NIC DHCP fallback) is
byte-identical across every cloud-init substrate; gcp mirrors it in shell,
and proxmox rides the template image’s default admin user.