DEV Community

Cover image for Terraform EC2 and S3 Upload Access: A Simple IAM Role Setup Without Access Keys
Ilyas Rufai
Ilyas Rufai

Posted on

Terraform EC2 and S3 Upload Access: A Simple IAM Role Setup Without Access Keys

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 cp credentials that come from instance metadata automatically.
  • Run terraform destroy when 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)
Enter fullscreen mode Exit fullscreen mode

Architecture flow: Terraform provisions EC2 with IAM role for S3 uploads

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
Enter fullscreen mode Exit fullscreen mode

Your layout should look like this:

terraform-ec2-s3-upload/
  versions.tf
  variables.tf
  main.tf
  outputs.tf
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

To find your public IP:

curl -s https://checkip.amazonaws.com
Enter fullscreen mode Exit fullscreen mode

Append /32 to that IP for ssh_cidr.

public_ip

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" {}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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}"
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Initialize and apply Terraform

Run Terraform from the project folder:

terraform init
terraform plan
terraform apply
Enter fullscreen mode Exit fullscreen mode

terraform init
Terraform init

terraform plan
Terraform Plan

terraform apply; Type yes when prompted.
Terraform apply

Terraform Applied

Instance running:
Success!

Step 9: SSH into the instance

Use the output SSH command:

ssh -i ~/.ssh/your-key-pair-name.pem ec2-user@<EC2_PUBLIC_IP>
Enter fullscreen mode Exit fullscreen mode

If the connection fails, confirm:

  1. ssh_cidr matches your current public IP.
  2. Your key file permissions are chmod 400.
  3. The instance status is running.

successful SSH session

Step 10: Verify S3 upload from EC2

On the EC2 instance, confirm AWS CLI identity:

aws sts get-caller-identity
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

Replace <BUCKET_NAME> with the s3_bucket_name Terraform output.

s3-upload-success

08-s3-console-object

How to verify this works

Run this quick checklist:

  1. terraform output shows ec2_public_ip and s3_bucket_name.
  2. aws sts get-caller-identity on EC2 returns the instance role ARN.
  3. aws s3 cp to uploads/ succeeds from EC2.
  4. Upload to a different prefix (for example, secrets/) fails with AccessDenied.
  5. No access keys exist in /home/ec2-user/.aws/credentials on 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
Enter fullscreen mode Exit fullscreen mode

Destroying resources

Type yes to remove EC2, IAM, and S3 resources created by this tutorial.

Destroy complete!

When this breaks down

  1. Default VPC missing: Some AWS accounts disable the default VPC. You will need to create a VPC and public subnet first.
  2. SSH blocked by IP change: If your home IP changes, update ssh_cidr and run terraform apply again.
  3. Too open security groups: Never set SSH to 0.0.0.0/0 in real environments.
  4. Policy too broad: s3:* on * is convenient for demos but unsafe for production workloads.
  5. 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

References

Top comments (0)