Exoscale
Exoscale is a European (Swiss) IaaS. SHC runs on
it as a plain compute substrate: a zone-scoped managed private network, per-group
security groups, and Debian compute instances, all provisioned by
terraform/modules/shc/exoscale and
bootstrapped by terraform-provider-shc over SSH.
This page is written from a live, proven estate (de-fra-1, an
exoscale/de-fra root kept in a private ops repo):
a 4-node cluster (one compose bootstrap/leader + three swarm nodes) serving
gitea over HTTPS with a real Let’s Encrypt certificate and Keycloak SSO. Where
Exoscale diverges from a “cloud-native everything” substrate it is called out
under First-live gotchas — every item there was found on
first apply and is now baked into the module or documented as a deferred gap.
One-line summary. Exoscale is a substrate-only cloud for SHC today: the daemon registers no Exoscale DNS, volume, or load-balancer backend. DNS rides a cloudflare-parent connection, volumes are node-local docker volumes, and ingress rides the bootstrap node’s own public IP.
At a glance
Section titled “At a glance”| Concern | Exoscale answer |
|---|---|
| Placement axis | zone only — Exoscale has no region layer. Instances, the private network, elastic IPs and the template lookup all ride ONE zone (de-fra-1, ch-gva-2, at-vie-1, …). |
| Substrate auth | Exoscale IAM API key + secret pair (the v2 API signs each request with EXO2-HMAC-SHA256, so both halves are required). |
| Boot image | template NAME resolved by data "exoscale_template". Default "Linux Debian 13 (Trixie) 64-bit" (live-verified). Keep a Debian, amd64 — the shc .deb is amd64-only. |
| SSH user | admin (a dedicated user cloud-init creates; root login is disabled). The provider dials admin and wraps every remote command in sudo -n. |
| Plane NIC | eth1 on a private-network cluster — but the stock template configures only eth0, so the module ships a networkd fallback unit (see gotchas). |
| DNS | cloudflare-parent — a cloudflare-type shc_connection (Exoscale DNS is a paid add-on; see below). |
| Volume provider | none — apps use node-local docker volumes. Exoscale block storage is not a wired SHC backend. |
| Load-balancer provider | none — ingress rides the bootstrap node’s public IP directly. |
| Backup target | Exoscale SOS (S3-compatible) is the natural restic target, but SHC restic wiring for this estate is a deferred gap. |
Credentials
Section titled “Credentials”Two credential sets are involved — the Exoscale substrate, and (because DNS is cloudflare-parent) a Cloudflare API token.
Exoscale IAM API key + secret
Section titled “Exoscale IAM API key + secret”- In the Exoscale portal open IAM → API Keys and create a key. For a full estate an unrestricted key is simplest; to scope it, grant compute-instance, private-network, security-group, elastic-IP, SSH-key and template-read operations.
- You get a key (public half, e.g.
EXO…) and a secret (the EXO2-HMAC-SHA256 signing key). Both are required.
The exoscale Terraform provider reads them natively from the environment, and SHC’s example/estate roots also wire them through variables for a single source of truth:
export EXOSCALE_API_KEY="EXO..."export EXOSCALE_API_SECRET="..."# Terraform variables (the example/estate roots read these):export TF_VAR_exoscale_api_key="$EXOSCALE_API_KEY"export TF_VAR_exoscale_api_secret="$EXOSCALE_API_SECRET"Pass them only as environment / CI variables — never in a .tfvars
file (they would land in the file, and the secret leaks into state exports).
Cloudflare API token (for DNS)
Section titled “Cloudflare API token (for DNS)”Because this estate pivoted DNS to Cloudflare (see
DNS approach), you also need a Cloudflare API
token scoped to Zone → DNS → Edit on the zone you point SHC at — never the
global key:
export TF_VAR_cloudflare_token="..." # feeds the SHC cloudflare connectionDNS approach: cloudflare-parent
Section titled “DNS approach: cloudflare-parent”SHC on Exoscale does NOT use Exoscale’s native DNS. The daemon’s exoscale
DNS provider is registered and ready (capability-wise), but Exoscale DNS is
a paid account add-on — an account without the subscription fails
CreateDNSDomain with Bad Request: No DNS subscription found, and nothing
Terraform does can enable it (the subscription is bought in the Exoscale portal
per-account). This was a first-apply pivot on the live estate (2026-07-13).
So the estate takes the same DNS shape as the hetzner/openstack estates: a
cloudflare-type shc_connection owning the parent zone, and
dns.provider = "cloudflare". The SHC daemon then mints and maintains records
itself (with ownership-TXT guards) through the Cloudflare API, and issues
Let’s Encrypt certs off the same connection.
Connection type | CredFields | Notes |
|---|---|---|
cloudflare (used here) | { token } | Scoped Zone:DNS:Edit token. zones is the parent Cloudflare zone (e.g. example.com), not a subdomain — Cloudflare is a flat zone, so records for an exo.example.com subdomain are written directly into the parent by longest-suffix match. |
exoscale (native, needs paid DNS) | { api_key, api_secret, zone } | The would-be native connection. Requires the Exoscale DNS subscription on the account. |
A couple of consequences worth internalizing:
- No NS sub-delegation, no separate DNS zone. Because the SHC connection
owns the parent Cloudflare zone directly, there is no
cloudflare_recordNS row and noexoscale_domainresource — Cloudflare is flat, and a subdomain is not a separate zone. - The cluster host record is self-registered. Once
dns.providerand the bootstraphostare set, the daemon’s proxy-reconcile watcher registers and maintains the cluster host’sArecord itself every leader tick (~60s), viaRegisterSystemHostDNS. You do not write a Terraform DNS resource for it. - Strict scope. SHC only writes records for hosts your apps explicitly claim (plus the cluster host). Point the token at a zone you are happy for SHC to manage — do not aim it at a zone carrying production records you own by hand.
Restoring the native Exoscale-DNS shape
Section titled “Restoring the native Exoscale-DNS shape”If your account does have the paid Exoscale DNS subscription and you want native DNS + NS sub-delegation instead:
- Add an
exoscale-typeshc_connectionwithcredentials = { api_key, api_secret, zone }andzones = ["exo.example.com"]. - Create the
exoscale_domainzone plus the four NS delegation rows in the parent zone (Exoscale serves every zone fromns1.exoscale.{io,ch,com,net}). - Point
shc_config’sdns.providerat the exoscale connection instead ofcloudflare.
The daemon’s exoscale DNS provider is already registered; only the account subscription and the Terraform blocks above are missing.
Volume & load-balancer providers
Section titled “Volume & load-balancer providers”Beyond DNS, connection type = "exoscale" registers no SHC backends —
Exoscale block-storage volumes and Exoscale NLB are an explicit gap (the same
disposition as scaleway / cloudflare / ovh). Concretely:
- Volumes:
storage.volumes.provideris left unset. Apps use node-local docker volumes on the instance root disk. Size the root disk accordingly — the estate runsroot_volume = { size = 100 }(GB). The module default is a tight 20 GB. - Load balancer: there is no LB attach. Ingress rides the bootstrap node’s own public IP (the self-registered cluster-host record points at it). Because there is no floating VIP in front of the cluster, treat the bootstrap (compose leader) node’s public IP as the edge.
- Backup: no restic wiring on this estate yet. Exoscale SOS (an
S3-compatible object store) is the natural
restictarget when you close the gap —backup.repotakes ans3:URL verbatim.
To close the volume/LB gaps you would register an Exoscale block-storage volume
provider (and/or an Exoscale NLB proxy provider) in the daemon, add an
exoscale-type connection, then wire storage.volumes.provider / a port
attach against it.
Minimal end-to-end deploy
Section titled “Minimal end-to-end deploy”A complete, parameterized root lives in
terraform/examples/exoscale-shc —
copy it and set your own zone, cloudflare domain and name prefix. The shape:
- Substrate + bootstrap (
module "shc"): compute instances, one zone-scoped private network, per-group security groups, thenshc initon the bootstrap node andshc node joinfor the rest — all over SSH. The module is pinned to a Pages-hosted tarball (the version lives in the URL; move it in lockstep with the provider pin). - Platform wiring (in the same root): a
provider "shc"SSH block against the bootstrap node, oneshc_connectionof typecloudflare, and oneshc_configsettingdns.provider = cloudflare+acme.email. - Apps: the official catalog via
shc_repo, plusshc_deploymentresources — e.g. a publicwhoami(proves DNS + LE) and a LAN-internalpostgres.
cd terraform/examples/exoscale-shc
export EXOSCALE_API_KEY=... EXOSCALE_API_SECRET=...export TF_VAR_exoscale_api_key="$EXOSCALE_API_KEY"export TF_VAR_exoscale_api_secret="$EXOSCALE_API_SECRET"export TF_VAR_cloudflare_token=... # Zone:DNS:Edit on your zoneexport TF_VAR_dns_zone=example.com # a zone you host on Cloudflareexport TF_VAR_acme_email=ops@example.com
tofu init
# First apply from empty: the shc provider's SSH host is a module output that# is unknown until the instances exist, so bring up the substrate first, then# apply the whole root (see the example README for why).tofu apply -target=module.shctofu applyAfter the apply, the daemon self-registers the cluster host record and issues
LE certs; whoami.<prefix>.<your-zone> resolves and serves a valid Let’s
Encrypt certificate.
The live estate splits this into two roots (
infra/for substrate+bootstrap+platform,deployments/for apps) so CI can gate app plans on the cluster existing. The example collapses both into one root for readability; the split is a scaling choice, not a requirement.
First-live gotchas
Section titled “First-live gotchas”Every item here was found on the first live apply against a real Exoscale account (2026-07-13/14). The module- or estate-level fix is already in place; they are documented so you recognize the symptom if you re-pin or diverge.
1. EXO2-HMAC-SHA256 v2 API auth needs BOTH key and secret
Section titled “1. EXO2-HMAC-SHA256 v2 API auth needs BOTH key and secret”Symptom: provider auth failures / signed-request rejections when only the API key is supplied.
Fix: Exoscale’s v2 API signs every request with EXO2-HMAC-SHA256, so the
key and the secret are both required. Supply
EXOSCALE_API_KEY + EXOSCALE_API_SECRET (or TF_VAR_exoscale_api_key +
TF_VAR_exoscale_api_secret). The exoscale connection type’s CredFields
are [api_key, api_secret] for the same reason.
2. The plane NIC (eth1) comes up DOWN — netplan configures only eth0
Section titled “2. The plane NIC (eth1) comes up DOWN — netplan configures only eth0”Symptom: on a private-network cluster, shc init --nic eth1 fails
X400992NICNOF — the private-network interface exists but is DOWN/unmanaged.
Exoscale’s Debian templates (netplan → systemd-networkd) configure only the
primary NIC (eth0); the second NIC never gets a DHCP lease.
Fix (baked into the module): cloud-init now writes a
70-shc-extra-nics.network systemd-networkd unit — a DHCP fallback that claims
any unclaimed en*/eth* interface — so the plane NIC leases an address and
comes UP on first boot. No per-cluster hand fix. Verify on first boot with
ip a and terraform output node_private_ips (it should be populated).
3. Exoscale DNS is a PAID add-on — pivot to cloudflare-parent
Section titled “3. Exoscale DNS is a PAID add-on — pivot to cloudflare-parent”Symptom: first apply of a native Exoscale DNS zone dies with
Bad Request: No DNS subscription found (CreateDNSDomain). The account has no
DNS subscription and Terraform cannot enable one (it’s bought in the portal).
Fix: wire DNS as a cloudflare-parent connection instead — a
cloudflare-type shc_connection (CredFields: token) and
dns.provider = "cloudflare". See DNS approach.
To restore native DNS, buy the subscription and follow
Restoring the native shape.
4. The real template name is Linux Debian 13 (Trixie) 64-bit
Section titled “4. The real template name is Linux Debian 13 (Trixie) 64-bit”Symptom: the original module default "Linux Debian 12 64-bit" never
resolved via data "exoscale_template".
Fix (baked into the module): the default template is now
"Linux Debian 13 (Trixie) 64-bit" — the live-verified template name in the
account’s list. Exoscale template names are stable identifiers; confirm with
exo compute instance-template list if Exoscale retitles a release.
5. Root SSH is disabled — dial admin and sudo -n
Section titled “5. Root SSH is disabled — dial admin and sudo -n”Symptom: ssh root@<node> is refused; a provider pinned older than 0.3.0
runs dpkg without sudo as a non-root user and the bootstrap fails
(“are you root?”).
Fix: cloud-init creates a dedicated admin user (sudo group +
NOPASSWD:ALL via /etc/sudoers.d/90-cloud-init-users) carrying the
cluster key, and adds a PermitRootLogin no sshd drop-in. Set ssh.user = "admin" on both module "shc" and the provider "shc" block, and pin the
provider >= 0.3.0 (its SSH engine wraps every remote command in
sudo -n, X400998SSHSUD on a missing grant). Module + provider are a
coupled pin — move them together.
6. Elastic IPs are FLOATING — leave assign_elastic_ips = false
Section titled “6. Elastic IPs are FLOATING — leave assign_elastic_ips = false”Symptom: attaching an Exoscale elastic IP does not make it the instance’s primary/SSH address — the OS must bring the EIP up on an interface first.
Fix: leave assign_elastic_ips = false (the default) so each node uses its
own dynamic primary public IP as the SSH / DNS target, exactly as the module
README recommends. (Unlike scaleway’s flexible IP or vultr’s reserved IP,
Exoscale EIPs are floating/secondary.)
7. Plane IPs are not pinned — a recreate may change them
Section titled “7. Plane IPs are not pinned — a recreate may change them”Symptom: recreating an instance may hand it a different managed-DHCP plane IP, which stored cluster membership does not follow automatically.
Fix: treat node replacement on Exoscale as remove-node + join-node, not
an in-place recreate. (Exoscale, like scaleway/vultr, assigns the plane IP by
managed DHCP and reads it back off the instance’s network_interface; there is
no cidrhost pin like hetzner/aws.)
8. root_volume.type must stay null
Section titled “8. root_volume.type must stay null”Symptom: setting a root-volume type errors — Exoscale offers no
root-volume type choice.
Fix: pass only root_volume = { size = <GB> }; leave type null (the
module validates this and fails loud rather than silently).
The provisioning contract, checked on first boot
Section titled “The provisioning contract, checked on first boot”After a first apply, spot-check on the bootstrap node (module README’s VERIFY checklist):
cloud-init status --longreports done;docker,docker compose,git,jq,rsync,resticall resolve.ssh admin@<node>works with the cluster key andsudo -n truepasses;ssh root@<node>is refused.ip ashows the plane NIC (eth1) UP with a managed-DHCP address, andterraform output node_private_ipsis populated.shc cluster statusshows quorum healthy;shc connection lsshows thecloudflareconnection validated.
Related
Section titled “Related”terraform/modules/shc/exoscale— the module and itsvariables.tfterraform/examples/exoscale-shc— the parameterized example roottests/clouds/exoscale— the live smoke-test lane (make test/exoscale)- Multi-node cluster setup
- Storage providers — the
local/nfs/cephfsvolume story (Exoscale has no cloud block backend wired)