A Terraform lab that provisions an EC2 instance in AWS (eu-south-2) with a generated SSH key pair, a security group, and a private S3 bucket to store the private key securely.
Authentication to AWS is handled via OIDC dynamic provider credentials configured in Terraform Cloud so no static AWS credentials are stored anywhere.
Terraform Cloud (lab-ec2)
│
│ OIDC token (workload identity)
▼
AWS STS → temporary credentials
│
├── IAM Key Pair ← RSA 4096-bit public key
├── Security Group ← inbound SSH (port 22) from allowed CIDR
├── EC2 Instance ← t3.micro, attached key pair + security group
└── S3 Bucket ← private key (.pem), encrypted + versioned
- Terraform CLI >= 1.15
- Terraform Cloud account with organization
emolinam5 - AWS account with OIDC trust configured (see below)
- AWS CLI (optional, for downloading the key from S3)
This project uses Terraform Cloud's native OIDC integration to authenticate to AWS without long-lived credentials. The setup follows the AWS APN blog guide.
| Field | Value |
|---|---|
| Provider URL | https://app.terraform.io |
| Audience | aws.workload.identity |
Attach a trust policy that allows Terraform Cloud to assume the role via the OIDC provider. Scope it to your organization and workspace:
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/app.terraform.io"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"app.terraform.io:aud": "aws.workload.identity"
},
"StringLike": {
"app.terraform.io:sub": "organization:emolinam5:project:*:workspace:*:run_phase:*"
}
}
}Attach the necessary IAM permissions to this role (EC2, S3, IAM key pair).
In the lab-ec2 workspace, set the following environment variables (not Terraform variables):
| Variable | Value | Sensitive |
|---|---|---|
TFC_AWS_PROVIDER_AUTH |
true |
No |
TFC_AWS_RUN_ROLE_ARN |
arn:aws:iam::<ID>:role/<name> |
Yes |
Terraform Cloud will automatically generate a short-lived OIDC token per run and exchange it for temporary AWS credentials via STS. No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is needed.
terraform/
├── backend.tf # Terraform Cloud workspace + provider requirements
├── keys.tf # TLS key generation + AWS key pair
├── compute.tf # Security group + EC2 instance
├── storage.tf # S3 bucket, encryption, versioning, private key upload
├── variables.tf # Input variables
└── outputs.tf # Public DNS + S3 URI
| Resource | Description |
|---|---|
tls_private_key |
RSA 4096-bit key pair generated by Terraform |
aws_key_pair |
Public key registered in AWS for EC2 access |
aws_security_group |
Allows inbound SSH (port 22) from var.ssh_cidr |
aws_instance |
EC2 instance (t3.micro by default) in eu-south-2 |
aws_s3_bucket |
Private bucket to store the .pem key |
aws_s3_bucket_public_access_block |
All public access blocked |
aws_s3_bucket_server_side_encryption_configuration |
SSE-S3 (AES256) encryption at rest |
aws_s3_bucket_versioning |
Versioning enabled to retain previous key versions |
aws_s3_object |
Private key uploaded as private_key.pem |
| Name | Description | Default |
|---|---|---|
aws_region |
AWS region to deploy resources | eu-south-2 |
ssh_cidr |
CIDR block allowed to connect via SSH | 185.71.137.32/32 |
aws_ami |
AMI ID for the EC2 instance | ami-0148251f3f228d827 |
aws_instance_type |
EC2 instance type (t3.micro or t3.nano) |
t3.micro |
aws_key_name |
Name of the AWS Key Pair resource | key-pair |
Variables can be overridden in the Terraform Cloud workspace under Variables > Terraform Variables.
| Name | Description |
|---|---|
public_ip |
Public DNS of the EC2 instance |
private_key_s3_uri |
S3 URI of the uploaded private key |
The repository includes a single GitHub Actions workflow (.github/workflows/terraform.yml) that handles all Terraform operations through Terraform Cloud. It requires one GitHub secret: TF_API_TOKEN.
| Trigger | plan job |
apply job |
destroy job |
|---|---|---|---|
| Pull request | Speculative plan (preview only) | Skipped | Skipped |
Push to main |
Real plan | Requires approval → applies | Skipped |
Manual → plan |
Speculative plan (preview only) | Skipped | Skipped |
Manual → apply |
Real plan | Requires approval → applies | Skipped |
Manual → destroy |
Real destroy plan | Skipped | Requires approval → destroys |
Plan job — always runs first. Uploads the Terraform configuration to TFC and creates a run. For pull requests and manual plan operations the run is speculative (preview only, can never be applied). For apply and destroy operations the run is real and sits waiting for confirmation.
Apply / Destroy jobs — both depend on the plan job and reuse the same TFC run via its run_id. They will not start until a reviewer manually approves the tf-apply environment in GitHub. The reviewer can check the plan summary written to the workflow run's Summary tab before approving.
Approval gate — configured via a GitHub environment named tf-apply. Set it up under Settings → Environments → tf-apply → Required reviewers.
Every workflow run writes a Terraform plan summary to the GitHub Actions Summary tab showing resources to add, change, and destroy, plus a direct link to the full plan in Terraform Cloud.
Go to Actions → 👷 Terraform Build & Destroy → Run workflow and select the operation:
plan— preview changes without applyingapply— plan then apply (requires approval)destroy— plan the destruction then destroy (requires approval)
Runs are triggered automatically in Terraform Cloud on workspace changes. To run manually via CLI:
cd terraform
terraform login
terraform init
terraform apply- Download the private key from S3:
aws s3 cp $(terraform output -raw private_key_s3_uri) ./private_key.pem
chmod 400 private_key.pem- Connect:
ssh -i private_key.pem ubuntu@$(terraform output -raw public_ip)terraform destroy