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
Prerequisites
- Terraform >= 1.5
- AWS CLI configured with credentials
- Task task runner
- tfsec (optional, for
checktask) - terraform-docs (optional, for
docstask)
Setup
1. Configure AWS credentials
2. Create the variable file
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):
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
This will:
- Fetch your public IP and inject it as the SSH/HTTPS source
- Create all AWS resources
- Write the SSH private key to
~/.ssh/id_ed25519_aws_vm
5. Connect
Tasks
| Task | Description |
|---|---|
task aws-vm:plan | Create Terraform plan |
task aws-vm:apply | Apply plan + save SSH key |
task aws-vm:destroy | Destroy all resources |
task aws-vm:destroy-vm | Destroy EC2 instance only (keeps EBS) |
task aws-vm:connect-vm | SSH into VM |
task aws-vm:config-vm | Run Ansible on VM |
task aws-vm:start-vm | Start instance |
task aws-vm:stop-vm | Stop instance |
task aws-vm:secrets-info | Show Secrets Manager retrieval instructions |
task aws-vm:setup-secret | Populate git credentials in Secrets Manager |
task aws-vm:migrate-state | Migrate state to S3 backend |
task aws-vm:check | Validate + security scan |
Cloud-init caveat:
user_data_replace_on_change = falseinmain.tfmeans Terraform will not re-run cloud-init when the cloud-init templates change on an existing instance. To apply updated cloud-init content, runtask aws-vm:destroy-vmfollowed bytask aws-vm:apply(the EBS data volume is preserved). Skipping this step will leave the running instance with the old bootstrap configuration.
Architecture Selection
| Architecture | Default Type | Notes |
|---|---|---|
x86_64 | t3.small | Intel/AMD, wider software compatibility |
arm64 | t4g.small | Graviton, ~20% better price/performance |
Set in infra.tfvars:
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:
- Remove the
lifecycle { prevent_destroy = true }block frommain.tf - Run
task aws-vm:planandtask aws-vm:apply(no instance changes, just removes the guard) - Run
task aws-vm:destroy
Alternatively, delete manually:
Remote State Backend (optional)
For shared/CI usage, configure an S3 backend:
Then update backend.tf, uncomment the backend block with your bucket name, and run:
Cloud-Init Bootstrap
At first boot the instance:
- Upgrades packages, installs git and unzip
- Fetches git credentials from Secrets Manager (using the instance IAM role — no login needed)
- Clones the infrastructure repository to
~/repos/infra/ - Mounts the EBS data volume to
/storage(NVMe-aware, formats on first boot) - Runs
ansible/bootstrap-ansible.shto configure the system
Monitor progress:
Ansible Integration
After cloud-init completes, configure the Ansible inventory for the AWS VM host and run:
GitHub Actions (OIDC)
For CI/CD without long-lived credentials, create an IAM OIDC provider for GitHub:
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
| Name | Version |
|---|---|
| terraform | >= 1.5 |
| aws | ~> 6.0 |
| cloudinit | ~> 2.3 |
| tls | ~> 4.0 |
Providers
| Name | Version |
|---|---|
| aws | ~> 6.0 |
| cloudinit | ~> 2.3 |
| tls | ~> 4.0 |
Modules
| Name | Source | Version |
|---|---|---|
| ec2 | terraform-aws-modules/ec2-instance/aws | ~> 6.0 |
| key_pair | terraform-aws-modules/key-pair/aws | ~> 3.0 |
| security_group | terraform-aws-modules/security-group/aws | ~> 6.0 |
| vpc | terraform-aws-modules/vpc/aws | ~> 6.0 |
Resources
| Name | Type |
|---|---|
| aws_ebs_volume.data | resource |
| aws_iam_instance_profile.ec2 | resource |
| aws_iam_role.ec2 | resource |
| aws_iam_role_policy.secrets_access | resource |
| aws_secretsmanager_secret.git_credentials | resource |
| aws_volume_attachment.data | resource |
| tls_private_key.main | resource |
| aws_ami.ubuntu | data source |
| aws_availability_zones.available | data source |
| cloudinit_config.main | data source |
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| admin_source_address | CIDR or IP allowed SSH and HTTPS access (e.g. your public IP: ‘1.2.3.4/32’) | string | n/a | yes |
| admin_user | SSH admin username (Ubuntu default is ‘ubuntu’) | string | "ubuntu" | no |
| architecture | CPU architecture: ‘x86_64’ or ‘arm64’ (Graviton) | string | "x86_64" | no |
| aws_region | AWS region for all resources | string | "eu-central-1" | no |
| data_disk_size_gb | Persistent data EBS volume size in GB (survives instance termination) | number | 10 | no |
| instance_type | EC2 instance type. Defaults to t3.small (x86_64) or t4g.small (arm64) when null. | string | null | no |
| os_disk_size_gb | Root OS disk size in GB | number | 20 | no |
| repo_directory | Local directory name for the cloned repository | string | "infra" | no |
| repo_url | URL of the infrastructure repository to clone | string | n/a | yes |
| spot_instance | Use a Spot instance instead of On-Demand. Reduces cost by ~70% but the instance may be interrupted. | bool | false | no |
| spot_price | Maximum spot bid price (USD/hr). null = on-demand price cap (recommended — avoids accidental overbidding). | string | null | no |
| ubuntu_version | Ubuntu release string used in the AMI name (e.g. ‘ubuntu-resolute-26.04’, ‘ubuntu-noble-24.04’) | string | "ubuntu-resolute-26.04" | no |
| vm_name | Name tag and hostname of the VM | string | "nest" | no |
Outputs
| Name | Description |
|---|---|
| ami_id | AMI ID used for the instance |
| aws_region | AWS region where resources are deployed |
| instance_id | EC2 instance ID |
| public_ip | Public IP address of the instance |
| public_key_fingerprint_sha256 | SHA-256 fingerprint of the SSH public key |
| secrets_manager_secret_name | Name of the Secrets Manager secret storing git credentials |
| spot_bid_status | Spot instance request bid status (null for on-demand) |
| spot_instance | Whether a Spot instance is used |
| spot_request_state | Spot instance request state (null for on-demand) |
| ssh_private_key | SSH private key (ED25519) for connecting to the instance |