Skip to content
SHC Docs

Vultr

Operator guide for standing up an SHC cluster on Vultr cloud compute. Built from a live reference estate (region ewr, first applied 2026-07) — every quirk below was found on real hardware, not guessed.

ConcernValue
Terraform moduleterraform/modules/shc/vultr (front door)
Example rootterraform/examples/vultr-shc
Substrate providervultr/vultr ~> 2.0
SHC connection typevultr — CredFields api_key, region
DNS approachCloudflare-parent (Vultr DNS refuses subdomain zones — see gotchas)
DNS connection typecloudflare — CredFields token
Volume providervultr-volume (block storage; region-scoped)
Load-balancer providervultr-lb (SharedVIP; tcp/http/https only)
Node imageDebian 13 x64 (trixie) (x86 / amd64 plans only)
Credentials (env)VULTR_API_KEY, plus a Cloudflare token for DNS

One Vultr API key drives both the substrate (the vultr Terraform provider) and the daemon’s vultr connection (dns/proxy/volume). A Vultr API key is account-level — it cannot be scoped into per-surface sub-keys — so the daemon reuses the account key exactly as the DigitalOcean estate reuses its account token. There is no scoped daemon sub-credential the way AWS/GCP have.

  1. Vultr customer portal → Account → API.

  2. Enable API access and copy the Personal Access Token.

  3. Turn off the IP allow-list (portal: “Access Control”) — see the gotchas below. The default allow-list contains only your current IP, which silently 403s any CI runner or shared build host.

  4. Export it:

    Terminal window
    export VULTR_API_KEY=... # substrate + the vultr connection
    export TF_VAR_vultr_api_key="$VULTR_API_KEY"

    The vultr Terraform provider reads VULTR_API_KEY natively; the estate also wires the provider block from var.vultr_api_key so the same key feeds the SHC vultr connection with one source of truth.

Because Vultr DNS cannot host subdomain zones, managed DNS runs through Cloudflare on the parent zone. Mint a token scoped Zone:DNS:Edit on the one zone you will serve records under:

Terminal window
export TF_VAR_dns_token=... # Cloudflare API token, Zone:DNS:Edit

Keep both out of *.tfvars files and out of Terraform state variables — pass them as TF_VAR_* / environment only.

Two connections light the platform. The estate wires them as first-class shc_connection resources at system scope (provider ≥ 0.0.322), so the tenant-less cluster-host DNS surface resolves them directly:

# Proxy (vultr-lb) + volume (vultr-volume). Its DNS surface goes UNUSED —
# Vultr DNS can't host subdomain zones (see gotchas).
resource "shc_connection" "vultr" {
name = "vultr"
type = "vultr"
credentials = {
api_key = var.vultr_api_key # the same account key
region = module.shc.region # required by vultr-lb AND vultr-volume
}
}
# Managed DNS on the parent zone.
resource "shc_connection" "cloudflare" {
name = "cloudflare"
type = "cloudflare"
credentials = { token = var.dns_token }
zones = ["example.com"] # YOUR Cloudflare-hosted parent zone
}

Then the post-init defaults, one YAML document (provider ≥ 0.3.0):

resource "shc_config" "platform" {
yaml = <<-EOF
dns:
provider: cloudflare # the connection NAME
public_address: ${local.bootstrap_ip}
storage:
volumes:
provider: vultr # vultr-volume dispatch (carries region)
acme:
email: ops@example.com # arms public Let's Encrypt (DNS-01)
EOF
depends_on = [shc_connection.vultr, shc_connection.cloudflare]
}

region is required in the vultr connection credentials: both vultr-lb and vultr-volume are region-scoped and provision in it.

The obvious design — delegate vultr.example.com into Vultr DNS with a vultr_dns_domain + parent NS records — does not work. Vultr DNS accepts only registrable domains; a subdomain zone is rejected at create:

Error: error while creating domain :
{"error":"Unable to create domain: Subdomains are not permitted","status":400}

So every SHC estate on Vultr hosts its *.<sub>.example.com records as ordinary rows inside the Cloudflare parent zone — the same shape the Hetzner and DigitalOcean estates use. The vultr connection still lights vultr-lb and vultr-volume; only its DNS surface goes unused.

SHC writes only records for hosts it claims, each guarded by a companion ownership TXT — it never touches records it did not create, so pointing it at a zone you also use for other things is safe.

With dns.provider set and bootstrap.host seeded, the daemon self-registers the cluster-host A record (cloud.<sub>.example.com) every leader tick (~60s) and fails closed rather than publish a LAN address — there is no hand-managed shc_dns_record resource to maintain.

  • vultr-volume — block storage, region-scoped. Set storage.volumes.provider: vultr (as above); the daemon provisions, attaches (by node name), formats, and mounts a volume for any app declaring x-shc-volumes. Recreate caveat: Vultr assigns each node’s VPC plane IP itself (no cidrhost pinning on this substrate), so treat node replacement as remove-node + join-node, not an in-place recreate.
  • vultr-lb — a SharedVIP Load Balancer in front of the system Traefik. It is off by default; flip it on with an shc_stack_ports attach (vultr@80 / vultr@443). vultr-lb is tcp/http/https only — a UDP conn@ mapping is rejected at install. For a single-node or direct-ingress cluster you don’t need it: Traefik on 80/443 on the bootstrap node, with the cluster host pointed at that node’s IP, serves the edge directly.

Full worked root: terraform/examples/vultr-shc. It mirrors the live estate — one module "shc" (infra + bootstrap) plus the vultr + cloudflare connections and shc_config, with the DNS provider, base domain, and name prefix all exposed as variables so you can point it at your own single Cloudflare zone.

Terminal window
cd terraform/examples/vultr-shc
export VULTR_API_KEY=...
export TF_VAR_vultr_api_key="$VULTR_API_KEY"
export TF_VAR_dns_token=... # Cloudflare Zone:DNS:Edit token
tofu init # needs the provider network-mirror config (~/.tofurc); see below
tofu apply \
-var dns_zone=example.com \
-var cluster_name=shc-vultr \
-var acme_email=ops@example.com

tofu apply alone provisions the instances + VPC + firewall groups, installs the .deb and runs shc init / shc node join over the provider’s SSH transport, wires both connections and the platform config, and (via the demo shc_deployment in the example) installs a public whoami app that gets a real Let’s Encrypt certificate through Cloudflare DNS-01.

Verify:

Terminal window
tofu output -raw ssh_private_key > /tmp/vultr-key && chmod 600 /tmp/vultr-key
ssh -i /tmp/vultr-key admin@"$(tofu output -json node_public_ipv4s | jq -r '.[0]')" \
'sudo shc cluster status; sudo shc connection ls'
curl -fsS https://whoami.example.com/ # 200 + request dump
echo | openssl s_client -connect whoami.example.com:443 \
-servername whoami.example.com 2>/dev/null | openssl x509 -noout -issuer # Let's Encrypt

Provider mirror: terraform-provider-shc is not on the public registry. tofu init needs TF_CLI_CONFIG_FILE / ~/.tofurc pointing at the SHC network mirror (https://<your-group>-<project>.gitlab.io); a bare tofu init fails with registry.opentofu.org does not have a provider named .../shc. See the repo’s PUBLISHING.md.

Each of these was found on a real Vultr apply. Symptom first, then the fix.

1. The stock image ships UFW enabled — ICMP passes, TCP is dropped

Section titled “1. The stock image ships UFW enabled — ICMP passes, TCP is dropped”

Symptom: the cluster comes up, ping between nodes works, the VPC plane looks healthy — but shc node join times out (X500023JNFAIL) against the 4003 enrollment endpoint, and every other cross-node TCP dial (raft, swarm) hangs. ping lies: Vultr’s Debian images ship UFW default-deny inbound with only 22/tcp allowed; ICMP is accepted (so the host answers ping) but all other TCP is silently dropped.

Fix: disable UFW in provisioning. The terraform/modules/shc/vultr cloud-init template now does this in bootcmd; the module’s vultr_firewall_group is the single inbound filter and the VPC plane is unfiltered at the cloud level. If you provision by hand, run ufw --force disable && systemctl disable --now ufw and confirm ufw status reports inactive on every node before joining.

2. Vultr DNS refuses subdomain zones → Cloudflare-parent

Section titled “2. Vultr DNS refuses subdomain zones → Cloudflare-parent”

Symptom: creating a vultr_dns_domain for sub.example.com fails with 400 "Subdomains are not permitted".

Fix: don’t delegate a subdomain into Vultr DNS. Host the records in the parent zone’s own provider (Cloudflare) and point the daemon at a cloudflare connection instead — see DNS above. This is the documented default estate shape on Vultr.

3. vultr_ssh_key trailing-newline perpetual diff

Section titled “3. vultr_ssh_key trailing-newline perpetual diff”

Symptom: every tofu plan shows a benign in-place update to the vultr_ssh_key resource, forever, even with no change.

Fix: trimspace() the key material. tls_private_key’s public_key_openssh carries a trailing \n, but the Vultr API returns the stored key normalized without it — so the values never compare equal. The module already wraps ssh_key in trimspace(); do the same in any hand-rolled vultr_ssh_key.

4. The API key is IP-allow-listed by default

Section titled “4. The API key is IP-allow-listed by default”

Symptom: Terraform / the daemon get 403 from the Vultr API from a CI runner or shared build host, even though the key is valid — it works from your laptop but not from automation.

Fix: in the Vultr portal (Account → API → Access Control) set the allow-list to allow-all (0.0.0.0/0), or add the runner’s egress ranges. Shared CI runners have non-allowlistable, rotating egress IPs, so allow-all is the practical setting for automation. (Rotate/retire the key when the estate is torn down.)

5. Plane NIC names and no plane-IP pinning

Section titled “5. Plane NIC names and no plane-IP pinning”

Symptom (minor): the VPC interface is enp8s0 (public is enp1s0) on Debian 13 — set nic_name if your plan/image differs. And because Vultr assigns the VPC address (no cidrhost pinning), a node recreate may hand it a different plane IP that stored membership won’t follow.

Fix: the module derives enp8s0/enp1s0 automatically (override via var.nic_name). Treat node replacement as remove + rejoin, not in-place recreate.

Backups to Vultr Object Storage are not wired by the module — a known gap. Vultr Object Storage is S3-compatible, so restic can target s3:<region>.vultrobjects.com/<bucket>/shc with backup.aws_access_key_id / backup.aws_secret_access_key set to a Vultr Object Storage key pair. But those keys are minted per-object-store in the Vultr console, not through the Terraform provider, and the same keys are needed just to create the bucket — so there is nothing to wire from Terraform. Provision the bucket + key by hand and set the backup.* config if you need off-cluster backups.