AWS
Run SHC on EC2 with one module "shc" (source
terraform/modules/shc/aws) building the
substrate — VPC + subnet in a single availability zone, per-group security
groups, one EIP per node — and terraform-provider-shc wiring the platform
over SSH. One aws connection lights every AWS-facing plane: Route53 for
DNS, EBS for volumes, S3 for restic backups, the s3-bucket minter,
and (when enabled) an NLB in front of the system Traefik.
The reference estate this guide is written from is example.com in
us-west-2 (AWS account 123456789012) plus a second apex delegated into
the same account — a live 4-node cluster (3× compose control + 1 swarm worker). The
canonical terraform is
terraform/examples/aws-shc; the live
smoke lane is tests/clouds/aws.
AMD64 only. The shc
.debis amd64 — instance types must be x86 families (t3/m6i/c6i/…), never Graviton (t4g/m7g). A Graviton node installs nothing.
1. Credentials
Section titled “1. Credentials”Two credential principals, kept apart on purpose:
| Principal | What it is | Where it comes from |
|---|---|---|
| Admin (terraform) | Builds EC2/VPC/SG/EIP, mints the scoped daemon IAM user, creates the S3 backup bucket | AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in the environment |
Daemon (the aws connection) | A scoped IAM user the daemon uses at runtime for Route53 / EBS / NLB / S3 / mint | A terraform resource (aws_iam_user + inline/managed policy + aws_iam_access_key) — never an input |
The aws terraform provider reads the admin keys natively from the
environment — there is no TF_VAR remap and nothing key-shaped lands in
.tfvars:
export AWS_ACCESS_KEY_ID=AKIA...export AWS_SECRET_ACCESS_KEY=...# region is set in the provider block / var.region, not an env varThe daemon’s credentials are minted in-root (the example does exactly this) so
you hand SHC a least-privilege user, not your admin keys. AWS caps a user at
two access keys — the cluster root’s connection takes key #1, a sibling
apps root (if you split infra from apps) takes key #2; rotation is taint on
the owning key.
Never put admin keys, the daemon key, the vault password, or the restic
password into a .tfvars file — they would be committed or land in a diff.
Admin keys ride the environment; the rest are random_password /
aws_iam_access_key resources read back with tofu output -raw.
2. The aws connection
Section titled “2. The aws connection”One connection type, aws, serves every AWS plane. Create it at system
scope (provider ≥ 0.0.322 does this automatically) so tenant-less
cluster-host DNS and every tenant’s zone-match resolve it without a duplicate.
resource "shc_connection" "aws" { name = "aws" type = "aws" credentials = { access_key = aws_iam_access_key.shc_daemon.id secret_key = aws_iam_access_key.shc_daemon.secret region = "us-west-2"
# Per-plane extras ride the SAME connection (CredFields persist whatever # arrives): the NLB proxy provider needs vpc_id + subnets; the EBS # provider needs availability_zone (X400343EBSCFG without it) + volume_type; # hosted_zone_id pins Route53 so zone discovery never masks an IAM error. vpc_id = module.shc.vpc_id subnets = module.shc.subnet_id availability_zone = "us-west-2a" volume_type = "gp3" hosted_zone_id = "Z0123456789EXAMPLE" } zones = ["example.com"] # only when Route53 is the DNS plane cluster = { ssh = local.app_ssh } # apply over the bootstrap node's SSH transport depends_on = [module.shc, aws_iam_user_policy_attachment.shc_daemon]}CredFields. The registry declares only the universal access_key /
secret_key / region; everything else (vpc_id, subnets,
availability_zone, volume_type, hosted_zone_id) rides the opaque cred map
and is read inside the plane that needs it. Set zones only when this
connection is also the DNS plane (Route53) — zones arms the connect-time
prober to validate zone visibility, so an IAM mistake fails here, loudly,
not as a silent runtime zone-miss.
Plane → provider names
Section titled “Plane → provider names”| Plane | Provider | Notes |
|---|---|---|
| DNS | Route53 | native managed DNS; dns.provider = this connection’s name |
| Volume | aws-ebs-volume (awsebs) | AZ-scoped → the whole cluster lives in one AZ |
| Backup (restic) | s3: repo | backup.repo = s3:s3.<region>.amazonaws.com/<bucket>/<prefix> |
| Object-store mint | aws-iam-s3 | the s3-bucket capability — per-(tenant,app) scoped bucket + IAM user |
| Load balancer | aws-nlb (awsnlb) | not yet enabled live — see gotcha #2 |
3. DNS approach
Section titled “3. DNS approach”The estate uses native Route53 and this is the recommended AWS shape:
Route53 is a first-class SHC DNS provider and the aws connection already
carries the credentials, so DNS is one more plane on the one connection — a
single credential to rotate, no second connection, no second token. Point
dns.provider at the aws connection name and give the connection
zones = ["<your-zone>"] + a hosted_zone_id cred. The daemon
self-registers the cluster host record (and every public app host) into the
zone with ownership-TXT guards; you never hand-manage records.
Cloudflare-parent is the alternative, and it is what the
example and
smoke lane default to — because a tester with a
single Cloudflare-hosted domain can point the whole thing at it in one
variable, without provisioning a Route53 hosted zone (which is a billed,
day-0, account-level concern). In that shape you run two connections: the
aws connection for volume/backup/mint (no zones), and a small cloudflare
connection for DNS:
resource "shc_connection" "cloudflare" { name = "cloudflare" type = "cloudflare" credentials = { token = var.cloudflare_api_token } zones = ["example.com"] cluster = { ssh = local.app_ssh }}# then: dns.provider = "cloudflare"Either way Let’s Encrypt works: with a public dns.public_address (the
bootstrap EIP) Traefik does HTTP-01 by default — the app host resolves to
the node, the challenge lands on port 80. (DNS-01 is available on both
providers if you prefer it.)
Across the fleet, most estates converged on the cloudflare-parent-mix (records straight into the parent zone). AWS is one of the exceptions where native DNS is the better default, because Route53 is already reachable through the substrate connection.
4. Minimal end-to-end deploy
Section titled “4. Minimal end-to-end deploy”Full, copy-pasteable terraform lives in
terraform/examples/aws-shc. The shape:
- Substrate + bootstrap — one
module "shc"call: EC2 instances, VPC + subnet in one AZ, per-group SGs, one EIP per node, then the.debinstall +shc init/shc node joinover SSH. Root disabled at the image; the provider dials theadminuser and wraps every command insudo -n. - Scoped daemon IAM —
aws_iam_user+ policy +aws_iam_access_keyfor the connection; an S3 bucket for restic. - Platform wiring over SSH — the
awsconnection, an optionalcloudflareDNS connection, and oneshc_config(dns.provider,dns.public_address,acme.email,storage.volumes.provider = aws,backup.*). - Apps —
shc_repo(the official catalog) +shc_deployments. The example deployspostgres(LAN-internal) andgitea(public, with a real Let’s Encrypt cert atgit.<domain>, consuming the postgres plug and the keycloak magic integration).
cd terraform/examples/aws-shcexport AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...export TF_CLI_CONFIG_FILE="$PWD/../../../<registry.tfrc>" # provider mirrorexport TF_VAR_cloudflare_api_token=... # DNS token (env, never tfvars)tofu inittofu apply \ -var cluster_name=demo \ -var dns_zone=example.com \ -var shc_host=cloud.example.com \ -var acme_email=ops@example.comVerify:
tofu output -raw ssh_private_key > /tmp/k && chmod 600 /tmp/kssh -i /tmp/k admin@$(tofu output -raw bootstrap_ip) 'sudo shc cluster status; sudo shc connection ls'dig +short git.example.comecho | openssl s_client -connect git.example.com:443 -servername git.example.com 2>/dev/null \ | openssl x509 -noout -issuer # issuer: ... Let's Encrypt ...shc auth login over HTTPS (the device flow) is proven on this estate, so
once the cluster host record + cert are up you can drive the cluster from a
laptop without SSH.
5. First-live gotchas
Section titled “5. First-live gotchas”Every one of these was found the first time SHC ran live on AWS. Each is a one-layer fix that has landed; they are documented here so you recognize the symptom and know the connection cred / config that avoids it.
Route53 TXT records need surrounding quotes
Section titled “Route53 TXT records need surrounding quotes”- Symptom: ownership-guard TXT records (
heritage=shc,shc/owner=<uuid>) and DNS-01 challenge TXTs write to Route53 but read back malformed, or the ChangeResourceRecordSets call is rejected — TXT ownership never converges, DNS-01 cert issuance silently fails. - Cause: Route53’s API stores TXT in RFC 1035 presentation form —
each string surrounded by
"and split into ≤255-octet chunks. A raw unquoted value is a different record. - Fix / what to check: SHC’s Route53 provider quotes, backslash-escapes, and 255-octet-chunks every TXT it writes (and strips on read). It is in the shipped provider — just make sure your daemon carries it. If you see TXT write/round-trip failures on Route53, that is the symptom of an old build.
No NLB in front of Traefik yet (LB gap)
Section titled “No NLB in front of Traefik yet (LB gap)”- Symptom: there is no cloud load balancer VIP; the system Traefik is reached directly on the bootstrap node’s EIP.
- Cause: the
aws-nlbproxy provider exists but its first live run is not yet gated green, so the estate deliberately leaves the NLB attach commented out and fronts Traefik on the node EIP. - Workaround (the live default): set
dns.public_addressto the bootstrap EIP and let the DNS ladder point app hosts at the node; HTTP-01 issues certs fine. When you want the NLB, uncomment theshc_stack_portsresource (aws@80:traefik:80,aws@443:traefik:443) — the DNS ladder then CNAMEs hosted apps to the NLB automatically. Treat the NLB as unproven until you validate it yourself; the connection already carries thevpc_id/subnetsit needs.
EBS attach: node-name resolve + wait-available
Section titled “EBS attach: node-name resolve + wait-available”- Symptom: an
x-shc-volumesmount fails to attach; orX400343EBSCFGat connect time; or the attach races the volume’screatingstate. - Cause: the daemon must map the SHC node to its EC2 instance id (by Name
tag) before
AttachVolume, and must wait for the freshly-created volume to reachavailable. The AZ must also be on the connection — EBS is AZ-scoped. - Fix / what to check: node-name resolve +
waitAvailableare in theawsebsprovider. Ensure theawsconnection carriesavailability_zoneandvolume_type, and that every node lives in that one AZ (the module enforces a single-AZ cluster for exactly this reason). The instanceNametag must equal the SHC node name (the module guarantees this).
Connection auth is AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
Section titled “Connection auth is AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY”- The daemon authenticates to every AWS plane with an access-key pair on the
connection (
access_key/secret_keycreds). There is no IMDS/instance- profile path in use on this estate — mint a scoped IAM user in terraform and hand its key to the connection. (Ambient/IMDS auth is a design direction, not the shipped default.)
Single-AZ is mandatory; EIP quota is 5/region
Section titled “Single-AZ is mandatory; EIP quota is 5/region”- EBS volumes are AZ-scoped, so the module places the entire cluster in one
AZ — a multi-AZ node group would strand volumes.
assign_eipsdefaults on (one EIP per node, so the bootstrap address survives stop/start); the default per-region EIP quota is 5, so past 5 nodes you need a quota bump orassign_eips = falseon worker groups.
admin-user SSH is a coupled module+provider bump
Section titled “admin-user SSH is a coupled module+provider bump”- The node image disables root SSH and provisions an
adminuser with NOPASSWD sudo; the provider dialsadminand wraps commands insudo -n. This is a coupled change: use module and provider ≥ 0.3.0 together. An older provider dialsadminbut runsdpkgwithout sudo and the bootstrap failsare you root?.
6. Volume + load-balancer providers
Section titled “6. Volume + load-balancer providers”| Capability | Provider name | Status on AWS |
|---|---|---|
| Block volume | aws-ebs-volume / awsebs | Live-proven. AZ-scoped; needs availability_zone + volume_type on the connection. storage.volumes.provider = <aws-connection-name>. |
| Load balancer | aws-nlb / awsnlb | Present, not yet gated green. target_type=ip, client-IP preserved, health-checks on the 30000-32767 node-port range. Enable via shc_stack_ports once you validate it. |
| Object-store mint | aws-iam-s3 | Live. The s3-bucket capability — the daemon mints a per-(tenant,app) bucket + scoped IAM user (cap the daemon’s iam:* to the shc-* user prefix). |
| Restic backup | s3: repo | Live. s3:s3.<region>.amazonaws.com/<bucket>/<prefix>, keyed from the same scoped IAM user. |
See the module’s README for the
full AWS-vs-hetzner delta table (firewall semantics, EIP addressing, the single
key slot, the plane NIC ens5).