AWS VM — Terraform

Deploys an Ubuntu 26.04 LTS (Resolute Raccoon) EC2 instance on AWS with:

  • x86_64 or ARM64 (Graviton) architecture selection
  • Persistent EBS data volume that survives instance termination
  • Secrets Manager + IAM Instance Profile for credential-less secret retrieval
  • Cloud-init bootstrap → Ansible configuration

Uses the terraform-aws-modules community collection to minimise custom code.

For the separate-admin-host bootstrap workflow, see Bootstrap on AWS. It reuses this template with bootstrap_mode = "external", a supplied public SSH key, an optional AMI pin, Elastic IP and SSM-based host trust. External mode mounts storage and prepares SSM but does not create Git credentials, clone the repository or run Ansible inside cloud-init. Standalone mode remains the default. Bootstrap owns separate state; do not use task aws-vm:* to manage bootstrap-created resources.

External mode mounts existing ext4 storage and defers new-volume formatting to Ansible’s automatic blank-owned-volume checks and one-time attempt marker (see the bootstrap storage policy). Supplied SSH keys are registered without optional comments, because EC2 rejects non-ASCII comments.

Offline checks (Terraform >= 1.7; AWS is mocked):

terraform -chdir=terraform/aws-vm init -backend=false
terraform -chdir=terraform/aws-vm validate
terraform -chdir=terraform/aws-vm test

Architecture

┌─────────────────────────────────────────────────┐
│  VPC (10.0.0.0/16)                              │
│  ┌──────────────────────────────────────────┐   │
│  │  Public Subnet (10.0.1.0/24)             │   │
│  │  ┌────────────────────────────────────┐  │   │
│  │  │  EC2 Instance                      │  │   │
│  │  │  - Ubuntu 26.04 LTS                │  │   │
│  │  │  - IAM Instance Profile            │  │   │
│  │  │  - gp3 root volume                 │  │   │
│  │  │  - EBS data volume (/storage)      │  │   │
│  │  └────────────────────────────────────┘  │   │
│  └──────────────────────────────────────────┘   │
│  Security Group: SSH/HTTPS from admin IP only   │
└─────────────────────────────────────────────────┘
         │
         ▼
Internet Gateway → Public IP

AWS Secrets Manager: git credentials (IAM role access)

Prerequisites

Setup

1. Configure AWS credentials

aws configure
# or use AWS_PROFILE / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars
# or use IAM Identity Center: aws sso login

2. Create the variable file

mkdir -p config/terraform/aws-vm
cp config-example/terraform/aws-vm/infra.tfvars config/terraform/aws-vm/infra.tfvars
# Edit config/terraform/aws-vm/infra.tfvars

3. Populate the git credentials secret

The Secrets Manager secret is created by Terraform but its value is intentionally not stored in Terraform state. Run the dedicated task to populate it (prompts for username and token):

task aws-vm:setup-secret

This works both before and after task aws-vm:apply. If cloud-init already ran and failed because the secret was empty, the task prints the SSH command to re-run the setup script on the VM.

4. Deploy

task aws-vm:apply

This will:

  1. Fetch your public IP and inject it as the SSH/HTTPS source
  2. Create all AWS resources
  3. Write the SSH private key to ~/.ssh/id_ed25519_aws_vm

5. Connect

task aws-vm:connect-vm

Tasks

TaskDescription
task aws-vm:planCreate Terraform plan
task aws-vm:applyApply plan + save SSH key
task aws-vm:destroyRequest full destroy (blocked by EBS guard)
task aws-vm:destroy-vmDestroy EC2 instance only (keeps EBS)
task aws-vm:connect-vmSSH into VM
task aws-vm:config-vmRun Ansible on VM
task aws-vm:start-vmStart instance
task aws-vm:stop-vmStop instance
task aws-vm:secrets-infoShow Secrets Manager retrieval instructions
task aws-vm:setup-secretPopulate git credentials in Secrets Manager
task aws-vm:migrate-stateMigrate state to S3 backend
task aws-vm:checkValidate + security scan

Cloud-init caveat: user_data_replace_on_change = false in main.tf means Terraform will not re-run cloud-init when the cloud-init templates change on an existing instance. To apply updated cloud-init content, run task aws-vm:destroy-vm followed by task aws-vm:apply (the EBS data volume is preserved). Skipping this step will leave the running instance with the old bootstrap configuration.

Architecture Selection

ArchitectureDefault TypeNotes
x86_64t3.smallIntel/AMD, wider software compatibility
arm64t4g.smallGraviton, ~20% better price/performance

Set in infra.tfvars:

architecture = "arm64"

Persistent Data Volume

The data EBS volume at /storage has prevent_destroy = true. destroy-vm keeps it; a full destroy is blocked, not a partial destroy that automatically keeps the volume. For bootstrap deployments use the separately approved bootstrap teardown, not these standalone tasks. For an approved full deletion of a standalone deployment:

Temporarily remove only the prevent_destroy = true argument from the local configuration, leaving its other lifecycle checks intact. With the original state and variable file, create and review a saved terraform plan -destroy before applying it. Restore the guard afterwards. No intermediate apply is needed merely to change this guard. The standalone destroy task auto-approves destruction and is not a preview command; do not use it against bootstrap state.

Remote State Backend (optional)

For shared/CI usage, configure an S3 backend:

# Create S3 bucket (name must be globally unique)
BUCKET="my-terraform-state-$(aws sts get-caller-identity --query Account --output text)"
aws s3api create-bucket --bucket "$BUCKET" --region eu-central-1 \
    --create-bucket-configuration LocationConstraint=eu-central-1
aws s3api put-bucket-versioning --bucket "$BUCKET" \
    --versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket "$BUCKET" \
    --server-side-encryption-configuration \
    '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'

# Create DynamoDB lock table
aws dynamodb create-table --table-name terraform-locks \
    --attribute-definitions AttributeName=LockID,AttributeType=S \
    --key-schema AttributeName=LockID,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST \
    --region eu-central-1

Then replace the local backend in backend.tf with the S3 block and your bucket name (never declare both backends), and run:

task aws-vm:migrate-state
terraform init -migrate-state

Cloud-Init Bootstrap

At first boot the instance:

  1. Upgrades packages, installs git and unzip
  2. Fetches git credentials from Secrets Manager (using the instance IAM role — no login needed)
  3. Clones the infrastructure repository to ~/repos/infra/
  4. Mounts the EBS data volume to /storage (NVMe-aware, formats on first boot)
  5. Runs ansible/bootstrap-ansible.sh to configure the system

Monitor progress:

# SSH in and watch the log
ssh -i ~/.ssh/id_ed25519_aws_vm ubuntu@<public-ip>
sudo tail -f /var/log/cloud-init-output.log

Ansible Integration

After cloud-init completes, configure the Ansible inventory for the AWS VM host and run:

task aws-vm:config-vm
# or manually: cd ansible && ./apply-cloud.sh

GitHub Actions (OIDC)

For CI/CD without long-lived credentials, create an IAM OIDC provider for GitHub:

# Create OIDC provider
aws iam create-open-id-connect-provider \
    --url https://token.actions.githubusercontent.com \
    --client-id-list sts.amazonaws.com \
    --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

Then create an IAM role with a trust policy scoped to your repo and reference it in GitHub Actions via aws-actions/configure-aws-credentials with role-to-assume.

Requirements

NameVersion
terraform>= 1.5
aws~> 6.0
cloudinit~> 2.3
tls~> 4.0

Providers

NameVersion
aws~> 6.0
cloudinit~> 2.3
tls~> 4.0

Modules

NameSourceVersion
ec2terraform-aws-modules/ec2-instance/aws~> 6.0
key_pairterraform-aws-modules/key-pair/aws~> 3.0
security_groupterraform-aws-modules/security-group/aws~> 6.0
vpcterraform-aws-modules/vpc/aws~> 6.0

Resources

NameType
aws_ebs_volume.dataresource
aws_eip.mainresource
aws_eip_association.mainresource
aws_iam_instance_profile.ec2resource
aws_iam_role.ec2resource
aws_iam_role_policy.secrets_accessresource
aws_iam_role_policy_attachment.ssmresource
aws_secretsmanager_secret.git_credentialsresource
aws_volume_attachment.dataresource
tls_private_key.mainresource
aws_ami.pinneddata source
aws_ami.ubuntudata source
aws_availability_zones.availabledata source
aws_ec2_instance_type.bootstrapdata source
cloudinit_config.maindata source

Inputs

NameDescriptionTypeDefaultRequired
admin_source_addressCIDR or IP allowed SSH and HTTPS access (e.g. your public IP: ‘1.2.3.4/32’)stringn/ayes
admin_userSSH admin username (Ubuntu default is ‘ubuntu’)string"ubuntu"no
allocate_elastic_ipAllocate a stable public IPv4 addressboolfalseno
ami_idPinned official Ubuntu AMI; null selects the most recent releasestringnullno
architectureCPU architecture: ‘x86_64’ or ‘arm64’ (Graviton)string"x86_64"no
aws_regionAWS region for all resourcesstring"eu-central-1"no
bootstrap_modeStandalone guest setup or external administrative-host orchestrationstring"standalone"no
data_disk_size_gbPersistent data EBS volume size in GB (survives instance termination)number10no
expected_account_idOptional account guard; bootstrap always supplies its expected AWS accountstringnullno
instance_typeEC2 instance type. Defaults to t3.small (x86_64) or t4g.small (arm64) when null.stringnullno
os_disk_size_gbRoot OS disk size in GBnumber20no
repo_directoryLocal directory name for the cloned repositorystring"infra"no
repo_urlURL of the infrastructure repository to clonestring""no
spot_instanceUse a Spot instance instead of On-Demand. Reduces cost by ~70% but the instance may be interrupted.boolfalseno
spot_priceMaximum spot bid price (USD/hr). null = on-demand price cap (recommended — avoids accidental overbidding).stringnullno
ssh_public_keyExisting public SSH key; null preserves standalone generated-key behaviorstringnullno
ubuntu_versionUbuntu release string used in the AMI name (e.g. ‘ubuntu-resolute-26.04’, ‘ubuntu-noble-24.04’)string"ubuntu-resolute-26.04"no
vm_nameName tag and hostname of the VMstring"nest"no

Outputs

NameDescription
ami_idAMI ID used for the instance
aws_regionAWS region where resources are deployed
bootstrap_hostNon-secret Ansible handoff; never dump all outputs (standalone can contain a private key).
data_volume_idPersistent EBS data volume ID
instance_idEC2 instance ID
public_ipPublic IP address of the instance
public_key_fingerprint_sha256SHA-256 fingerprint of the SSH public key
secrets_manager_secret_nameName of the Secrets Manager secret storing git credentials
spot_bid_statusSpot instance request bid status (null for on-demand)
spot_instanceWhether a Spot instance is used
spot_request_stateSpot instance request state (null for on-demand)
ssh_private_keySSH private key (ED25519) for connecting to the instance
ssh_public_keyPublic SSH key authorized for the administrator