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.

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:destroyDestroy all resources
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. It survives both destroy-vm and destroy tasks. To fully delete it:

  1. Remove the lifecycle { prevent_destroy = true } block from main.tf
  2. Run task aws-vm:plan and task aws-vm:apply (no instance changes, just removes the guard)
  3. Run task aws-vm:destroy

Alternatively, delete manually:

aws ec2 delete-volume --volume-id <vol-id>

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 update backend.tf, uncomment the backend block with your bucket name, 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_iam_instance_profile.ec2resource
aws_iam_role.ec2resource
aws_iam_role_policy.secrets_accessresource
aws_secretsmanager_secret.git_credentialsresource
aws_volume_attachment.dataresource
tls_private_key.mainresource
aws_ami.ubuntudata source
aws_availability_zones.availabledata 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
architectureCPU architecture: ‘x86_64’ or ‘arm64’ (Graviton)string"x86_64"no
aws_regionAWS region for all resourcesstring"eu-central-1"no
data_disk_size_gbPersistent data EBS volume size in GB (survives instance termination)number10no
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 clonestringn/ayes
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
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
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