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/.
1. Credentials
Section titled “1. Credentials”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).
| what | where |
|---|---|
| Auth endpoint | https://auth.cloud.ovh.us/v3 (OVH US Keystone v3) |
| Region | US-WEST-OR-1 |
| Application credential id | non-secret, but pass it alongside the secret |
| Application credential secret | secret — 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:
export TF_VAR_openstack_application_credential_id=xxxxxxxxxxxxxxxxexport TF_VAR_openstack_application_credential_secret=yyyyyyyyyyyyyyyy# Cloudflare token for the SHC DNS connection (see §2)export TF_VAR_cloudflare_token=zzzzzzzzzzzzzzzzThe 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.yamlflow works too (OS_AUTH_URL,OS_APPLICATION_CREDENTIAL_ID,OS_APPLICATION_CREDENTIAL_SECRET,OS_REGION_NAME, orOS_CLOUD), which the provider reads with an emptyprovider "openstack" {}block. The example uses explicitTF_VAR_*so the same variables feed both the provider and any tooling.
2. The SHC connection & DNS approach
Section titled “2. The SHC connection & DNS approach”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]}| item | value |
|---|---|
connection type | cloudflare |
CredFields | { token } — a Cloudflare API token scoped Zone:DNS:Edit on the zone (never the global key) |
zones | the parent Cloudflare zone (e.g. example.com), not openstack.example.com |
| DNS approach | Cloudflare-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.
3. End-to-end deploy
Section titled “3. End-to-end deploy”The example folds infra + bootstrap + platform into one tofu apply.
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 prefixcat > 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 zonetest_domain = "openstack.example.com" # base for app + cluster hostsssh_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 bootstrapEOF
tofu inittofu applyWhat one apply builds:
- 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. - Bootstrap — the shc
.debinstalled on every node over the provider’s SSH transport,shc initon the bootstrap node,shc node joinfor the rest. - Platform — the Cloudflare
shc_connection+shc_config(dns.provider/acme.email), applied over SSH against the bootstrap node. - Apps — the example installs
whoami(public, over the edge) andpostgres(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:
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.1openssl 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 ...First-live gotchas
Section titled “First-live gotchas”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.
No Designate → Cloudflare-parent DNS
Section titled “No Designate → Cloudflare-parent DNS”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.
Image name must be anchored
Section titled “Image name must be anchored”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.
External network name has no default
Section titled “External network name has no default”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).
Plane NIC assumption
Section titled “Plane NIC assumption”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.
Metadata service vs config drive
Section titled “Metadata service vs config drive”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).
Volume & load-balancer providers
Section titled “Volume & load-balancer providers”| capability | status on OpenStack |
|---|---|
| Volume provider | none 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 provider | none 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.
Related
Section titled “Related”- Example root — the single-apply front door this page walks through
- Module README — the full input contract + the aws/hetzner delta table
- Live smoke test —
make test/openstack: provisions, waits healthy, installs a test app, asserts DNS + a valid Let’s Encrypt cert, tears down - Multi-node cluster setup