Scaleway
Standing SHC up on Scaleway — instances on a regional VPC private network, per-group security groups, flexible IPs, and the platform (DNS + Let’s Encrypt) wired first-class on top. Audience: operators who already know the cluster-setup flow and want the Scaleway-specific facts.
The front-door module is terraform/modules/shc/scaleway; the copy-paste
root is terraform/examples/scaleway-shc;
the live smoke test is tests/clouds/scaleway.
This whole page tracks a proven live estate (a scaleway/fr-par root in
a private ops repo, applied fr-par-1, 4 nodes, 2026-07) — it is descriptive of
what works, not aspirational.
1. Credentials and where they go
Section titled “1. Credentials and where they go”Scaleway splits into two credential surfaces, and SHC uses them for two different things — keep them apart.
| Credential | What it is | Feeds |
|---|---|---|
API access key (SCWXXXXXXXXXXXXXXXXX) + secret key (a UUID) | An IAM application’s key pair (Console → IAM → Applications → API keys) | The scaleway terraform provider — builds the substrate (instances, private network, security groups, flexible IPs) |
| Project ID (a UUID) | The Scaleway project the substrate lives in | The scaleway provider’s project_id — non-secret, hardcode it like an AWS account id |
| DNS token | The token that mints/maintains records for your zone | The SHC daemon’s DNS connection — see §2. On the live estate this is a Cloudflare token, not a Scaleway one |
The API keys authorize the whole substrate build, so treat them as
break-glass secrets. They ride environment variables only — never a
tfvars file, never Terraform state:
# substrate (terraform `scaleway` provider)export SCW_ACCESS_KEY=SCWXXXXXXXXXXXXXXXXXexport SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxexport SCW_DEFAULT_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# OR, if the root declares them as variables (the example does):export TF_VAR_scaleway_access_key="$SCW_ACCESS_KEY"export TF_VAR_scaleway_secret_key="$SCW_SECRET_KEY"export TF_VAR_scaleway_project_id="$SCW_DEFAULT_PROJECT_ID"
# DNS (SHC connection) — a Cloudflare token by default (see §2)export TF_VAR_dns_token="$CLOUDFLARE_TOKEN"The Scaleway provider reads SCW_ACCESS_KEY / SCW_SECRET_KEY /
SCW_DEFAULT_PROJECT_ID / SCW_DEFAULT_REGION / SCW_DEFAULT_ZONE from the
environment natively — an empty provider "scaleway" {} block works if you
export those. The example and the live estate instead pass access/secret as
TF_VAR_* into an explicit provider block and hardcode the non-secret
region/zone/project_id, which reads clearer in a committed root.
Region vs zone
Section titled “Region vs zone”Scaleway resources split by scope, and SHC’s module needs both:
region(fr-par,nl-ams,pl-waw, …) carries the regional resources: the one VPC private network and the IPAM lookups that read each node’s plane IP back.zone(fr-par-1,fr-par-2, …) carries the zonal resources: instances, flexible IPs, private NICs. The zone MUST sit inside the region (fr-par+fr-par-1); the module validates it.
2. The SHC connection and DNS approach
Section titled “2. The SHC connection and DNS approach”This is the load-bearing Scaleway decision — read it before you pick a domain.
There are two DNS shapes, and only one of them usually works
Section titled “There are two DNS shapes, and only one of them usually works”SHC ships a native scaleway connection type, DNS-only:
| Connection type | scaleway | cloudflare |
|---|---|---|
| Capabilities | {dns} only — no volume, no proxy, no restic, no CA | {dns} |
CredFields | [secret_key] | [token] |
| Wire | Scaleway domain/v2beta1 API, X-Auth-Token: <secret_key> (the account secret key doubles as the DNS token) | Cloudflare API, scoped token |
| Lego (Traefik DNS-01) code | identity (scaleway) | identity (cloudflare) |
The native scaleway connection only works if your apex domain is
registered at, or hosted by, Scaleway Domains. Scaleway’s domain/v2beta1
API refuses to create a zone under a domain it does not host — there is no
“external zone by NS delegation alone” path like Azure DNS or Google Cloud
DNS. On the live estate the apex (example.com) lives at Cloudflare, so
the first apply of a scaleway_domain_zone returned 403 Forbidden: domain not found and the native shape was abandoned.
The default: cloudflare-parent
Section titled “The default: cloudflare-parent”The estate — and every SHC estate whose apex is not hosted by the cloud it runs on — uses the cloudflare-parent shape:
- one
cloudflare-typeshc_connection(CredFields: [token]), zone = the parent Cloudflare zone (e.g.example.com, NOTscaleway.example.com); shc_configwithdns.provider = cloudflare.
Cloudflare is a flat zone: scaleway.example.com is a subdomain within
example.com, not a separate zone, so the daemon writes the subdomain’s
records straight into the parent zone (records resolve by longest-suffix
match). There is no NS sub-delegation and no scaleway_domain_zone.
The SHC daemon mints and maintains every record itself through the Cloudflare
API, guarded by ownership TXT records; the connect-time prober validates the
token AND confirms zone visibility, so a bad token/zone fails loudly at
apply, not silently at runtime.
The estate points the cluster host’s public target at the bootstrap node’s flexible IP:
resource "shc_connection" "dns" { name = "cloudflare" type = "cloudflare" credentials = { token = var.dns_token } # Zone:DNS:Edit, scoped to the parent zone zones = ["example.com"] # PARENT zone, not scaleway.example.com depends_on = [module.shc]}
resource "shc_config" "platform" { yaml = <<-EOF dns: provider: cloudflare public_address: ${module.shc.node_public_ipv4s[0]} acme: email: ops@example.com EOF depends_on = [shc_connection.dns]}The daemon self-registers the cluster host’s A record every leader tick
(60s) via RegisterSystemHostDNS once dns.provider and bootstrap.host
are set and the node has a public target — you do NOT hand-manage a
shc_dns_record for it. App hosts (whoami.scaleway.example.com, …) get
their records the same way when a deployment claims them.
If you DO host your apex at Scaleway Domains, the native shape is restorable:
scaleway_domain_zone+ ascaleway-typeshc_connectionreusing the accountsecret_keyas theX-Auth-Token, withdns.provider = scaleway. The connection type stays DNS-only either way. The example makes this a one-variable flip (dns_provider = "scaleway",dns_token = "$SCW_SECRET_KEY"), but it is unproven live — Scaleway Domains was never in the test loop.
3. Volumes, load balancers, and backup — the gaps
Section titled “3. Volumes, load balancers, and backup — the gaps”There is no Scaleway volume provider and no Scaleway LB proxy provider in
SHC today. The scaleway connection is DNS-only (Providers = {dns: scaleway} in the capability registry). Consequences:
- No
storage.volumes.providerto set — apps that opt intox-shc-volumesfall back to node-local disk, not a cloud block volume. (Contrast Hetzner’shcloud/ AWS’sawsebsvolume providers, which do cross-node attach and rebind.) - No load-balancer VIP. The cluster edge rides the nodes’ own flexible
IPs on 80/443 directly (the “lan” edge shape). There is no
scaleway-lbto attach traefik’s ports to, so the topology is neverkind = "lb". - No object-store mint. Scaleway Object Storage is S3-compatible, so
restic backup to it works with a separate Object Storage access/secret
pair (a manual
backup.repo+resticEnvKeyswiring, mirroring the AWS root’s restic→S3 block). It is not auto-provisioned. Until wired,shc backup create --file(local.bakexport) is the estate’s only backup path.
Because there is no cloud volume/LB, the estate uses flexible IPs to give
every node a stable public address that survives stop/start and recreate —
this is what makes the daemon-managed DNS targets and the SSH transport host
durable. Set assign_flexible_ips = true (the estate does); without it nodes
get a dynamic public IP that is released on stop and changes on recreate.
4. Minimal end-to-end deploy
Section titled “4. Minimal end-to-end deploy”The full copy-paste root is
terraform/examples/scaleway-shc;
its README has the exact commands. The shape:
-
One
module "shc"call builds the substrate (instances, one regional VPC private network, per-group security groups, flexible IPs) AND runs the bootstrap (deb install +shc init/shc node join) over terraform-provider-shc’s SSH transport — no bearer token, the CLI auths on the node’s local admin socket.module "shc" {source = "https://<pages-host>/modules/shc-scaleway-0.3.0.tgz"cluster_name = "demo-scaleway"region = "fr-par"zone = "fr-par-1"ssh_public_key = tls_private_key.cluster.public_key_opensshssh = { user = "admin", private_key = tls_private_key.cluster.private_key_openssh }node_groups = {compose = { count = 1, type = "DEV1-M", runtime = "compose" } # bootstrap/leaderswarm = { count = 3, type = "DEV1-M", runtime = "swarm" } # quorum + swarm workload}networking = { private = true, ip_range = "10.10.0.0/16", subnet = "10.10.1.0/24" }assign_flexible_ips = trueroot_volume = { size = 40, type = "sbs_volume" } # see §5 — NOT b_ssdfirewall = {# inter_node left unset → derives "none" from this private plane.public_ports = [{ port = "80", protocol = "tcp" },{ port = "443", protocol = "tcp" },{ port = "30000-32767", protocol = "tcp" },{ port = "30000-32767", protocol = "udp" },]}bootstrap = {package = { deb_url = "https://<pages-host>/shc.deb", version = "0.3.0" }vault_password = random_password.vault.resulthost = "cloud.scaleway.example.com"}}The
.debis amd64-only, so nodetypeMUST be an x86 family (DEV1/GP1/PRO2/…) — never the ARMCOPARMfamilies.type = "DEV1-M"is 3 vCPU / 4 GB, the proven size. -
Wire DNS + ACME first-class with the
shc_connection+shc_configpair from §2 (transport inherited from a singleprovider "shc" { ssh = { … } }block — no per-resourcecluster{}). -
Deploy apps against the same transport:
resource "shc_repo" "shc" {name = "shc"url = "https://<pages-host>/apps/index.yaml"}resource "shc_deployment" "whoami" {repo = "shc"app = "whoami"stack = "whoami"public = truehosts = { "whoami.scaleway.example.com" = { service = "whoami", port = 80 } }wait_healthy = truedepends_on = [shc_repo.shc]}Because the cloudflare connection is created at system scope (provider ≥ 0.0.322), default-tenant apps claiming
whoami.scaleway.example.comresolve it directly — no per-tenant DNS connection needed. Ontofu apply, the daemon mints the DNS record, Traefik requests a Let’s Encrypt cert, and the app answers over the public host with a valid cert.
The estate splits this into two Terraform roots (infra/ owns substrate +
bootstrap + the system DNS connection; deployments/ owns the apps, reading
the bootstrap IP + SSH key from infra’s state) — recommended for anything
long-lived so the two phases can move independently. The single-root example
folds them together for a readable demo.
5. First-live gotchas
Section titled “5. First-live gotchas”Every one of these was found on the first live apply (fr-par-1, DEV1-M,
debian_bookworm, 2026-07-13/14). The module at >= 0.3.0 already
works around them; they are documented so a re-pin or a different instance
family does not re-trip them.
b_ssd root volumes are discontinued → use sbs_volume
Section titled “b_ssd root volumes are discontinued → use sbs_volume”- Symptom: the apply fails at instance create with
b_ssd volumes are not supported anymore. Newer instance families (PRO2, PLAY2, …) also require SBS. - Fix: set
root_volume = { size = 40, type = "sbs_volume" }(Scaleway Block Storage). The module defaults tosbs_volume, but keep it explicit in the root — it is load-bearing, and an accidental revert to ab_ssd-era default bricks the next apply. DEV1-M silently rejectsb_ssd/l_ssd.
The cluster-plane NIC is ens6, NOT ens5
Section titled “The cluster-plane NIC is ens6, NOT ens5”- Symptom: if you assume
ens5(a common cross-cloud guess), the daemon binds the wrong interface and the cluster plane never comes up. The Private Network NIC is hotplugged post-boot byscaleway_instance_private_nic, landing at PCI slot 6 →ens6; the primary/public NIC is at slot 2 →ens2. The image’s own netplan hotplug handling DHCP-configures the new NIC against IPAM. - Fix: none needed at
>= 0.3.0— the module derives the NIC fromnetworking.private ? "ens6" : "ens2", which matches the live-verified value. If a different instance family lands the NIC on another slot, checkip aon the node and pinvar.nic_name. (The0.0.378-era module derivedens5and needed anic_name = "ens6"override — that override is now dropped.)
The security group MUST set external_rules = true or a refresh seals SSH
Section titled “The security group MUST set external_rules = true or a refresh seals SSH”- Symptom (the worst one — you lock yourself out): with the security
group keeping its inbound rules in a separate
scaleway_instance_security_group_rulesresource but WITHOUTexternal_rules = trueon thescaleway_instance_security_group, everyterraform refreshreads the live rules back as inline drift and wipes every inbound rule in place — including the SSH/22 rule. The next plan looks clean while the cluster is quietly sealed off; SSH and the whole edge go dark. - Fix:
external_rules = trueon the security-group resource, so the inline rule set and the separate rules resource do not fight. Fixed upstream — no hand-fix needed on the>= 0.3.0module pin. If you ever vendor or fork the module, this flag is non-negotiable.
Cosmetic: cloud-init reports error on docker-cli (bookworm)
Section titled “Cosmetic: cloud-init reports error on docker-cli (bookworm)”- Symptom:
cloud-init status --longshows anerrorfor thedocker-clipackage ondebian_bookworm. - Fix: ignore it. Bookworm has no
docker-clipackage —docker.iobundles the client there — so everything else installs fine. (Trixie split the client out, which is why the recipe names it.)
IAM SSH keys land on root, which is inert — log in as admin
Section titled “IAM SSH keys land on root, which is inert — log in as admin”- Symptom: you expect the cluster key on
root, butssh root@nodeis refused. - Fix: this is intended. Scaleway injects the project’s IAM SSH keys at
first boot (they land on
root), but the image disables root login (disable_root+ aPermitRootLogin nosshd drop-in). The authoritative channel is cloud-init CREATING a dedicatedadminuser (already sudo + NOPASSWD). The module dialsadmin, and the provider wraps every remote command insudo -n. Confirmssh admin@<node>works andsudo -n truepasses.
Plane IPs are not pinned — a recreate may change one
Section titled “Plane IPs are not pinned — a recreate may change one”- Symptom: after recreating an instance, its cluster-plane IP changed and stored membership did not follow.
- Fix: Scaleway (like Vultr) exposes no per-node private-IP choice — the
plane IP is assigned by managed DHCP/IPAM inside
networking.subnetand read back via thescaleway_ipam_iplookup. Treat node replacement as remove-node + join-node, not an in-place recreate. (Hetzner/AWS pin the plane IP viacidrhost; Scaleway cannot.)
6. Reference
Section titled “6. Reference”| Fact | Value |
|---|---|
| Front-door module | terraform/modules/shc/scaleway (mirrors modules/shc/vultr) |
| Substrate provider | scaleway/scaleway ~> 2.0 |
| SSH user | admin (dedicated, cloud-init-created; root disabled) |
| Node sizing key | type (DEV1-M, GP1-XS; x86/amd64 only) |
| Root volume | root_volume = { size, type = "sbs_volume" } |
| Plane NIC | ens6 (private) / ens2 (primary) |
| Public addressing | assign_flexible_ips = true (EIP analogue) |
| DNS connection | cloudflare (CredFields [token]) by default; native scaleway (CredFields [secret_key]) only if the apex is Scaleway-hosted |
| DNS record management | daemon self-registers cluster host + app hosts; no shc_dns_record |
| Volume provider | none (gap) |
| LB proxy provider | none (gap) — edge rides node flexible IPs |
| Backup | manual restic → Scaleway Object Storage (S3-compatible), or --file export |
| Proven live | fr-par-1, 4 nodes (1 compose + 3 swarm), 2026-07-13/14 |
See also: terraform/modules/shc/scaleway/README.md
(the module’s own delta-vs-vultr table + VERIFY-on-first-boot checklist),
cluster setup, and
storage providers.