Skip to content
SHC Docs

OpenStack (OVH)

SHC runs on plain OpenStack (Nova + Neutron): one private Neutron network + subnet + router to the external network, Nova instances on explicit ports with pinned plane IPs, and floating IPs for the public edge. The reference substrate is OVH US Public Cloud (region US-WEST-OR-1), where this module was first proven live on 2026-07-13.

Live-verified once — OVH US Public Cloud, Debian 13, 2026-07-13. Everything the module assumes about a new OpenStack cloud (image names, the external network name, the plane NIC, whether a metadata service exists) is deployment-specific — see First-live gotchas and the module README (terraform/modules/shc/openstack/README.md) before trusting the defaults on a different cloud.

The end-to-end example this page walks through lives in terraform/examples/openstack-shc/.


OVH’s OpenStack authenticates with a Keystone v3 application credential — a project-scoped, revocable credential that carries its own scope, so you pass no username / password / project / domain. Mint one in the OVH panel (or openstack application credential create) and you get an id and a secret (the secret is shown once).

whatwhere
Auth endpointhttps://auth.cloud.ovh.us/v3 (OVH US Keystone v3)
RegionUS-WEST-OR-1
Application credential idnon-secret, but pass it alongside the secret
Application credential secretsecret — never in tfvars/state/handoff

The terraform-provider-openstack provider reads them from the provider block, which in turn reads variables — pass those as environment variables, never a tfvars file:

Terminal window
export TF_VAR_openstack_application_credential_id=xxxxxxxxxxxxxxxx
export TF_VAR_openstack_application_credential_secret=yyyyyyyyyyyyyyyy
# Cloudflare token for the SHC DNS connection (see §2)
export TF_VAR_cloudflare_token=zzzzzzzzzzzzzzzz

The provider block itself is:

provider "openstack" {
auth_url = "https://auth.cloud.ovh.us/v3"
region = "US-WEST-OR-1"
application_credential_id = var.openstack_application_credential_id
application_credential_secret = var.openstack_application_credential_secret
}

The auth endpoint, region, and project id are non-secret coordinates — hardcode them as locals (the way the AWS root hardcodes its account id). Only the id + secret ride as variables.

The classic OpenStack OS_* env / clouds.yaml flow works too (OS_AUTH_URL, OS_APPLICATION_CREDENTIAL_ID, OS_APPLICATION_CREDENTIAL_SECRET, OS_REGION_NAME, or OS_CLOUD), which the provider reads with an empty provider "openstack" {} block. The example uses explicit TF_VAR_* so the same variables feed both the provider and any tooling.


OpenStack here does not drive its own DNS. OVH’s OpenStack has no Designate service — verified live against the US-WEST-OR-1 Keystone catalog: the service list is compute / network / volumev3 / load-balancer / image / object-store / key-manager / placement / share, with no dns entry. There is nothing for a designate-type shc_connection to talk to.

So SHC’s DNS runs through a Cloudflare connection into the parent zone — the same shape the hetzner/exoscale estates use:

resource "shc_connection" "cloudflare" {
name = "cloudflare"
type = "cloudflare"
credentials = { token = var.cloudflare_token } # CredFields for type "cloudflare" = { token }
zones = ["example.com"] # the PARENT zone, not a subdomain
depends_on = [module.shc]
}
resource "shc_config" "platform" {
yaml = <<-EOF
dns:
provider: cloudflare # the connection NAME above
public_address: ${local.bootstrap_ip}
acme:
email: ops@example.com # arms public Let's Encrypt
EOF
depends_on = [shc_connection.cloudflare]
}
itemvalue
connection typecloudflare
CredFields{ token } — a Cloudflare API token scoped Zone:DNS:Edit on the zone (never the global key)
zonesthe parent Cloudflare zone (e.g. example.com), not openstack.example.com
DNS approachCloudflare-parent, records written straight into the parent zone. No NS sub-delegation, no separate DNS-zone resource, no cloudflare terraform provider.

Why the parent zone, not a subdomain? Cloudflare is a flat zone — openstack.example.com is a subdomain within example.com, not a separate zone the way an Azure DNS child zone is. The daemon writes the subdomain’s records directly into the parent (records resolve by longest-suffix match), so zones = ["example.com"]. Contrast the azure/gcp estates, which delegate a per-cloud subdomain into a cloud-native zone via NS records and point dns.provider at that cloud.

The connect-time prober validates the token and confirms zone visibility — a bad token or an unlisted zone fails loudly at apply, not as a silent runtime zone-miss. Provider ≥ 0.3.0 creates the connection at system scope, so the cluster-host DNS surface and default-tenant apps resolve it directly (no per-tenant duplicate needed).

The module’s topology block is dead weight here. The openstack module’s one-connection topology block models a single cloud connection driving DNS + LB + volume together (the Designate style) — which cannot split DNS onto Cloudflare. So this estate leaves topology = null, keeps manage_platform = true, and wires the platform first-class with the root shc_* resources above (the hetzner pattern). There is no OpenStack-type shc_connection at all: no Designate (DNS → Cloudflare), and Cinder/Octavia are deferred (no volume/proxy connection), so the daemon needs only the Cloudflare connection for DNS + Let’s Encrypt.


The example folds infra + bootstrap + platform into one tofu apply.

Terminal window
cd terraform/examples/openstack-shc
# 1. credentials (see §1) + the Cloudflare token (see §2)
export TF_VAR_openstack_application_credential_id=...
export TF_VAR_openstack_application_credential_secret=...
export TF_VAR_cloudflare_token=...
# 2. point it at YOUR cloudflare-hosted domain + a name prefix
cat > terraform.tfvars <<'EOF'
cluster_name = "shc-openstack"
external_network_name = "Ext-Net" # `openstack network list --external`
image_name = "^Debian 13$" # `openstack image list | grep -i debian`
flavor_name = "b3-8" # `openstack flavor list` (x86, disk > 0)
dns_zone = "example.com" # your Cloudflare zone
test_domain = "openstack.example.com" # base for app + cluster hosts
ssh_public_key = "ssh-ed25519 AAAA... you@host"
ssh_private_key_path = "~/.ssh/id_ed25519"
shc_host = "cloud.openstack.example.com"
vault_pw = "" # leave "" for infra-only; set to bootstrap
EOF
tofu init
tofu apply

What one apply builds:

  1. Infra — Nova instances on explicit Neutron ports (pinned plane IPs), one network + subnet + router to Ext-Net, per-group security groups, one floating IP per node.
  2. Bootstrap — the shc .deb installed on every node over the provider’s SSH transport, shc init on the bootstrap node, shc node join for the rest.
  3. Platform — the Cloudflare shc_connection + shc_config (dns.provider / acme.email), applied over SSH against the bootstrap node.
  4. Apps — the example installs whoami (public, over the edge) and postgres (internal) from the official catalog to prove the DNS + Let’s Encrypt path.

The bootstrap node is the first node (index 0) of the alphabetically-first node_groups key. Its floating IP is the SSH target, the DNS target, and every node’s seeded public_address. The cluster-host A record (cloud.<domain>) is self-registered by the daemon’s proxy-reconcile watcher every leader tick once dns.provider and bootstrap.host are set (provider ≥ 0.3.0) — no hand-managed DNS resource.

Verify:

Terminal window
ssh admin@$(tofu output -raw bootstrap_ip) 'sudo -n shc cluster status; sudo -n shc connection ls'
dig +short whoami.openstack.example.com @1.1.1.1
openssl s_client -connect $(tofu output -raw bootstrap_ip):443 \
-servername whoami.openstack.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -issuer # -> issuer= ... Let's Encrypt ...

Each of these was found on the first live apply (OVH US, 2026-07-13) and is now handled by the module — but the symptom is worth recognizing on a new cloud.

Per-subnet dnsmasq REFUSES recursion without dns_nameservers

Section titled “Per-subnet dnsmasq REFUSES recursion without dns_nameservers”

Symptom. cloud-init’s apt phase dies on Could not resolve 'deb.debian.org'; docker never installs; the provider’s bootstrap dies in the substrate-prep gate (exit 127 on the docker compose version probe). Deceptively, resolv.conf looks healthy and resolvectl shows DNS servers — but every recursive query answers REFUSED (rcode 5). OVH’s Neutron DHCP dnsmasq hands itself out as the resolver (DHCP option 6 = the agent ports at .2/.3) yet refuses to recurse when the subnet carries no dns_nameservers.

Fix. The module defaults networking.dns_nameservers to public resolvers (["1.1.1.1", "8.8.8.8"]) — it updates the subnet in place, no instance replacement. Pass [] explicitly only to fall back to the deployment’s dnsmasq. On a new cloud, verify DNS actually resolves (getent hosts deb.debian.org), not just that resolv.conf is populated.

Symptom. A designate-type connection (or the module’s topology DNS block) has nothing to talk to — the Keystone catalog has no dns service.

Fix. Drive DNS through a cloudflare shc_connection into the parent zone and set dns.provider: cloudflare (see §2). Leave topology = null, keep manage_platform = true, and wire the platform with root shc_* resources.

Symptom. A loose regex like debian.*13 also matches Debian 13 - UEFI (a different boot path).

Fix. Anchor it: image_name = "^Debian 13$" resolves to the expected Glance image on OVH. On a new cloud, openstack image list and either anchor the regex or pin image_id.

Symptom. external_network_name is required; a wrong name fails at plan/apply on the data lookup.

Fix. openstack network list --external — on OVH it is Ext-Net (shared, router:external=true, the floating-IP pool).

Symptom. The plane NIC hint defaults to ens3 (the first virtio interface on KVM/q35 Debian); a different hypervisor/machine-type can name it otherwise.

Fix. ip a on first boot; set nic_name if it differs. On OVH it is ens3.

Symptom. cloud-init never ran → the node comes up unprovisioned.

Fix. The module assumes a Nova metadata service (OVH runs one; config_drive stays false). On a cloud with no metadata proxy on the custom subnet, set config_drive = true.

Flavors need a local disk (or boot-from-volume)

Section titled “Flavors need a local disk (or boot-from-volume)”

Symptom. A disk-less flavor has no root disk to boot from.

Fix. Pick an x86 flavor with disk > 0 (OVH b3-8 / d2-4 both ship a 50 GiB local disk, so no boot-from-volume is needed). For disk-less flavors set root_volume.size (or per-group root_volume_size) — that pulls Cinder in (deferred, see below).


capabilitystatus on OpenStack
Volume providernone registered — apps use LOCAL docker volumes on the node disk. OVH exposes Cinder (volumev3 in the catalog), but there is no SHC Cinder volume provider today, so storage.volumes.provider is left unset.
Load-balancer / proxy providernone registered — ingress rides the bootstrap node’s floating IP directly (the self-registered cluster-host record points at it). OVH exposes Octavia (load-balancer in the catalog), but there is no SHC Octavia proxy provider today.
Backup (restic)not wired by default. OVH object-store (Swift, S3-compatible) is the natural restic target when the gap is closed.

To close the volume gap: register a Cinder provider in the daemon’s volumeBuilders, add an openstack-type shc_connection carrying the application credential, then set storage.volumes.provider to that connection name. To close the LB gap: register an Octavia proxy provider, then attach the system traefik’s 80/443 through it. Until then, boot flavors with a local disk (§ gotchas) and reach ingress on the bootstrap floating IP.