AWS OpenTofu #107: Secure EC2 Management with IAM & SSM
Eliminate SSH key management, open port 22 vulnerabilities, and bastion hosts. Build IAM roles, attach instance profiles, and manage private EC2 instances securely with AWS Systems Manager Session Manager.
AWS + OpenTofu Level 100: Infrastructure as Code from Zero
Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.
In Lab 106, we launched our first EC2 instance and took an uncompromising stance on network security: we refused to open SSH port 22 to 0.0.0.0/0.
This immediately raises a critical operational challenge:
“If port 22 is closed and our security group allows zero inbound management traffic, how do engineers connect to the server to troubleshoot bugs, view application logs, or perform maintenance?”
Historically, cloud engineering teams solved this with SSH Key Pairs and Bastion Hosts (Jump Boxes):
- The Private Key Nightmare: Engineers generated an RSA key pair (
id_rsaor.pem). They shared the file over Slack, stored it on unencrypted developer laptops, or embedded it into CI/CD scripts. - Employee Offboarding Chaos: When an engineer left the company, how did security revoke their access? Because the public key was baked into
/home/ec2-user/.ssh/authorized_keysacross 60 virtual machines, revoking access required an emergency rotation sprint across the entire fleet. - The Bastion Host Tax: Companies deployed dedicated EC2 instances (“Jump Boxes”) running 24/7 with open port 22 to act as gatekeepers. These bastion hosts added hundreds of dollars to monthly AWS bills, required operating system patching, and became high-value targets for external attackers.
Modern cloud engineering has abandoned SSH keys and bastion hosts entirely in favor of AWS Systems Manager (SSM) Session Manager.
In this lab, you will configure Zero-Trust EC2 Management. Using OpenTofu, you will define an IAM Role, create an IAM Instance Profile, attach the AWS-managed AmazonSSMManagedInstanceCore policy, and launch a server with zero inbound firewall rules. You will open an interactive, encrypted shell directly from your terminal over AWS control plane APIs—with full auditability and zero open ports.
🎯 What You Will Learn
| AWS Identity & Systems Management | OpenTofu IAM Architecture | Engineering & Zero-Trust Standards |
|---|---|---|
| • AWS Systems Manager (SSM) Agent mechanics | • Declaring aws_iam_role & AssumeRole trust policies | • True Zero-Trust: Zero open inbound firewall rules |
• The AmazonSSMManagedInstanceCore managed policy | • Managing policy attachments with OpenTofu | • Eliminating SSH keys, .pem files, & bastion hosts |
| • IAM Roles vs. IAM Instance Profiles | • Creating and linking aws_iam_instance_profile | • Centralized audit trails via AWS CloudTrail |
• Interactive shell access via aws ssm start-session | • Zero-ingress Security Group architecture | • Executing remote commands via aws ssm send-command |
💰 Estimated Lab Cost
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| Amazon EC2 Virtual Machine | t3.micro On-Demand (~1 hour) | $0.00 | ~$0.012 / hour |
| Amazon EBS Storage | 20 GB gp3 Root Volume (~1 hour) | $0.00 | ~$0.002 / hour ($0.08/GB-mo) |
| Public IPv4 Address | Auto-assigned IPv4 (~1 hour) | $0.00 | $0.005 / hour (AWS IPv4 charge) |
| AWS Systems Manager (SSM) | Session Manager interactive access | $0.00 | $0.00 (Always 100% Free) |
| AWS IAM Roles & Policies | Identity and Access Management | $0.00 | $0.00 (Always 100% Free) |
| Total Estimated Cost | Complete Hands-On Session (~1 hour) | $0.00 | ~$0.019 (~2 cents total) |
[!NOTE] SSM is 100% Free: AWS does not charge any fees for SSM Session Manager connections or IAM role evaluations. If you have passed the 12-month Free Tier, your only cost will be running the
t3.microinstance during the exercise (~2 cents), which will be completely destroyed when you runtofu destroy.
🛡️ Architecture: The Zero-Trust Management Model
Let’s compare the old, insecure SSH architecture with modern AWS Systems Manager Session Manager:
THE OLD WAY: VULNERABLE & EXPENSIVE (SSH & BASTIONS)
===================================================================================
Engineer Laptop ──[ SSH Key / Port 22 ]──► [ Bastion Host ] ──► [ EC2 Server ]
▲
│ Port 22 open to internet (Attacked by botnets)
│ Ongoing EC2 compute costs for Bastion
THE MODERN ZERO-TRUST WAY: AWS SYSTEMS MANAGER SESSION MANAGER
===================================================================================
Engineer Terminal ──[ HTTPS / SigV4 ]──► [ AWS SSM Service API ]
│
│ Encrypted WebSocket (Outbound only)
▼
[ EC2 Virtual Machine ]
(Runs SSM Agent via IAM Role)
- INBOUND PORTS: NONE (0 rules!)
- OUTBOUND PORTS: HTTPS 443 (To AWS SSM)
- SSH KEYS: NONE (IAM Authenticated)
How Session Manager Works Under the Hood:
- The SSM Agent: Pre-installed by AWS on all modern Amazon Linux 2023 and Ubuntu server images.
- Outbound Communication Only: The SSM Agent initiates an outbound encrypted TLS connection on TCP port 443 to the regional AWS Systems Manager endpoint (
ssm.ap-southeast-1.amazonaws.com). - No Inbound Open Ports: Because the connection is outbound from the server to AWS, your Security Group requires zero inbound rules. You can block all ingress traffic completely.
- IAM-Governed Access: When an engineer runs
aws ssm start-session, AWS verifies the engineer’s IAM permissions (ssm:StartSession). If authorized, AWS bridges the terminal session to the instance’s outbound channel. - Auditing: Every command executed can be recorded and streamed directly to Amazon CloudWatch Logs or an S3 audit bucket.
🧩 The Three-Layer IAM Bridge: Role, Policy, Instance Profile
In AWS, you cannot attach an IAM Role directly to an EC2 instance. Instead, AWS uses a three-layer bridge:
+-------------------------------------------------------------------------------+
| THE IAM-TO-EC2 BRIDGE |
| |
| 1. IAM POLICY |
| arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore |
| (Grants permission to register with SSM, stream logs, and receive commands|
| │ |
| ▼ (Attached via aws_iam_role_policy_attachment) |
| 2. IAM ROLE |
| aws_iam_role.ssm_instance_role |
| Trust Policy: Allows 'ec2.amazonaws.com' to AssumeRole |
| │ |
| ▼ (Encapsulated inside) |
| 3. IAM INSTANCE PROFILE |
| aws_iam_instance_profile.ssm_profile |
| The physical container that passes credentials to the EC2 hypervisor |
| │ |
| ▼ (Injected into) |
| 4. EC2 INSTANCE |
| aws_instance.server.iam_instance_profile = aws_iam_instance_profile.id |
+-------------------------------------------------------------------------------+
📁 Step 1: Set Up Lab Directory Structure
Navigate to your ~/opentofu-labs repository and create the directory for Lab 107:
cd ~/opentofu-labs
mkdir -p lab-107-ec2-ssm
cd lab-107-ec2-ssm
We will organize our OpenTofu code into focused configuration files:
lab-107-ec2-ssm/
├── providers.tf # Engine & AWS Provider configuration
├── variables.tf # Input parameters (region, environment)
├── data.tf # Discovers latest AMI & default VPC
├── iam.tf # IAM Role, Policy Attachment & Instance Profile
├── main.tf # Zero-ingress Security Group & EC2 Instance
└── outputs.tf # Instance ID & ready-to-run SSM CLI command
⚙️ Step 2: Configure Providers & Variables
Create providers.tf:
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "OpenTofu-Level-100"
Environment = var.environment
ManagedBy = "OpenTofu"
LabPart = "107"
}
}
}
Create variables.tf:
variable "aws_region" {
type = string
description = "Target AWS region."
default = "ap-southeast-1"
}
variable "environment" {
type = string
description = "Target deployment environment."
default = "dev"
}
variable "server_name" {
type = string
description = "Name tag for the EC2 server."
default = "tofu-ssm-demo"
}
🔎 Step 3: Discover AMI & Default VPC (data.tf)
Create data.tf to fetch the latest Amazon Linux 2023 AMI and your account’s default VPC:
# 1. Fetch official Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-kernel-6.1-x86_64"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# 2. Discover default VPC for security group placement
data "aws_vpc" "default" {
default = true
}
🔑 Step 4: Construct the IAM Role & Instance Profile (iam.tf)
Now let’s build the three-layer IAM bridge in iam.tf:
# 1. IAM Role with EC2 AssumeRole Trust Policy
resource "aws_iam_role" "ssm_instance_role" {
name = "${var.server_name}-role"
description = "Allows EC2 instances to communicate with AWS Systems Manager"
# The Trust Policy: Tells AWS that the EC2 service is allowed to assume this role
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
tags = {
Name = "${var.server_name}-role"
}
}
# 2. Attach the AWS-Managed SSM Policy to the Role
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.ssm_instance_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
# 3. Create the IAM Instance Profile (The container passed to the EC2 instance)
resource "aws_iam_instance_profile" "ssm_profile" {
name = "${var.server_name}-instance-profile"
role = aws_iam_role.ssm_instance_role.name
tags = {
Name = "${var.server_name}-instance-profile"
}
}
Key Concept: What is sts:AssumeRole?
The assume_role_policy is a Trust Policy. It does not grant permissions to do anything in AWS; instead, it defines who is allowed to wear this role’s hat. By setting Principal = { Service = "ec2.amazonaws.com" }, we strictly restrict this role so that only EC2 hypervisors can request temporary credentials on behalf of attached virtual machines.
🛡️ Step 5: Declare Zero-Ingress Security Group & Instance (main.tf)
Create main.tf.
Observe the Security Group: there are literally zero ingress blocks. Port 22, Port 80, Port 443 inbound—all are completely nonexistent!
# 1. Zero-Ingress Security Group: 100% Inbound Traffic Blocked!
resource "aws_security_group" "zero_ingress" {
name = "${var.server_name}-zero-ingress-sg"
description = "Zero inbound access. Outbound HTTPS allowed for SSM communication."
vpc_id = data.aws_vpc.default.id
# NOTE: NO INGRESS BLOCK! All inbound connections are rejected at the hypervisor.
# Outbound Egress: Must allow outbound HTTPS (TCP 443) so SSM Agent can reach AWS APIs
egress {
description = "Allow outbound HTTPS traffic to AWS Systems Manager endpoints"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.server_name}-zero-ingress-sg"
}
}
# 2. EC2 Virtual Machine with Attached Instance Profile
resource "aws_instance" "secure_server" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = "t3.micro"
# Attach our zero-ingress firewall
vpc_security_group_ids = [aws_security_group.zero_ingress.id]
# Inject the IAM Instance Profile created in iam.tf
iam_instance_profile = aws_iam_instance_profile.ssm_profile.name
# Encrypted root volume
root_block_device {
volume_type = "gp3"
volume_size = 20
encrypted = true
delete_on_termination = true
}
# Enforce IMDSv2
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
tags = {
Name = var.server_name
Role = "PrivateManagedWorkload"
}
}
[!IMPORTANT] Why Outbound Port 443 is Required: The SSM Agent running inside the EC2 operating system needs to establish an outbound TLS connection to
https://ssm.<region>.amazonaws.com. If you block outbound port 443 in your egress rules, the instance cannot communicate with the AWS Systems Manager service.
📤 Step 6: Expose Outputs & Connection Command (outputs.tf)
Create outputs.tf to generate the exact connection command:
output "instance_id" {
description = "The EC2 Instance ID."
value = aws_instance.secure_server.id
}
output "iam_role_arn" {
description = "The ARN of the IAM Role attached to the instance."
value = aws_iam_role.ssm_instance_role.arn
}
output "ssm_connect_command" {
description = "Ready-to-run AWS CLI command to establish an encrypted shell session."
value = "aws ssm start-session --target ${aws_instance.secure_server.id}"
}
output "security_posture" {
description = "Audit summary of security controls applied."
value = {
open_inbound_ports = "NONE (0 inbound rules)"
ssh_port_22 = "BLOCKED"
management_protocol = "AWS Systems Manager (Encrypted TLS)"
root_encryption = "Enforced (gp3 AES-256)"
metadata_service = "IMDSv2 Enforced"
}
}
🚀 Step 7: Preview & Provision (tofu apply)
Initialize the directory:
tofu init
tofu fmt
tofu validate
Run tofu plan to review the proposed resources:
tofu plan
Notice the plan summary:
Plan: 4 to add, 0 to change, 0 to destroy.
+ aws_iam_instance_profile.ssm_profile
+ aws_iam_role.ssm_instance_role
+ aws_iam_role_policy_attachment.ssm_core
+ aws_instance.secure_server
+ aws_security_group.zero_ingress
Apply the configuration:
tofu apply -auto-approve
aws_iam_role.ssm_instance_role: Creating...
aws_security_group.zero_ingress: Creating...
aws_iam_role.ssm_instance_role: Creation complete after 1s
aws_iam_role_policy_attachment.ssm_core: Creating...
aws_iam_instance_profile.ssm_profile: Creating...
aws_security_group.zero_ingress: Creation complete after 2s
aws_iam_role_policy_attachment.ssm_core: Creation complete after 1s
aws_iam_instance_profile.ssm_profile: Creation complete after 1s
aws_instance.secure_server: Creating...
aws_instance.secure_server: Creation complete after 25s [id=i-0123456789abcdef0]
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
Outputs:
iam_role_arn = "arn:aws:iam::123456789012:role/tofu-ssm-demo-role"
instance_id = "i-0123456789abcdef0"
ssm_connect_command = "aws ssm start-session --target i-0123456789abcdef0"
⚡ Step 8: Open an Interactive Terminal Session via SSM
Now for the magic moment.
Wait 60 to 90 seconds after creation to allow the EC2 instance to complete its hardware boot sequence and for the SSM Agent to register with AWS Systems Manager.
1. Check SSM Online Status
Verify that your instance has checked in with the SSM fleet manager:
INSTANCE_ID=$(tofu output -raw instance_id)
aws ssm describe-instance-information \
--filters "Key=InstanceIds,Values=$INSTANCE_ID" \
--query "InstanceInformationList[0].{PingStatus:PingStatus, AgentVersion:AgentVersion, Platform:PlatformName}" \
--output table
Output:
----------------------------------------------------------------------
| DescribeInstanceInformation |
+---------------+------------------------+---------------------------+
| AgentVersion | PingStatus | Platform |
+---------------+------------------------+---------------------------+
| 3.3.1611.0 | Online | Amazon Linux |
+---------------+------------------------+---------------------------+
PingStatus: Online confirms the SSM agent has registered successfully!
2. Connect to the Server Shell
Run the output command:
aws ssm start-session --target $INSTANCE_ID
You will instantly be connected to a secure bash shell directly inside the Amazon Linux operating system:
Starting session with SessionId: admin-prawira-0123456789abcdef0
sh-5.2$ whoami
ssm-user
sh-5.2$ uname -a
Linux ip-172-31-14-82.ap-southeast-1.compute.internal 6.1.128-135.201.amzn2023.x86_64
sh-5.2$ sudo su -
[root@ip-172-31-14-82 ~]# uptime
13:45:10 up 2 min, 0 users, load average: 0.05, 0.08, 0.03
You are logged into your virtual server with zero open inbound ports on the firewall, zero SSH keys on your laptop, and zero bastion hosts on your bill!
To disconnect, type:
exit
exit
📡 Step 9: Execute Remote Commands Without a Shell (send-command)
What if you need to run a maintenance script or retrieve logs from 50 servers simultaneously without opening an interactive shell?
With SSM, you can use Run Command:
aws ssm send-command \
--instance-ids $INSTANCE_ID \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["cat /etc/os-release | grep PRETTY_NAME", "df -h /"]' \
--output text --query "Command.CommandId"
Save the printed CommandId and fetch the execution output:
COMMAND_ID="<your-command-id>"
aws ssm get-command-invocation \
--command-id $COMMAND_ID \
--instance-id $INSTANCE_ID \
--query "StandardOutputContent" \
--output text
Output:
PRETTY_NAME="Amazon Linux 2023.6.20260304"
Filesystem Size Used Avail Use% Mounted on
/dev/xvda4 20G 1.8G 19G 9% /
Every command executed is cryptographically authenticated via IAM and permanently logged in AWS CloudTrail for enterprise compliance auditing.
🧹 Step 10: Clean Teardown (tofu destroy)
Tear down the lab resources:
tofu destroy -auto-approve
Output:
aws_instance.secure_server: Destroying... [id=i-0123456789abcdef0]
aws_instance.secure_server: Destruction complete after 30s
aws_security_group.zero_ingress: Destroying...
aws_iam_instance_profile.ssm_profile: Destroying...
aws_iam_role_policy_attachment.ssm_core: Destroying...
aws_iam_role.ssm_instance_role: Destroying...
Destroy complete! Resources: 4 destroyed.
All compute, firewall, and IAM profile resources are completely deleted. Your AWS account returns to zero cost.
🛠️ Common Troubleshooting & Gotchas
1. Error: SessionManagerPlugin is not found
- Cause: The
aws ssm start-sessioncommand requires a lightweight local client plugin (session-manager-plugin) installed on your workstation. - Fix: Install the official AWS Session Manager plugin:
- On macOS:
brew install --cask session-manager-plugin - On Linux (Ubuntu/Debian):
curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o "session-manager-plugin.deb" sudo dpkg -i session-manager-plugin.deb - On Windows: Download the official MSI from AWS documentation.
- On macOS:
2. TargetNotConnected Error When Running start-session
- Cause: Attempting to connect before the SSM Agent has finished checking in, or outbound port 443 is blocked.
- Fix: Wait 60 seconds after
tofu applyand runaws ssm describe-instance-informationto confirmPingStatus: Onlinebefore starting a session.
3. Missing IAM Instance Profile
- Gotcha: If you attach the
aws_iam_roledirectly to the EC2 instance without creating anaws_iam_instance_profile, OpenTofu will throw a schema error. Remember: EC2 instances require an Instance Profile, not a bare role.
🏋️ Hands-On Challenge
Before moving to Lab 108:
- Attach Read-Only S3 Access to the EC2 Role:
In
iam.tf, add a secondaws_iam_role_policy_attachmentthat attaches the AWS-managed policyarn:aws:iam::aws:policy/AmazonS3ReadOnlyAccesstoaws_iam_role.ssm_instance_role.name. - Run
tofu apply. - Open an SSM session using
aws ssm start-session --target <id>. - Run
aws s3 lsfrom inside the server shell. Notice that the instance can query S3 buckets without configuring any access keys, passwords, or credentials inside the operating system! The credentials are automatically rotated and provided by the IAM instance profile metadata.
📖 Summary & What You Learned
In this lab, you implemented enterprise Zero-Trust server access:
- Zero-Trust Networking: Built a Security Group with zero inbound rules, eliminating port 22 SSH vulnerability scans.
- The IAM-to-EC2 Bridge: Understood how IAM Policies, IAM Roles (
sts:AssumeRole), and IAM Instance Profiles connect identity to virtual compute. - AWS Systems Manager: Leveraged the outbound SSM Agent architecture to establish encrypted TLS sessions.
- Auditable Operations: Used
aws ssm start-sessionfor interactive terminals andaws ssm send-commandfor remote script execution. - Bastion Elimination: Removed the architectural and financial overhead of maintaining 24/7 bastion hosts.
🚀 What’s Next? (Lab 108)
In this lab, we used an AWS-managed policy (AmazonSSMManagedInstanceCore). But in production environments, security teams will never allow broad managed policies on sensitive workloads.
In [AWS OpenTofu #108: IAM Policies & Least Privilege], we will:
- Write custom, granular IAM policies from scratch using the
aws_iam_policy_documentdata source. - Master the four core IAM policy elements: Principal, Action, Resource, and Effect.
- Implement explicit deny rules and permission boundaries.
- Audit IAM policy evaluation logic to enforce strict Least Privilege.