Systems Manager Session
AWS Systems Manager Session Manager provides secure EC2 access without SSH keys, bastion hosts, or open inbound ports. Set up VPC endpoints (ssm, ssmmessages), attach an IAM role with SSM permissions to your EC2, and connect via Console or CLI. Works with instances in private subnets with no internet access.

Introduction
AWS Systems Manager Session Manager is a fully managed AWS service that allows you to securely connect to your EC2 instances (Linux or Windows) without needing to open inbound ports, manage SSH keys, or use a bastion host. In this post, I will demonstrate how an EC2 sitting in a private subnet, without any access to the Internet, can be connected to using SSM through the AWS Console and remotely.
Key Features
- Uses SSM agent which runs on your instance
- No need for SSH server or RDP listener
- All traffic is encrypted with TLS
- Logging can be enabled for session activity
- Works with Linux, MacOS, and Windows EC2 instances
- Can be initiated through AWS Console, CLI, or SDK
The SSM agent comes pre-installed on Amazon Linux 2, Amazon Linux 2023, and Windows Server AMIs. For other operating systems, you may need to install it manually.
Logical

Players
- IAM user with the console access, CLI credentials, and policy enabled for SSM
- EC2 with SSM role and security group for SSM traffic outbound
- VPC Endpoints with security group for SSM traffic outbound and EC2 traffic inbound
- VPC with two private subnets without any Internet traffic inbound or outbound
For private subnets without internet access, you MUST create VPC endpoints for both ssm and ssmmessages services. Without these endpoints, the SSM agent cannot communicate with the Systems Manager service.
VPC with Two Private Subnets
provider "aws" {
region = "us-east-1"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "my-vpc"
}
}
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = false
tags = {
Name = "private-subnet-a"
}
}
resource "aws_subnet" "private_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = false
tags = {
Name = "private-subnet-b"
}
}
IAM User with Policy
provider "aws" {
region = "us-east-1"
}
resource "aws_iam_user" "mgnuser" {
name = "mgnuser"
tags = {
Purpose = "MGN Session Access"
}
}
resource "random_password" "mgnuser" {
length = 16
special = true
}
resource "aws_iam_user_login_profile" "mgnuser" {
user = aws_iam_user.mgnuser.name
password_reset_required = true
password = random_password.mgnuser.result
}
resource "aws_iam_access_key" "mgnuser" {
user = aws_iam_user.mgnuser.name
}
resource "aws_iam_user_policy" "mgnuser_inline_policy" {
name = "mgnuser-ssm-inline-policy"
user = aws_iam_user.mgnuser.name
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Action = [
"ssm:StartSession",
"ssm:SendCommand"
],
Resource = [
"arn:aws:ec2:us-east-1:<your-account-id>:instance/<instance-id>",
"arn:aws:ssm:*::document/AWS-StartPortForwardingSession"
]
},
{
Effect = "Allow",
Action = [
"ssm:DescribeSessions",
"ssm:GetConnectionStatus",
"ssm:DescribeInstanceInformation",
"ssm:DescribeInstanceProperties",
"ec2:DescribeInstances"
],
Resource = "*"
},
{
Effect = "Allow",
Action = [
"ssm:TerminateSession",
"ssm:ResumeSession"
],
Resource = [
"arn:aws:ssm:*:*:session/${aws:username}-*"
]
}
]
})
}
output "mgnuser_console_password" {
value = random_password.mgnuser.result
sensitive = true
}
output "mgnuser_access_key_id" {
value = aws_iam_access_key.mgnuser.id
sensitive = true
}
output "mgnuser_secret_access_key" {
value = aws_iam_access_key.mgnuser.secret
sensitive = true
}
Restrict the ssm:StartSession resource to specific instance IDs or use tags with conditions to limit which instances users can connect to. This follows the principle of least privilege.
EC2 Role with Policy
provider "aws" {
region = "us-east-1"
}
# IAM Role for EC2
resource "aws_iam_role" "ec2_ssm_role" {
name = "ec2-ssm-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Principal = {
Service = "ec2.amazonaws.com"
},
Action = "sts:AssumeRole"
}
]
})
tags = {
Purpose = "SSM Messaging Access"
}
}
# Inline Policy
resource "aws_iam_role_policy" "ec2_ssm_inline_policy" {
name = "ssm-messaging-inline"
role = aws_iam_role.ec2_ssm_role.id
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Action = [
"ssm:UpdateInstanceInformation",
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
],
Resource = "*"
}
]
})
}
# IAM Instance Profile (to attach to EC2)
resource "aws_iam_instance_profile" "ec2_ssm_instance_profile" {
name = "ec2-ssm-instance-profile"
role = aws_iam_role.ec2_ssm_role.name
}
Security Group for EC2
provider "aws" {
region = "us-east-1"
}
resource "aws_security_group" "self_referencing_sg" {
name = "self-referencing-sg"
description = "Allow all outbound traffic to itself only"
vpc_id = aws_vpc.main.id
tags = {
Name = "self-referencing-sg"
}
}
resource "aws_security_group_rule" "egress_self" {
type = "egress"
from_port = 0
to_port = 0
protocol = "-1" # all protocols
security_group_id = aws_security_group.self_referencing_sg.id
source_security_group_id = aws_security_group.self_referencing_sg.id
description = "Allow all outbound traffic to itself"
}
Security Group for VPC Endpoints
resource "aws_security_group" "https_from_sg" {
name = "https-from-self-referencing-sg"
description = "Allow inbound 443 from self-referencing SG"
vpc_id = aws_vpc.main.id
tags = {
Name = "https-from-self-referencing-sg"
}
}
resource "aws_security_group_rule" "ingress_https_from_self_sg" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
security_group_id = aws_security_group.https_from_sg.id
source_security_group_id = aws_security_group.self_referencing_sg.id
description = "Allow HTTPS from self-referencing SG"
}
resource "aws_security_group_rule" "egress_all" {
type = "egress"
from_port = 0
to_port = 0
protocol = "-1" # all protocols
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
security_group_id = aws_security_group.https_from_sg.id
description = "Allow all outbound traffic"
}
VPC Endpoint 1 - SSM
resource "aws_vpc_endpoint" "ssm_endpoint" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.ssm"
vpc_endpoint_type = "Interface"
subnet_ids = [
aws_subnet.private_a.id,
aws_subnet.private_b.id
]
security_group_ids = [
aws_security_group.https_from_sg.id
]
private_dns_enabled = true
tags = {
Name = "ssm-endpoint"
}
}
VPC Endpoint 2 - SSM Messages
resource "aws_vpc_endpoint" "ssmmessages_endpoint" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.ssmmessages"
vpc_endpoint_type = "Interface"
subnet_ids = [
aws_subnet.private_a.id,
aws_subnet.private_b.id
]
security_group_ids = [
aws_security_group.https_from_sg.id
]
private_dns_enabled = true
tags = {
Name = "ssmmessages-endpoint"
}
}
EC2 with Role and Security Group
resource "aws_instance" "ssm_ec2" {
ami = "ami-02e3d076cbd5c28fa"
instance_type = "t3.micro"
subnet_id = aws_subnet.private_a.id
vpc_security_group_ids = [aws_security_group.self_referencing_sg.id]
iam_instance_profile = aws_iam_instance_profile.ec2_ssm_instance_profile.name
associate_public_ip_address = false
key_name = null
tags = {
Name = "ssm-managed-ec2"
}
}
Connecting to the EC2 - AWS Console
Once you run the terraform script to create all the players, you will be able to login as the user that was created, go to EC2 console, and Connect to PowerShell of the EC2 through SSM Session Manager, all without a jumpbox/bastion host.
Connecting to the EC2 - Outside of AWS Console
From outside of AWS, you can connect to the same private EC2 without the need for a jumpbox/bastion host provided you install the Session Manager plugin located here: https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
Once installed, you can obtain the EC2 Instance ID and run the command to connect to the EC2:
aws ssm start-session --target i-06547163d9f33d195
Troubleshooting
- Instance not showing in Session Manager - Verify the IAM instance profile is attached to the EC2. Check that the SSM agent is running with
sudo systemctl status amazon-ssm-agent(Linux) orGet-Service AmazonSSMAgent(Windows). - "Unable to start session" error - Ensure VPC endpoints for both
ssmandssmmessagesexist. Verify security groups allow HTTPS (443) traffic between EC2 and endpoints. - SSM agent not connecting - Check that
enable_dns_supportandenable_dns_hostnamesare enabled on the VPC. The agent needs to resolve the endpoint DNS names. - Permission denied when starting session - Verify the IAM user/role has
ssm:StartSessionpermission for the specific instance ARN or wildcard. - Session times out immediately - Check the EC2 instance's IAM role has the required
ssmmessages:*permissions. The instance needs these to establish the control and data channels. - Port forwarding not working - Ensure the IAM policy includes permission for the
AWS-StartPortForwardingSessiondocument. Verify the target port is listening on the EC2.
Conclusion
AWS Systems Manager Session Manager is a powerful tool that provides secure, auditable, and convenient access to your EC2 instances without the need for SSH or RDP. By eliminating the need to open inbound ports or manage SSH keys, it significantly reduces your attack surface and simplifies operations.
Whether you're managing a large fleet of Linux or Windows instances, Session Manager ensures compliance and security by integrating seamlessly with IAM for access control and CloudWatch or S3 for session logging.
Incorporating Session Manager into your infrastructure is a best practice for modern cloud environments, especially for organizations focused on security, automation, and operational efficiency.
Bonus - Port Forwarding
If you rather not connect to a PowerShell on the EC2, you can set up port forwarding where you can use remote desktop protocol (for Windows) to connect to the EC2:
aws ssm start-session --target i-06547163d9f33d195 --document-name AWS-StartPortForwardingSession --parameters portNumber="3389",localPortNumber="49225"
The above:
- Uses the Port Forwarding document from AWS to initiate the port forwarding
- Uses your machine's local port of 49225 (make sure this is free)
- Uses the default RDP port of 3389 on the remote EC2 to marry the local port you defined above
- Allows you to use Remote Desktop Protocol (on port 49225) to connect to the remote EC2 to get a full desktop experience