Skip to content
SHC Docs

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.


ConcernExoscale answer
Placement axiszone 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 authExoscale IAM API key + secret pair (the v2 API signs each request with EXO2-HMAC-SHA256, so both halves are required).
Boot imagetemplate 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 useradmin (a dedicated user cloud-init creates; root login is disabled). The provider dials admin and wraps every remote command in sudo -n.
Plane NICeth1 on a private-network cluster — but the stock template configures only eth0, so the module ships a networkd fallback unit (see gotchas).
DNScloudflare-parent — a cloudflare-type shc_connection (Exoscale DNS is a paid add-on; see below).
Volume providernone — apps use node-local docker volumes. Exoscale block storage is not a wired SHC backend.
Load-balancer providernone — ingress rides the bootstrap node’s public IP directly.
Backup targetExoscale SOS (S3-compatible) is the natural restic target, but SHC restic wiring for this estate is a deferred gap.

Two credential sets are involved — the Exoscale substrate, and (because DNS is cloudflare-parent) a Cloudflare API token.

  1. 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.
  2. 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:

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

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:

Terminal window
export TF_VAR_cloudflare_token="..." # feeds the SHC cloudflare connection

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 typeCredFieldsNotes
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_record NS row and no exoscale_domain resource — Cloudflare is flat, and a subdomain is not a separate zone.
  • The cluster host record is self-registered. Once dns.provider and the bootstrap host are set, the daemon’s proxy-reconcile watcher registers and maintains the cluster host’s A record itself every leader tick (~60s), via RegisterSystemHostDNS. 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.

If your account does have the paid Exoscale DNS subscription and you want native DNS + NS sub-delegation instead:

  1. Add an exoscale-type shc_connection with credentials = { api_key, api_secret, zone } and zones = ["exo.example.com"].
  2. Create the exoscale_domain zone plus the four NS delegation rows in the parent zone (Exoscale serves every zone from ns1.exoscale.{io,ch,com,net}).
  3. Point shc_config’s dns.provider at the exoscale connection instead of cloudflare.

The daemon’s exoscale DNS provider is already registered; only the account subscription and the Terraform blocks above are missing.


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.provider is left unset. Apps use node-local docker volumes on the instance root disk. Size the root disk accordingly — the estate runs root_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 restic target when you close the gap — backup.repo takes an s3: 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.


A complete, parameterized root lives in terraform/examples/exoscale-shc — copy it and set your own zone, cloudflare domain and name prefix. The shape:

  1. Substrate + bootstrap (module "shc"): compute instances, one zone-scoped private network, per-group security groups, then shc init on the bootstrap node and shc node join for 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).
  2. Platform wiring (in the same root): a provider "shc" SSH block against the bootstrap node, one shc_connection of type cloudflare, and one shc_config setting dns.provider = cloudflare + acme.email.
  3. Apps: the official catalog via shc_repo, plus shc_deployment resources — e.g. a public whoami (proves DNS + LE) and a LAN-internal postgres.
Terminal window
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 zone
export TF_VAR_dns_zone=example.com # a zone you host on Cloudflare
export 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.shc
tofu apply

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


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

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

  1. cloud-init status --long reports done; docker, docker compose, git, jq, rsync, restic all resolve.
  2. ssh admin@<node> works with the cluster key and sudo -n true passes; ssh root@<node> is refused.
  3. ip a shows the plane NIC (eth1) UP with a managed-DHCP address, and terraform output node_private_ips is populated.
  4. shc cluster status shows quorum healthy; shc connection ls shows the cloudflare connection validated.