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):
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 | Request full destroy (blocked by EBS guard) |
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. 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:
Then replace the local backend in backend.tf with the S3 block and your bucket
name (never declare both backends), 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_eip.main | resource |
| aws_eip_association.main | resource |
| aws_iam_instance_profile.ec2 | resource |
| aws_iam_role.ec2 | resource |
| aws_iam_role_policy.secrets_access | resource |
| aws_iam_role_policy_attachment.ssm | resource |
| aws_secretsmanager_secret.git_credentials | resource |
| aws_volume_attachment.data | resource |
| tls_private_key.main | resource |
| aws_ami.pinned | data source |
| aws_ami.ubuntu | data source |
| aws_availability_zones.available | data source |
| aws_ec2_instance_type.bootstrap | 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 |
| allocate_elastic_ip | Allocate a stable public IPv4 address | bool | false | no |
| ami_id | Pinned official Ubuntu AMI; null selects the most recent release | string | null | 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 |
| bootstrap_mode | Standalone guest setup or external administrative-host orchestration | string | "standalone" | no |
| data_disk_size_gb | Persistent data EBS volume size in GB (survives instance termination) | number | 10 | no |
| expected_account_id | Optional account guard; bootstrap always supplies its expected AWS account | string | null | 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 | "" | no |
| 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 |
| ssh_public_key | Existing public SSH key; null preserves standalone generated-key behavior | 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 |
| bootstrap_host | Non-secret Ansible handoff; never dump all outputs (standalone can contain a private key). |
| data_volume_id | Persistent EBS data volume ID |
| 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 |
| ssh_public_key | Public SSH key authorized for the administrator |