Clouds
SHC ships a first-class Terraform module per cloud
(terraform/modules/shc/<cloud>) plus a
copy-pasteable example root
(terraform/examples/<cloud>-shc) that builds
the substrate and wires the platform (cloud connection + DNS + Let’s Encrypt)
through terraform-provider-shc. Each cloud also has a live smoke lane under
tests/clouds/<cloud> driven from the repo root with
make test/<cloud>.
Every example and smoke lane defaults to Cloudflare-parent DNS — you point
it at one Cloudflare-hosted domain you own and SHC writes its records straight
into that zone. This is the recommended way to try or test any cloud uniformly:
one domain, one token, works everywhere. Each cloud’s native DNS (Route53,
Cloud DNS, Azure DNS, …) is documented in its page and selectable with
SHC_TEST_DNS_PROVIDER / var.dns_provider, but native DNS usually needs an
NS-delegated subzone or a provider-hosted apex — more moving parts than a single
Cloudflare zone.
Per-cloud guides
Section titled “Per-cloud guides”| Cloud | DNS | Block storage / LB | Headline first-live gotcha |
|---|---|---|---|
| AWS | Native Route53 on the one aws connection (example/tests default to Cloudflare-parent so a tester uses their own single zone) | aws-ebs-volume (awsebs) — AZ-scoped, needs availability_zone+volume_type creds | Route53 TXT needs surrounding-quote presentation form; no NLB yet (front Traefik on the bootstrap EIP, HTTP-01); EBS attach needs node-name resolve + waitAvailable |
| Azure | native Azure DNS, NS-delegated out of a parent (estate default) — or cloudflare-parent to point at a zone you own | azure-disk managed disks (StandardSSD/Premium_LRS) — UNVERIFIED-LIVE; LB azure-lb | fresh-subscription core quota: DSv5 ships quota 0 → pivot to Standard_D2s_v4 (v4 has the default 10-core quota); admin/root are reserved admin_usernames |
| DigitalOcean | Native DO managed DNS (delegated from a Cloudflare parent); Cloudflare-parent fallback for testers | dovolume (block) + dolb (LB) | cloud-init user_data must be pure ASCII — DO’s ConfigDrive double-encodes a UTF-8 em-dash and silently voids the whole document; the region-default VPC also refuses to delete |
| Exoscale | cloudflare-parent (native Exoscale DNS is a paid account add-on) | none — node-local docker volumes (no cloud block backend) | plane NIC eth1 boots DOWN — the stock template configures only eth0; the module ships a 70-shc-extra-nics.network DHCP fallback |
| Hostinger | cloudflare-parent, always — Hostinger DNS is never wired (the module creates no DNS resources) | none of either. No managed LB and no block volumes exist on the account; apps ride node-local docker disk and ingress is per-node | Billing, not a bug: no hourly capacity. Every node is a subscription with a ONE-MONTH minimum that destroy cancels without a refund, so a 10-minute smoke test costs $9.99+. Also: no private network (multi-node = nebula WAN overlay only), no tags/labels (hostname is the only attribution), no cloud-init (post-install shell script), and no firewall resource in the terraform provider — nodes are unfiltered public boxes |
| GCP (GCE) | native Cloud DNS, NS-delegated (bundled free); cloudflare-mix for a single test domain | gcepd (zonal persistent disk, mirrors awsebs) | GCE has no cloud-init — the startup-script must stop+mask apt-daily or the dpkg lock kills node prep (compose plugin never installs) |
| OpenStack (OVH US) | Cloudflare-parent — records written straight into the parent zone (OVH has no Designate; no NS delegation, no openstack platform connection) | none — apps use local docker disk (Cinder deferred; no SHC volume provider) | Per-subnet dnsmasq REFUSES recursion when dns_nameservers is empty (resolv.conf looks healthy; every query answers REFUSED/rcode-5) → module defaults public resolvers ["1.1.1.1","8.8.8.8"] |
| Scaleway | cloudflare-parent (native scaleway DNS unusable unless the apex is Scaleway-hosted — 403 domain not found) | none (gap — edge rides node flexible IPs, no LB either) | security group MUST set external_rules = true or every terraform refresh wipes inbound rules and seals SSH; also b_ssd discontinued → sbs_volume, plane NIC is ens6 not ens5 |
| Vultr | Cloudflare-parent (Vultr DNS refuses subdomain zones) | vultr-volume (LB: vultr-lb) | Stock image ships UFW ENABLED — ICMP passes so ping lies, TCP dropped → cluster joins fail X500023JNFAIL; disable UFW in prep |
Test configuration
Section titled “Test configuration”Each live smoke lane (make test/<cloud>) provisions a real, billable
cluster, wires DNS + Let’s Encrypt, installs a public whoami app, asserts DNS
resolution + a valid LE certificate, then tears the cluster down. They all obey
one shared env-var contract, so the same handful of variables drives every
cloud — you only add that cloud’s credentials.
Costs money. Every lane spins up real infrastructure. They auto-tear-down at the end; keep a cluster alive for debugging with the teardown toggle below, then reclaim it with
make test/<cloud>/clean.
The easy path — one Cloudflare domain for every cloud
Section titled “The easy path — one Cloudflare domain for every cloud”Own one domain on Cloudflare, mint a Zone:DNS:Edit API token scoped to it,
and you can test every cloud with the same two variables:
export SHC_TEST_DOMAIN=example.com # a Cloudflare-hosted zone you ownexport SHC_TEST_CLOUDFLARE_TOKEN=cf-xxxxxxxx # Zone:DNS:Edit on that zoneSHC writes its records (<prefix>-cloud.example.com, <prefix>-whoami.example.com)
directly into that flat zone by longest-suffix match — no per-cloud subzone, no
NS delegation. Then add just the target cloud’s credentials and run, e.g.:
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...make test/awsShared variables (honored by every make test/<cloud>)
Section titled “Shared variables (honored by every make test/<cloud>)”| Variable | Default | Purpose |
|---|---|---|
SHC_TEST_DOMAIN | cryptobulls.vip (checked into tests/lib/tf-harness.sh) | The base domain SHC writes hosts under. Not a secret, so it is defaulted in the harness rather than a token store — override it with your own zone. On the easy path this is a single Cloudflare-hosted zone you own. tests/hetzner is the exception: it needs a Hetzner-DNS zone and uses DNS_ZONE / SHC_TEST_HETZNER_ZONE instead. |
SHC_TEST_DNS_PROVIDER | cloudflare | Which SHC DNS connection to wire. cloudflare (recommended) uses one small connection pointed at your zone; a cloud-native value (aws/gcp/azure/digitalocean/scaleway) rides that cloud’s own DNS — see the per-cloud page. |
SHC_TEST_CLOUDFLARE_TOKEN | — (required when SHC_TEST_DNS_PROVIDER=cloudflare) | Cloudflare Zone:DNS:Edit API token for SHC_TEST_DOMAIN. Env only — never a tfvars file. Alias: SHC_TEST_DNS_TOKEN (provider-neutral name; use it when the DNS provider isn’t Cloudflare). The legacy CLOUDFLARE_API_TOKEN is still honored. |
SHC_TEST_PREFIX | shc-<cloud>-* | Naming prefix for cloud resources and the DNS host labels (cluster_name). Keep it DNS-label-safe (lowercase alnum + hyphen). |
SHC_DEB_URL | — (optional) | Public URL of a prebuilt SHC .deb. When set, the lane installs that instead of building one from the working tree. |
SHC_DEB | — (optional) | Path to a prebuilt .deb to install. Required when running lanes in parallel: the self-build path runs goreleaser --clean, which deletes dist/ under any sibling lane. Build once, export SHC_DEB, then fan out; an unguarded concurrent build now refuses rather than wiping its peer. |
Teardown toggle: set SHC_NO_TEARDOWN=1 (aws, azure, digitalocean,
exoscale, gcp) or SHC_TEST_NO_TEARDOWN=1 (hostinger, openstack, scaleway,
vultr) to leave the cluster running for post-mortem inspection. Reclaim it
later with make test/<cloud>/clean.
On Hostinger a kept cluster is not an idling meter — it is a live subscription that auto-renews at a full month. Leave it up only deliberately, and reclaim it the same day.
Per-cloud credentials
Section titled “Per-cloud credentials”Add the target cloud’s native credentials to the shared variables above. These are read by the underlying cloud provider / SDK, never written to state:
| Cloud | Credential env vars |
|---|---|
| AWS | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (optional AWS_DEFAULT_REGION) |
| Azure | ARM_SUBSCRIPTION_ID, ARM_TENANT_ID, ARM_CLIENT_ID, ARM_CLIENT_SECRET |
| DigitalOcean | DIGITALOCEAN_TOKEN |
| Exoscale | EXOSCALE_API_KEY, EXOSCALE_API_SECRET |
| GCP | GOOGLE_APPLICATION_CREDENTIALS (path to a service-account JSON) + a project (SHC_TEST_* / var.project) |
| Hostinger | HOSTINGER_API_TOKEN — plus SHC_TEST_HOSTINGER_ACCEPT_BILLING=1, which the lane requires before it will spend (see the cost note in its page) |
| OpenStack | OS_AUTH_URL, OS_APPLICATION_CREDENTIAL_ID, OS_APPLICATION_CREDENTIAL_SECRET (or the classic OS_USERNAME/OS_PASSWORD/OS_PROJECT_*) |
| Scaleway | SCW_ACCESS_KEY, SCW_SECRET_KEY, SCW_DEFAULT_PROJECT_ID (use a dedicated project) |
| Vultr | VULTR_API_KEY |
Each lane also accepts per-cloud sizing/placement overrides
(SHC_TEST_REGION, SHC_TEST_ZONE, SHC_TEST_NODE_COUNT, instance-type vars,
…); see tests/clouds/<cloud>/README.md for that cloud’s full knob list and the
exact assertions it runs.
Make targets
Section titled “Make targets”Uniform across every cloud (mirrors the test/hetzner tier):
| Target | Effect |
|---|---|
make test/<cloud> | Build/stage the deb, provision, verify (DNS + LE cert), auto-teardown |
make test/<cloud>/clean | Product-side unwind → tofu destroy (reclaim a kept-alive cluster) |
make test/<cloud>/status | tofu output of the provisioned scenario (or a hint when absent) |
make test/<cloud>/<target> | Forward any sub-make target (test, destroy, status, build-deb) |