Skip to content
SHC Docs

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 every shc_* resource. No cloud provider in required_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 the nodes list, and calls core. hostinger is 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
# ...
}

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).

LayerGateWhat it creates
bootstrapbootstrap != nullthe 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.)
topologytopology != 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).
NameTypeDefaultRequiredDescription
nodeslist(object)yesEvery node, bootstrap-first. Each: {name, public_ipv4, private_ip?, runtime, group, index, nic, public_address?}. private_ip is null on a public-plane cluster.
sshobjectnullwith bootstrapThe 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).
bootstrapobjectnullnoBootstrap config (see below). null skips the bootstrap layer (nodes provisioned but no shc_* resource touched).
topologyobjectnullnoPlatform/topology config, dispatched by kind (see below). Requires bootstrap != null.

bootstrap object:

FieldTypeDefaultDescription
packageobject {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_passwordstring— (required)Seals the system vault; the SSH engine hard-errors when empty. Treated as sensitive.
nebulaboolfalseshc init --nebula on the bootstrap node (needs nebula binaries on PATH — the substrate stages them).
fabricstring""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_cidrstring""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.
hoststring""The public domain seed (shc init --host) — NOT an address.
acmeobjectNamed ACME providers + a cluster default: providers (map of {directory, email, eab_kid, eab_hmac}), default, email. Blank directory = Let’s Encrypt production.
modulesmap(object)System-app selection + per-app overrides ({host?, base_path?, vars?}). Key set = install selection: unset = daemon default, {} = none, populated = exactly those.

topology object:

FieldTypeDefaultDescription
kindstring— (required)"lan", "wan", or "lb".
dnsobject{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_addressstring""Cluster public entrypoint address (lan/lb DNS).
mesh_enabledbooltrueFor “wan”: whether the mesh is enabled.
lb_portsmap(object)For “lb”: extra LB port attaches, keyed by "external[/udp]", value {container?, service?} (service default traefik).
volume_providerobjectFor “lb”: {provider, config?} — the block-volume storage provider config.
volume_secretsmap(string){}For “lb”: secret keys for the volume provider.
NameDescription
cluster_endpointThe daemon API endpoint. null unless bootstrap != null.
cluster_ca_pemThe 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. core never 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.


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.

NameTypeDefaultRequiredDescription
cluster_namestringyesCluster name; every node is "<role><NN>.<cluster_name>"; also prefixes and labels every hcloud resource.
regionstring"fsn1"noDefault Hetzner location. Per-group override: node_groups.<key>.region.
imagestring"debian-13"noDefault OS image (keep a Debian image). Per-group override available.
create_ssh_keyboolfalsenoMint 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_keystringnullno*SSH public key. create_ssh_key = false: required (BYO). create_ssh_key = true: optional break-glass key.
extra_authorized_keyslist(string)[]noExtra public keys appended to the admin user via cloud-init. Create-time on hcloud_server — changing later REPLACES servers.
nic_namestringnullnoOverride the auto-derived plane NIC hint (private ? "enp7s0" : "eth0"). Per-group override available.
labelsmap(string){}noExtra labels merged onto every hcloud resource.
estatestringnullnoOrg-estate marker stamped as estate=<name> on every resource.
install_nebulaboolnullnoForce nebula binaries into cloud-init regardless of bootstrap/topology. null derives it from bootstrap.nebula / topology.kind == "wan". Set explicitly with manage_platform = false.
sshobjectnullwith bootstrapPassed 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_groupsmap(object)yesNode 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.
networkingobject{}noONE 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_groupbooltruenoOwn an hcloud “spread” placement group (anti-affinity) named cluster_name.
nebula_versionstring"1.9.5"noslackhq/nebula release staged by cloud-init when a node needs it.
bootstrapobjectnullnoForwarded 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.
topologyobjectnullnoForwarded to core unchanged. Requires bootstrap != null.
manage_platformbooltruenotrue: call core (full front door). false: INFRA-ONLY — build servers/network/firewall, expose nodes, touch no shc_* resource (mixed-cluster split).
protectedboolfalsenoDelete/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.

NameDescription
cluster_nameEchoes var.cluster_name.
bootstrap_nodeThe shc init node: {name, public_ipv4, private_ip}.
nodesEvery 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_runtimesFlat per-node lists, same order as nodes.
nic_namePlane NIC hint for --nic.
cluster_endpointDaemon API endpoint. null unless manage_platform = true AND bootstrap != null.
cluster_ca_pemCluster global-root CA PEM (same null condition).
regionThe default location.
network_idThe cluster network id (or null when private = false).
firewall_ids{cluster, groups} firewall ids.
ssh_public_keyThe cluster key’s public half (generated or echoed BYO).
ssh_private_keyThe GENERATED private key (sensitive; null for BYO).
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 } }
}
}

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:

CloudPlacement inputInstance size keyExtra infra inputs
hetznerregionserver_typelabels, placement_group, networking.zone/subnet
awsavailability_zoneinstance_typeami/ami_name_filter/ami_owner, root_volume, assign_eips, iam_instance_profile, tags
gcpzonemachine_type*image/image_family/image_project, root_volume, assign_static_ips, service_account, labels
azureresource group / regionVM size*image reference, VNet/subnet
digitaloceanregiondroplet size*image, assign_reserved_ips, tags
linoderegioninstance type*image, region-scoped VPC
scaleway / vultr / exoscale / alibaba / cloudstack / openstackzone/regionprovider-native sizeprovider-native network + IP assignment
hostingerdata_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
proxmoxproxmox_nodecores / memory / disk_sizetemplate_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.