Use the EC2 (AWS) backend

The ec2 backend runs the disposable VM as an AWS EC2 instance. It reuses the same shared cloud-init, ephemeral SSH key, and host-side agent workflow (mount/fetch/auth/run/audit) as the other backends — only the “where the VM lives” layer differs.

Status: validated end-to-end (TARGET=ec2 ./ztd test green on a real account). EC2 instances cost money per running hourztd down terminates them; don’t leave one running.

1. Set up AWS

You need three things from AWS: an account, an IAM identity with EC2 permissions, and a pair of access keys for it. Do it via the console (a–c below) or the AWS CLI. If you already have all three, skip to step 2.

a. An account and a default VPC

Any AWS account works. New accounts get a default VPC in every region — ztd uses it out of the box (it launches into the default subnet and creates a security group there). If yours was deleted, either recreate it (VPC console → Actions → Create default VPC) or set aws_subnet_id + aws_security_group_id to your own.

b. An IAM user scoped to EC2

Create a dedicated IAM user (Console → IAM → Users → Create user), then attach this inline policy (IAM → Users → your user → Add permissions → Create inline policy → JSON). It’s tightly scoped — ztd tags everything it creates with ztd = "true", and the policy uses that tag so the credentials can only destroy or modify resources ztd itself created, all bound to one region:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ztdDescribe",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeInstanceAttribute",
        "ec2:DescribeInstanceTypes",
        "ec2:DescribeInstanceCreditSpecifications",
        "ec2:DescribeTags",
        "ec2:DescribeImages",
        "ec2:DescribeVolumes",
        "ec2:DescribeNetworkInterfaces",
        "ec2:DescribeVpcs",
        "ec2:DescribeSubnets",
        "ec2:DescribeAvailabilityZones",
        "ec2:DescribeSecurityGroups"
      ],
      "Resource": "*",
      "Condition": { "StringEquals": { "aws:RequestedRegion": "us-west-2" } }
    },
    {
      "Sid": "ztdRunInstancesSupport",
      "Effect": "Allow",
      "Action": "ec2:RunInstances",
      "Resource": [
        "arn:aws:ec2:us-west-2::image/*",
        "arn:aws:ec2:us-west-2:*:subnet/*",
        "arn:aws:ec2:us-west-2:*:security-group/*",
        "arn:aws:ec2:us-west-2:*:network-interface/*",
        "arn:aws:ec2:us-west-2:*:volume/*",
        "arn:aws:ec2:us-west-2:*:key-pair/*"
      ]
    },
    {
      "Sid": "ztdRunInstancesTaggedOnly",
      "Effect": "Allow",
      "Action": "ec2:RunInstances",
      "Resource": "arn:aws:ec2:us-west-2:*:instance/*",
      "Condition": { "StringEquals": { "aws:RequestTag/ztd": "true" } }
    },
    {
      "Sid": "ztdCreateSecurityGroupTaggedOnly",
      "Effect": "Allow",
      "Action": "ec2:CreateSecurityGroup",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "us-west-2",
          "aws:RequestTag/ztd": "true"
        }
      }
    },
    {
      "Sid": "ztdTagOnCreateOnly",
      "Effect": "Allow",
      "Action": "ec2:CreateTags",
      "Resource": "arn:aws:ec2:us-west-2:*:*/*",
      "Condition": {
        "StringEquals": { "ec2:CreateAction": ["RunInstances", "CreateSecurityGroup"] }
      }
    },
    {
      "Sid": "ztdManageZtdTaggedOnly",
      "Effect": "Allow",
      "Action": [
        "ec2:TerminateInstances",
        "ec2:DeleteSecurityGroup",
        "ec2:AuthorizeSecurityGroupIngress",
        "ec2:AuthorizeSecurityGroupEgress",
        "ec2:RevokeSecurityGroupIngress",
        "ec2:RevokeSecurityGroupEgress"
      ],
      "Resource": "*",
      "Condition": { "StringEquals": { "aws:ResourceTag/ztd": "true" } }
    }
  ]
}

How it’s scoped:

  • Describe* can’t take resource-level permissions (an AWS limitation), so it stays * — but it’s read-only and region-locked.
  • RunInstances may only create an instance carrying the ztd tag (aws:RequestTag/ztd), and TerminateInstances / DeleteSecurityGroup / the SG-rule actions only apply to resources already tagged ztd = "true" (aws:ResourceTag/ztd). So the credentials can’t touch anything ztd didn’t make.
  • Everything is pinned to us-west-2. If you set a different aws_region, replace us-west-2 throughout.

This relies on ztd’s ztd = "true" tags and aws provider 5.x tagging resources on create — both true by default. If a fresh up ever throws UnauthorizedOperation on an SG-rule action (a provider-tagging-order edge case), move those four …SecurityGroup…Ingress/Egress actions into the ztdRunInstancesSupport region statement (no tag condition). The AdministratorAccess managed policy also works but is far broader than needed.

c. Access keys

For that user: Security credentials → Access keys → Create access keyApplication running outside AWS → copy the Access key ID and Secret access key (the secret is shown once). These are what ztd uses.

Or: set it up with the AWS CLI

Everything in a–c can be scripted with the aws CLI instead — the one exception is the initial account signup, which is web-only. Run these on your host (not in ztd’s toolbox — that has no aws CLI) as an admin identity (a profile that already has IAM rights; this is a one-time bootstrap that creates the scoped ztd user):

# 0. save the least-privilege policy from step b above as ztd-ec2-policy.json

# 1. a dedicated IAM user
aws iam create-user --user-name ztd

# 2. attach the scoped policy
aws iam put-user-policy --user-name ztd --policy-name ztdEc2 \
  --policy-document file://ztd-ec2-policy.json

# 3. ensure a default VPC exists in your region (no-op if you already have one)
aws ec2 describe-vpcs --filters Name=isDefault,Values=true --region us-west-2 \
  --query 'Vpcs[0].VpcId' --output text | grep -q '^vpc-' \
  || aws ec2 create-default-vpc --region us-west-2

# 4. create access keys AND write them straight into ztd's secrets file
mkdir -p .ztd/secrets
aws iam create-access-key --user-name ztd \
  | jq -r '.AccessKey | "export AWS_ACCESS_KEY_ID=\"\(.AccessKeyId)\"\nexport AWS_SECRET_ACCESS_KEY=\"\(.SecretAccessKey)\"\nexport AWS_REGION=\"us-west-2\""' \
  > .ztd/secrets/aws.env
chmod 600 .ztd/secrets/aws.env

Step 4 drops a ready-to-use .ztd/secrets/aws.env — so if you take the CLI path you can skip step 2 below and go straight to step 3. (The aws CLI reads its own admin credentials from ~/.aws / AWS_PROFILE as usual; that identity is unrelated to the scoped ztd user it’s creating.)

2. Put the credentials where ztd reads them

mkdir -p .ztd/secrets
cp examples/aws.env.example .ztd/secrets/aws.env
$EDITOR .ztd/secrets/aws.env      # paste the key id + secret; set AWS_REGION

The ztd wrapper sources this and compose forwards the AWS_* vars; the aws provider reads them directly. Credentials never go in .ztd/ztd.toml, and .ztd/secrets/ is gitignored.

3. Non-secret config (.ztd/ztd.toml)

[aws]
region        = "us-west-2"   # default
instance_type = "t3.micro"    # cheapest practical / free-tier x86; bump for real work
ssh_ingress_cidr = "203.0.113.4/32"   # lock SSH to your IP (recommended)

Defaults that “just work” on a normal account: the latest official Debian 12 AMI is looked up for your region + arch, and a security group is created in your default VPC allowing SSH in + all egress out. Override subnet_id / security_group_id / ami to use your own.

Lock down SSH. ssh_ingress_cidr defaults to 0.0.0.0/0 for a zero-config first boot. The ephemeral key still gates authentication, but set it to your <ip>/32 so port 22 isn’t open to the world. Find your public IP with:

curl -s https://checkip.amazonaws.com     # → e.g. 203.0.113.4  →  set 203.0.113.4/32

(If your ISP rotates your IP you’ll re-run up when it changes, or widen the CIDR.)

4. Run it

./ztd init                      # once — downloads the aws provider
TARGET=ec2 ./ztd check          # verifies creds + region are present
TARGET=ec2 ./ztd up             # launches the instance; prints its public IP
TARGET=ec2 ./ztd mount          # sshfs-sync your working tree up
TARGET=ec2 ./ztd ssh
TARGET=ec2 ./ztd down           # TERMINATES the instance — don't skip this

Everything else (auth, skills, run, fetch, audit, cattle) works exactly as on Proxmox — same commands, prefixed with TARGET=ec2.

Validate end-to-end

TARGET=ec2 ./ztd test

Runs the full acceptance suite: launches a t3.micro, checks ephemeral-key SSH, cloud-init, Claude install + ztd auth, guest docker/internet, mount sync-up, fetch, the run + audit plumbing, then terminates the instance. A few minutes and a few cents; it spends no Claude usage (the run step uses a no-op).

Notes

  • Public IP is a real Terraform attribute, so IP resolution is a direct output read — no virsh/guest-agent polling like the other backends.
  • Reachability: the instance needs a public IP and your SG must allow SSH from your machine, since the host-side commands SSH straight in over the ephemeral key.
  • Deeper preflight (AMI availability, IAM permissions) is left to up, which fails fast and clearly — the SigV4-signed API calls aren’t worth reimplementing in check.