You need a small virtual machine that can upload files to object storage. The fast-but-wrong path is to create an Identity and Access Management (IAM) access key, SSH into the box, and paste credentials into ~/.aws/credentials. That works once, then those keys live on disk, get copied into scripts, and eventually leak.
In this article, you will use Terraform to launch an Elastic Compute Cloud (EC2) instance and attach an IAM role so the instance can upload to one Simple Storage Service (S3) bucket without storing AWS access keys on the server.
Who this is for: Developers and DevOps engineers learning Terraform on AWS.
What you will build: One EC2 instance, one private S3 bucket, one IAM role with least-privilege upload permissions, and a working aws s3 cp test from the instance.
Prerequisites:
- An AWS account with permissions to create EC2, S3, IAM, and security groups
- Terraform 1.5+ installed locally
-
AWS CLI configured locally (
aws configure) - An EC2 key pair already created in your target AWS Region (for SSH access)
Versions used in this tutorial:
- Terraform
1.5+ - AWS provider
~> 5.0 - Amazon Linux 2023 AMI
TL;DR
- Do not put IAM access keys on EC2 for S3 uploads; use an IAM instance profile instead.
- Create the S3 bucket first, then attach a role policy scoped to that bucket and an
uploads/prefix. - Launch EC2 in your default virtual private cloud (VPC) with a security group that allows SSH only from your IP.
- Verify access from the instance with
aws s3 cpcredentials that come from instance metadata automatically. - Run
terraform destroywhen finished to avoid ongoing EC2 charges.
Why putting access keys on EC2 fails
| Approach | Why teams use it | Why it breaks |
|---|---|---|
| IAM user access keys on the server | Fast to test uploads | Keys sit on disk, are hard to rotate, and expand blast radius if the instance is compromised |
Overly broad IAM policy (s3:* on *) |
Fewer permission errors during setup | One compromised instance can read, delete, or overwrite unrelated buckets |
| Manual console clicks only | No infrastructure as code (IaC) history | Drift, no repeatable setup, hard to audit |
Key idea: The EC2 instance should assume an IAM role at boot. AWS injects short-lived credentials through the instance metadata service. Your Terraform code documents exactly what the instance can do.
What you are building
Your laptop (Terraform)
|
v
AWS API calls
|
+--> S3 bucket (uploads/ prefix)
|
+--> IAM role + instance profile
|
+--> EC2 instance (assumes role, uploads via AWS CLI)
Step 1: Create the project layout
Create a new folder and four files:
mkdir terraform-ec2-s3-upload && cd terraform-ec2-s3-upload
touch versions.tf variables.tf main.tf outputs.tf
Your layout should look like this:
terraform-ec2-s3-upload/
versions.tf
variables.tf
main.tf
outputs.tf
Step 2: Pin provider versions
Add provider constraints in versions.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
Step 3: Define input variables
Add tunable values in variables.tf:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Prefix for resource names"
type = string
default = "tf-ec2-s3-demo"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "key_name" {
description = "Existing EC2 key pair name in this region"
type = string
}
variable "ssh_cidr" {
description = "Your public IP in CIDR format for SSH access (example: 203.0.113.10/32)"
type = string
}
Create a terraform.tfvars file with your values:
aws_region = "us-east-1"
key_name = "your-key-pair-name"
ssh_cidr = "YOUR_PUBLIC_IP/32"
To find your public IP:
curl -s https://checkip.amazonaws.com
Append /32 to that IP for ssh_cidr.
Step 4: Create the S3 bucket
Add the bucket and hardening settings in main.tf:
resource "aws_s3_bucket" "uploads" {
bucket = "${var.project_name}-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_public_access_block" "uploads" {
bucket = aws_s3_bucket.uploads.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
data "aws_caller_identity" "current" {}
Bucket name includes your account ID to reduce naming collisions.
Step 5: Create IAM role and least-privilege S3 policy
Still in main.tf, define what the EC2 instance is allowed to do:
data "aws_iam_policy_document" "ec2_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ec2_upload_role" {
name = "${var.project_name}-ec2-role"
assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
}
data "aws_iam_policy_document" "s3_upload_policy" {
statement {
sid = "ListBucketPrefix"
effect = "Allow"
actions = [
"s3:ListBucket",
]
resources = [aws_s3_bucket.uploads.arn]
condition {
test = "StringLike"
variable = "s3:prefix"
values = ["uploads/*"]
}
}
statement {
sid = "UploadObjectsOnly"
effect = "Allow"
actions = [
"s3:PutObject",
"s3:AbortMultipartUpload",
]
resources = ["${aws_s3_bucket.uploads.arn}/uploads/*"]
}
}
resource "aws_iam_policy" "s3_upload_policy" {
name = "${var.project_name}-s3-upload"
policy = data.aws_iam_policy_document.s3_upload_policy.json
}
resource "aws_iam_role_policy_attachment" "attach_upload_policy" {
role = aws_iam_role.ec2_upload_role.name
policy_arn = aws_iam_policy.s3_upload_policy.arn
}
resource "aws_iam_instance_profile" "ec2_profile" {
name = "${var.project_name}-instance-profile"
role = aws_iam_role.ec2_upload_role.name
}
This role can upload only to uploads/* inside one bucket. It cannot delete objects or read unrelated buckets.
Step 6: Use default VPC networking and security group
For a beginner-friendly setup, use the default VPC and one public subnet:
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
}
resource "aws_security_group" "ec2_sg" {
name = "${var.project_name}-sg"
description = "Allow SSH from your IP only"
vpc_id = data.aws_vpc.default.id
ingress {
description = "SSH from admin IP"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Step 7: Launch the EC2 instance
Add the Amazon Linux 2023 AMI lookup and EC2 instance:
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-kernel-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = var.instance_type
subnet_id = data.aws_subnets.default.ids[0]
vpc_security_group_ids = [aws_security_group.ec2_sg.id]
key_name = var.key_name
iam_instance_profile = aws_iam_instance_profile.ec2_profile.name
user_data = <<-EOF
#!/bin/bash
dnf update -y
dnf install -y awscli
EOF
tags = {
Name = "${var.project_name}-ec2"
}
}
Add outputs in outputs.tf:
output "ec2_public_ip" {
description = "Public IP for SSH"
value = aws_instance.app.public_ip
}
output "s3_bucket_name" {
description = "Target bucket for uploads"
value = aws_s3_bucket.uploads.id
}
output "ssh_command" {
description = "SSH command example"
value = "ssh -i ~/.ssh/${var.key_name}.pem ec2-user@${aws_instance.app.public_ip}"
}
Step 8: Initialize and apply Terraform
Run Terraform from the project folder:
terraform init
terraform plan
terraform apply
terraform apply; Type yes when prompted.

Step 9: SSH into the instance
Use the output SSH command:
ssh -i ~/.ssh/your-key-pair-name.pem ec2-user@<EC2_PUBLIC_IP>
If the connection fails, confirm:
-
ssh_cidrmatches your current public IP. - Your key file permissions are
chmod 400. - The instance status is
running.
Step 10: Verify S3 upload from EC2
On the EC2 instance, confirm AWS CLI identity:
aws sts get-caller-identity
You should see the role ARN for ${project_name}-ec2-role, not an IAM user.
Upload a test file:
echo "upload test $(date)" > /tmp/upload-test.txt
aws s3 cp /tmp/upload-test.txt s3://<BUCKET_NAME>/uploads/upload-test.txt
aws s3 ls s3://<BUCKET_NAME>/uploads/
Replace <BUCKET_NAME> with the s3_bucket_name Terraform output.
How to verify this works
Run this quick checklist:
-
terraform outputshowsec2_public_ipands3_bucket_name. -
aws sts get-caller-identityon EC2 returns the instance role ARN. -
aws s3 cptouploads/succeeds from EC2. - Upload to a different prefix (for example,
secrets/) fails withAccessDenied. - No access keys exist in
/home/ec2-user/.aws/credentialson the instance.
The last check confirms you are using role-based access, not static keys.
Clean up to avoid charges
When you finish testing:
terraform destroy
Type yes to remove EC2, IAM, and S3 resources created by this tutorial.
When this breaks down
- Default VPC missing: Some AWS accounts disable the default VPC. You will need to create a VPC and public subnet first.
-
SSH blocked by IP change: If your home IP changes, update
ssh_cidrand runterraform applyagain. -
Too open security groups: Never set SSH to
0.0.0.0/0in real environments. -
Policy too broad:
s3:*on*is convenient for demos but unsafe for production workloads. - No remote state backend: This tutorial uses local Terraform state. For team use, move state to S3 with locking (DynamoDB).
Frequently asked questions
Q: Why not attach an IAM user access key to EC2?
Access keys are long-lived secrets. IAM roles provide temporary credentials that rotate automatically and are tied to the instance lifecycle.
Q: Can this instance read objects from S3?
Not with the policy in this tutorial. It can upload to uploads/* only. Add explicit s3:GetObject permissions only if your workload needs reads.
Q: Is this production-ready?
It is a strong learning baseline, not a production baseline. Production setups usually add private subnets, Systems Manager (SSM) Session Manager instead of public SSH, encryption, logging, and tighter network controls.
What I learned building this
- IAM instance profiles are the default pattern for EC2-to-AWS service access.
- Prefix-scoped S3 policies reduce damage if an instance is compromised.
- Terraform makes role and bucket permissions reviewable in pull requests.
Project on GitHub
- Terraform project on GitHub with EC2, IAM role, and S3 upload policy.























