Skip to content
SHC Docs

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.


Scaleway splits into two credential surfaces, and SHC uses them for two different things — keep them apart.

CredentialWhat it isFeeds
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 inThe scaleway provider’s project_id — non-secret, hardcode it like an AWS account id
DNS tokenThe token that mints/maintains records for your zoneThe 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:

Terminal window
# substrate (terraform `scaleway` provider)
export SCW_ACCESS_KEY=SCWXXXXXXXXXXXXXXXXX
export SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
export 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.

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.

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 typescalewaycloudflare
Capabilities{dns} only — no volume, no proxy, no restic, no CA{dns}
CredFields[secret_key][token]
WireScaleway 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) codeidentity (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 estate — and every SHC estate whose apex is not hosted by the cloud it runs on — uses the cloudflare-parent shape:

  • one cloudflare-type shc_connection (CredFields: [token]), zone = the parent Cloudflare zone (e.g. example.com, NOT scaleway.example.com);
  • shc_config with dns.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 + a scaleway-type shc_connection reusing the account secret_key as the X-Auth-Token, with dns.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.provider to set — apps that opt into x-shc-volumes fall back to node-local disk, not a cloud block volume. (Contrast Hetzner’s hcloud / AWS’s awsebs volume 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-lb to attach traefik’s ports to, so the topology is never kind = "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 + resticEnvKeys wiring, mirroring the AWS root’s restic→S3 block). It is not auto-provisioned. Until wired, shc backup create --file (local .bak export) 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.


The full copy-paste root is terraform/examples/scaleway-shc; its README has the exact commands. The shape:

  1. 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_openssh
    ssh = { user = "admin", private_key = tls_private_key.cluster.private_key_openssh }
    node_groups = {
    compose = { count = 1, type = "DEV1-M", runtime = "compose" } # bootstrap/leader
    swarm = { 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 = true
    root_volume = { size = 40, type = "sbs_volume" } # see §5 — NOT b_ssd
    firewall = {
    # 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.result
    host = "cloud.scaleway.example.com"
    }
    }

    The .deb is amd64-only, so node type MUST be an x86 family (DEV1/GP1/PRO2/…) — never the ARM COPARM families. type = "DEV1-M" is 3 vCPU / 4 GB, the proven size.

  2. Wire DNS + ACME first-class with the shc_connection + shc_config pair from §2 (transport inherited from a single provider "shc" { ssh = { … } } block — no per-resource cluster{}).

  3. 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 = true
    hosts = { "whoami.scaleway.example.com" = { service = "whoami", port = 80 } }
    wait_healthy = true
    depends_on = [shc_repo.shc]
    }

    Because the cloudflare connection is created at system scope (provider ≥ 0.0.322), default-tenant apps claiming whoami.scaleway.example.com resolve it directly — no per-tenant DNS connection needed. On tofu 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.


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 to sbs_volume, but keep it explicit in the root — it is load-bearing, and an accidental revert to a b_ssd-era default bricks the next apply. DEV1-M silently rejects b_ssd/l_ssd.
  • 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 by scaleway_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 from networking.private ? "ens6" : "ens2", which matches the live-verified value. If a different instance family lands the NIC on another slot, check ip a on the node and pin var.nic_name. (The 0.0.378-era module derived ens5 and needed a nic_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_rules resource but WITHOUT external_rules = true on the scaleway_instance_security_group, every terraform refresh reads 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 = true on 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.0 module 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 --long shows an error for the docker-cli package on debian_bookworm.
  • Fix: ignore it. Bookworm has no docker-cli package — docker.io bundles 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, but ssh root@node is 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 + a PermitRootLogin no sshd drop-in). The authoritative channel is cloud-init CREATING a dedicated admin user (already sudo + NOPASSWD). The module dials admin, and the provider wraps every remote command in sudo -n. Confirm ssh admin@<node> works and sudo -n true passes.

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.subnet and read back via the scaleway_ipam_ip lookup. Treat node replacement as remove-node + join-node, not an in-place recreate. (Hetzner/AWS pin the plane IP via cidrhost; Scaleway cannot.)

FactValue
Front-door moduleterraform/modules/shc/scaleway (mirrors modules/shc/vultr)
Substrate providerscaleway/scaleway ~> 2.0
SSH useradmin (dedicated, cloud-init-created; root disabled)
Node sizing keytype (DEV1-M, GP1-XS; x86/amd64 only)
Root volumeroot_volume = { size, type = "sbs_volume" }
Plane NICens6 (private) / ens2 (primary)
Public addressingassign_flexible_ips = true (EIP analogue)
DNS connectioncloudflare (CredFields [token]) by default; native scaleway (CredFields [secret_key]) only if the apex is Scaleway-hosted
DNS record managementdaemon self-registers cluster host + app hosts; no shc_dns_record
Volume providernone (gap)
LB proxy providernone (gap) — edge rides node flexible IPs
Backupmanual restic → Scaleway Object Storage (S3-compatible), or --file export
Proven livefr-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.