Skip to content
SHC Docs

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 .deb is amd64 — instance types must be x86 families (t3/m6i/c6i/…), never Graviton (t4g/m7g). A Graviton node installs nothing.


Two credential principals, kept apart on purpose:

PrincipalWhat it isWhere it comes from
Admin (terraform)Builds EC2/VPC/SG/EIP, mints the scoped daemon IAM user, creates the S3 backup bucketAWS_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 / mintA 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:

Terminal window
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=...
# region is set in the provider block / var.region, not an env var

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


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.

PlaneProviderNotes
DNSRoute53native managed DNS; dns.provider = this connection’s name
Volumeaws-ebs-volume (awsebs)AZ-scoped → the whole cluster lives in one AZ
Backup (restic)s3: repobackup.repo = s3:s3.<region>.amazonaws.com/<bucket>/<prefix>
Object-store mintaws-iam-s3the s3-bucket capability — per-(tenant,app) scoped bucket + IAM user
Load balanceraws-nlb (awsnlb)not yet enabled live — see gotcha #2

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.


Full, copy-pasteable terraform lives in terraform/examples/aws-shc. The shape:

  1. Substrate + bootstrap — one module "shc" call: EC2 instances, VPC + subnet in one AZ, per-group SGs, one EIP per node, then the .deb install + shc init / shc node join over SSH. Root disabled at the image; the provider dials the admin user and wraps every command in sudo -n.
  2. Scoped daemon IAMaws_iam_user + policy + aws_iam_access_key for the connection; an S3 bucket for restic.
  3. Platform wiring over SSH — the aws connection, an optional cloudflare DNS connection, and one shc_config (dns.provider, dns.public_address, acme.email, storage.volumes.provider = aws, backup.*).
  4. Appsshc_repo (the official catalog) + shc_deployments. The example deploys postgres (LAN-internal) and gitea (public, with a real Let’s Encrypt cert at git.<domain>, consuming the postgres plug and the keycloak magic integration).
Terminal window
cd terraform/examples/aws-shc
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...
export TF_CLI_CONFIG_FILE="$PWD/../../../<registry.tfrc>" # provider mirror
export TF_VAR_cloudflare_api_token=... # DNS token (env, never tfvars)
tofu init
tofu apply \
-var cluster_name=demo \
-var dns_zone=example.com \
-var shc_host=cloud.example.com \
-var acme_email=ops@example.com

Verify:

Terminal window
tofu output -raw ssh_private_key > /tmp/k && chmod 600 /tmp/k
ssh -i /tmp/k admin@$(tofu output -raw bootstrap_ip) 'sudo shc cluster status; sudo shc connection ls'
dig +short git.example.com
echo | 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.


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.
  • Symptom: there is no cloud load balancer VIP; the system Traefik is reached directly on the bootstrap node’s EIP.
  • Cause: the aws-nlb proxy 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_address to 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 the shc_stack_ports resource (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 the vpc_id/subnets it needs.

EBS attach: node-name resolve + wait-available

Section titled “EBS attach: node-name resolve + wait-available”
  • Symptom: an x-shc-volumes mount fails to attach; or X400343EBSCFG at connect time; or the attach races the volume’s creating state.
  • 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 reach available. The AZ must also be on the connection — EBS is AZ-scoped.
  • Fix / what to check: node-name resolve + waitAvailable are in the awsebs provider. Ensure the aws connection carries availability_zone and volume_type, and that every node lives in that one AZ (the module enforces a single-AZ cluster for exactly this reason). The instance Name tag 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_key creds). 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_eips defaults 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 or assign_eips = false on 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 admin user with NOPASSWD sudo; the provider dials admin and wraps commands in sudo -n. This is a coupled change: use module and provider ≥ 0.3.0 together. An older provider dials admin but runs dpkg without sudo and the bootstrap fails are you root?.

CapabilityProvider nameStatus on AWS
Block volumeaws-ebs-volume / awsebsLive-proven. AZ-scoped; needs availability_zone + volume_type on the connection. storage.volumes.provider = <aws-connection-name>.
Load balanceraws-nlb / awsnlbPresent, 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 mintaws-iam-s3Live. 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 backups3: repoLive. 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).