The command is burned into the memory of every cloud engineer: ssh -i key.pem [email protected]
. It’s the classic digital key to the kingdom—the bastion host sitting in a public subnet, bravely facing the internet with port 22 open. I’ve walked into countless environments where this was the standard operating procedure. And every single time, it’s a disaster waiting to happen.
This isn’t just about security hygiene; it’s a ticking time bomb. You’re one leaked SSH key, one unpatched vulnerability, one brute-force attack away from a full-blown breach. Auditors see a security group with 0.0.0.0/0
on port 22 and they immediately see red. Managing key rotation is a manual, error-prone nightmare that nobody wants to own. We built castles in the cloud, but we left the main gate wide open. It’s time to tear down that gate and build a better way in.
Architecture Context
The fundamental flaw of the public bastion host is that it relies on perimeter security—a concept that has completely broken down in the cloud. The “zero-trust” model dictates that we shouldn’t inherently trust any connection, whether it originates from inside or outside the network. We must verify everything.
This is where AWS Systems Manager Session Manager comes in. It allows us to completely eliminate the need for a public-facing instance, open ingress ports, and SSH keys. Instead of connecting to the instance, we connect to the AWS API. We authenticate with IAM, and AWS establishes a secure, auditable tunnel to the SSM agent running on our instance, which lives happily in a private subnet with no direct route to the internet.
Here’s the architectural shift:
The Old, Broken Way (Perimeter Security):
- User on the internet connects to a Public IP address on the Bastion Host.
- Security Group allows TCP port 22 from
0.0.0.0/0
(or a list of brittle corporate IPs). - Authentication is based on a static, long-lived SSH private key.
The New, Zero-Trust Way (IAM-Gated Access):
- The “bastion” instance has no Public IP and lives in a Private Subnet.
- The Security Group has zero inbound rules.
- The user authenticates with AWS via the CLI using short-lived IAM credentials.
- The AWS SSM API connects the user to the SSM agent on the instance.
- All access is governed by fine-grained IAM policies.
This isn’t just an improvement; it’s a complete paradigm shift in how we manage administrative access.
Implementation Details
Let’s build this the right way using Terraform. This blueprint assumes you have a VPC with public and private subnets already configured.
1. The IAM Role for the Bastion Instance
First, our EC2 instance needs permission to communicate with the SSM service. We’ll create an IAM role and attach the AWS managed policy AmazonSSMManagedInstanceCore
. This is the minimum required permission set.
# iac/iam.tf
resource "aws_iam_role" "bastion_host_role" {
name = "bastion-host-ssm-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
tags = {
Name = "BastionHostRole"
}
}
resource "aws_iam_role_policy_attachment" "ssm_managed_instance_core" {
role = aws_iam_role.bastion_host_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "bastion_host_profile" {
name = "bastion-host-instance-profile"
role = aws_iam_role.bastion_host_role.name
}
2. The Private Bastion Instance
Next, we define the EC2 instance itself. Notice three critical things:
associate_public_ip_address = false
– It has no public IP.subnet_id
points to one of our private subnets.iam_instance_profile
attaches the role we just created.
# iac/ec2.tf
resource "aws_security_group" "bastion_sg" {
name = "bastion-sg"
description = "Security group for the bastion host"
vpc_id = var.vpc_id
# NO INGRESS RULES!
# Egress is allowed to fetch patches, etc.
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "BastionSG"
}
}
resource "aws_instance" "bastion_host" {
ami = "ami-0c55b159cbfafe1f0" # Example: Amazon Linux 2
instance_type = "t3.micro"
subnet_id = var.private_subnet_id
associate_public_ip_address = false
iam_instance_profile = aws_iam_instance_profile.bastion_host_profile.name
security_groups = [aws_security_group.bastion_sg.id]
tags = {
Name = "secure-bastion-host"
Project = "zero-trust-access"
}
}
3. VPC Endpoints: Cutting the Cord to the Internet
For a truly secure, SOC 2-ready setup, the bastion shouldn’t even need a NAT Gateway for its API calls. We can provision VPC Endpoints, which use AWS PrivateLink to allow the EC2 instance to communicate with SSM APIs without ever leaving the AWS network.
# iac/vpc_endpoints.tf
# This security group allows traffic from within the VPC on port 443
resource "aws_security_group" "vpc_endpoint_sg" {
name = "vpc-endpoint-sg"
vpc_id = var.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.vpc_cidr]
}
}
# We need three endpoints for full SSM functionality
resource "aws_vpc_endpoint" "ssm" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.ssm"
vpc_endpoint_type = "Interface"
private_dns_enabled = true
subnet_ids = [var.private_subnet_id]
security_group_ids = [aws_security_group.vpc_endpoint_sg.id]
}
resource "aws_vpc_endpoint" "ec2messages" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.ec2messages"
vpc_endpoint_type = "Interface"
private_dns_enabled = true
subnet_ids = [var.private_subnet_id]
security_group_ids = [aws_security_group.vpc_endpoint_sg.id]
}
resource "aws_vpc_endpoint" "ssmmessages" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.ssmmessages"
vpc_endpoint_type = "Interface"
private_dns_enabled = true
subnet_ids = [var.private_subnet_id]
security_group_ids = [aws_security_group.vpc_endpoint_sg.id]
}
With this infrastructure, you can now connect to your instance using the AWS CLI:
# Ensure you have the Session Manager plugin installed for the AWS CLI<br>aws ssm start-session --target <your-instance-id>
You are now in a shell on a private instance, having never touched port 22 or an SSH key.
Architect’s Note
Your bastion isn’t just a jump box anymore; it’s a secure execution environment. This pattern fundamentally changes the bastion’s role. It’s no longer a simple SSH gateway. It becomes your primary control plane entry point for running kubectl
against an EKS cluster, executing Ansible playbooks against your private fleet, or running database migration scripts. By installing your tooling on this hardened, private instance, you create a single, auditable, and secure point of entry for all administrative tasks, drastically reducing your attack surface.
Pitfalls & Optimisations
Deploying this pattern is a huge step forward, but true senior engineering means understanding the second-order consequences.
Pitfall: Overly Permissive IAM
With Session Manager, IAM is your new firewall. A policy that allows ssm:StartSession
on resource *
is the modern equivalent of opening port 22 to the world. You must lock this down. Use IAM policies to grant access to specific instances based on tags.
Here’s an example of a policy that only allows a developer to start a session on instances tagged with Project: zero-trust-access
.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ssm:StartSession",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringLike": {
"ssm:resourceTag/Project": [
"zero-trust-access"
]
}
}
},
{
"Effect": "Allow",
"Action": "ssm:TerminateSession",
"Resource": "arn:aws:ssm:*:*:session/${aws:username}-*"
}
]
}
Optimisation: Enable Audit Logging for Compliance
For any environment under compliance like SOC 2 or HIPAA, you need a record of who did what. Session Manager can log every keystroke of a session to CloudWatch Logs or S3. This provides an immutable audit trail that is a nightmare to achieve with traditional SSH logs.
You can enable this in the Session Manager preferences in the AWS console or via Infrastructure as Code. All session logs are sent to a designated S3 bucket or CloudWatch Log Group, giving you full visibility and making your auditors very happy.
Optimisation: Port Forwarding for Database Access
Session Manager isn’t just for shell access. It can create secure tunnels to any port on your private instance, which is perfect for connecting your local GUI to a private RDS database.
First, update your IAM policy to allow the ssm:StartPortForwardingSession
action. Then, use a command like this:
aws ssm start-session \
--target <instance-id> \
--document-name AWS-StartPortForwardingSession \
--parameters '{"portNumber":["5432"], "localPortNumber":["54321"]}'
This command securely forwards port 54321
on your local machine to port 5432
on the target EC2 instance, which can then connect to your RDS instance. You can now point psql
or DBeaver at localhost:54321
as if the database were running on your own machine.
Unlocked: Your Key Takeaways
- Public Bastions are an Anti-Pattern: Stop exposing instances with open SSH ports to the internet. It’s an unnecessary and significant risk.
- Embrace Zero-Trust: Use AWS Session Manager to provide secure, brokered access to instances in private subnets. Access is authenticated and authorized via IAM for every session.
- IAM is the New Security Group: Your primary security control for instance access shifts from network rules to granular IAM policies. Use tag-based conditions for least-privilege access.
- Go Fully Private with VPC Endpoints: For maximum security and compliance, use VPC Interface Endpoints to ensure SSM traffic never traverses the public internet.
- Audit Everything: Enable Session Manager logging to CloudWatch or S3 to create a detailed, immutable audit trail of all administrative activity.
Moving to a Session Manager-based model isn’t just a security upgrade; it’s a fundamental shift towards a more modern, secure, and auditable way of managing production systems.
If your team is facing this challenge, I specialize in architecting these secure, audit-ready systems.
Email me for a strategic consultation: [email protected]
Explore my projects and connect on Upwork: https://www.upwork.com/freelancers/~0118e01df30d0a918e