Bastion-free access with SSM: what developers should know
SSH keys scattered across your team, bastion hosts consuming resources, open SSH ports waiting to be exploited—these pain points disappear when you move to AWS Systems Manager Session Manager. No SSH, no open ports. Instead, you get encrypted, audited terminal sessions directly to your instances through the AWS management plane.
If you've built infrastructure on AWS, you've likely wrestled with secure remote access. The traditional bastion host pattern works, but it adds complexity, management overhead, and another attack surface. Session Manager changes that equation entirely. In this article, we'll walk through what developers and infrastructure teams need to know to implement bastion-free access in production, including SSM agent setup, IAM permission models, database port forwarding, and audit logging—all with concrete Terraform examples you can adapt to your own architecture.
Understanding SSM Agent on instances
Session Manager works through the Systems Manager Agent (SSM Agent), a lightweight daemon that runs on your EC2 instances and communicates outbound to AWS Systems Manager endpoints over HTTPS. Because the connection is initiated from the instance to AWS (not inbound from you to the instance), you never need to open inbound SSH ports or manage SSH keys on the instance itself.
Every Amazon Linux 2, Ubuntu 22.04 LTS, and recent Windows AMI includes SSM Agent pre-installed. If you're using older AMIs or custom images, you'll need to install and configure it. Here's how to ensure it's running and enabled in your Terraform:
resource "aws_instance" "app_server" {
ami = data.aws_ami.amazon_linux_2.id
instance_type = "t3.medium"
iam_instance_profile = aws_iam_instance_profile.ssm_profile.name
user_data = base64encode(file("${path.module}/scripts/install_ssm_agent.sh"))
tag_specifications {
resource_type = "instance"
tags = {
Name = "app-server-prod"
}
}
}
resource "aws_iam_role" "ssm_instance_role" {
name = "ssm-instance-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "ssm_managed_instance_core" {
role = aws_iam_instance_role.ssm_instance_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ssm_profile" {
name = "ssm-instance-profile"
role = aws_iam_role.ssm_instance_role.name
}The AmazonSSMManagedInstanceCore policy grants the instance permission to communicate with Systems Manager, CloudWatch, and EC2 Messages services. This is the minimum required. The SSM Agent will automatically start on boot, register with Systems Manager, and appear in your Session Manager console within a minute or two.
Verify that your instances are online by checking the Fleet Manager in the AWS console or by querying with the AWS CLI: aws ssm describe-instance-information. Instances won't appear if the IAM role is missing or if network connectivity to the Systems Manager service endpoints is blocked (a common issue in restrictive VPCs).
IAM permissions for Session Manager
Once SSM Agent runs on your instances, you need to grant your developers and operators permission to start sessions. This is where IAM policies give you precise control over who can access which instances and what they can do inside the session.
The managed policy AmazonSSMFullAccess grants full Session Manager permissions, but in production you should scope access tightly. Here's a best-practice policy that allows a developer to start sessions only on instances tagged with Environment=dev:
resource "aws_iam_policy" "developer_session_access" {
name = "developer-session-manager-access"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "StartSessionOnDevInstances"
Effect = "Allow"
Action = [
"ssm:StartSession"
]
Resource = "arn:aws:ec2:*:*:instance/*"
Condition = {
StringEquals = {
"ssm:resourceTag/Environment" = "dev"
}
}
},
{
Sid = "TerminateOwnSessions"
Effect = "Allow"
Action = [
"ssm:TerminateSession"
]
Resource = "arn:aws:ssm:*:*:session/$${aws:username}-*"
},
{
Sid = "ListInstancesForSession"
Effect = "Allow"
Action = [
"ssm:DescribeInstanceInformation"
]
Resource = "*"
}
]
})
}
resource "aws_iam_group" "developers" {
name = "developers"
}
resource "aws_iam_group_policy_attachment" "developers_session_access" {
group = aws_iam_group.developers.name
policy_arn = aws_iam_policy.developer_session_access.arn
}Notice the resource-level condition on the StartSession action. This blocks developers from accessing production instances even if they try. The TerminateSession permission uses the $${aws:username} placeholder, so developers can only kill their own sessions, not others'. For production, you'd typically create a separate policy with Environment=prod and grant it only to your ops or platform team.
You should also set up Session Manager preferences to enforce additional guardrails like logging all terminal output to CloudWatch or S3, and enforcing KMS encryption of the session transcript. Add this to your Terraform:
resource "aws_ssm_document" "session_manager_prefs" {
name = "SSM_SessionManagerRunShell"
document_type = "Session"
document_format = "JSON"
content = jsonencode({
schemaVersion = "1.0"
description = "Session Manager preferences"
sessionType = "Standard_Stream"
inputs = {
s3BucketName = aws_s3_bucket.session_logs.id
s3KeyPrefix = "session-logs/"
s3EncryptionEnabled = true
cloudWatchLogGroupName = aws_cloudwatch_log_group.session_logs.name
cloudWatchEncryptionEnabled = true
kmsKeyId = aws_kms_key.session_logs.id
idleSessionTimeout = "20"
maxSessionDuration = "60"
runAsEnabled = false
runAsDefaultUser = ""
}
})
}
resource "aws_s3_bucket" "session_logs" {
bucket = "session-logs-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_versioning" "session_logs" {
bucket = aws_s3_bucket.session_logs.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_cloudwatch_log_group" "session_logs" {
name = "/aws/ssm/session-logs"
retention_in_days = 30
}Port forwarding to private databases
One of the most practical uses of Session Manager is secure access to databases running in private subnets. Instead of opening database ports to the world or routing through a bastion, you use Session Manager port forwarding to tunnel connections through the encrypted session.
Imagine you have a PostgreSQL database in a private subnet and need to run a migration or query from your laptop. With Session Manager, you start a session on an instance in the same VPC, then forward your local port 5432 to the database host:port through that session.
First, ensure your database security group allows inbound traffic from the application instance (or its security group) on the database port:
resource "aws_security_group" "postgres" {
name = "postgres-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app_server.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_db_instance" "postgres" {
allocated_storage = 20
storage_type = "gp3"
engine = "postgres"
engine_version = "15.3"
instance_class = "db.t4g.micro"
db_name = "appdb"
username = "postgres"
password = random_password.db_password.result
publicly_accessible = false
vpc_security_group_ids = [aws_security_group.postgres.id]
db_subnet_group_name = aws_db_subnet_group.private.name
}Now, from your local terminal, you can open a port-forwarded session:
aws ssm start-session \
--target i-0abc123def456 \
--document-name AWS-StartPortForwardingSession \
--parameters "localPortNumber=5432,portNumber=5432,host=postgres.c1234567890.us-east-1.rds.amazonaws.com"Once that command runs, your local port 5432 is connected to the database through the instance. Use your PostgreSQL client locally as if the database were on localhost, and all traffic flows through the encrypted session. When you're done, Ctrl+C to close the port forward.
You can automate this with a shell alias or Terraform output:
output "db_port_forward_command" {
value = "aws ssm start-session --target ${aws_instance.app_server.id} --document-name AWS-StartPortForwardingSession --parameters 'localPortNumber=5432,portNumber=5432,host=${aws_db_instance.postgres.address}'"
}Sessions logged for audits
Every Session Manager session you start is logged. Unlike SSH, where logs depend on shell history and syslog configuration, Session Manager captures session activity by default, sending terminal output to CloudWatch Logs and optionally to S3. This is invaluable for compliance and security investigations.
Session logs include a timestamp, the IAM principal who started the session, the instance ID, the session ID, and a complete transcript of all commands and output. S3 logging also creates audit records you can query with Athena for long-term retention and analysis.
To view logs in CloudWatch:
resource "aws_cloudwatch_log_group" "session_logs" {
name = "/aws/ssm/session-logs"
retention_in_days = 90
}
resource "aws_cloudwatch_log_stream" "session_logs" {
name = "session-logs-stream"
log_group_name = aws_cloudwatch_log_group.session_logs.name
}Then search CloudWatch Logs Insights with a query like:
fields @timestamp, @message, sessionId, principalId
| filter @message like /error|failed|delete|drop/i
| stats count() by principalIdFor S3 logs, you can query across sessions using Athena. The logs are JSON-formatted and partitioned by date, making it easy to investigate specific time windows or users. This level of auditability often satisfies SOC 2, ISO 27001, and PCI DSS requirements without additional tooling.
Common mistakes
- Forgetting the IAM role on instances. SSM Agent cannot register with Systems Manager without an IAM instance profile with
AmazonSSMManagedInstanceCore. Instances will appear offline in the console even though the agent is running. - Network issues in private VPCs. Instances must reach Systems Manager service endpoints. If your VPC has no NAT gateway or VPC endpoint for Systems Manager, ssm, ec2-messages, and ssmmessages, instances won't register. Add VPC endpoints or a NAT to fix this.
- Overly permissive IAM policies. Granting
AmazonSSMFullAccessto all developers defeats the security model. Use resource tags and conditions to scope access by environment and instance type. - Not enabling session logging. Without CloudWatch or S3 logging configured, you lose the audit trail. Always enable logging in your Session Manager preferences document.
- Port forwarding to wrong port. When using port forwarding, ensure the local port, remote port, and database host all match your configuration. Typos are common and result in connection refused errors.
Key takeaways
- Session Manager eliminates the need for bastion hosts, SSH keys, and open SSH ports by using outbound HTTPS connections from the instance to AWS Systems Manager.
- Every instance needs SSM Agent (pre-installed on most AMIs) and an IAM instance profile with
AmazonSSMManagedInstanceCoreto register and accept sessions. - Use resource-level IAM policies with tags to scope Session Manager access by environment, role, and team, ensuring developers can access only the instances they need.
- Port forwarding lets you securely access private databases and services from your local machine without exposing them to the internet.
- Session logging to CloudWatch and S3 provides compliance-grade audit trails of all terminal activity, including timestamps and IAM principals.
- Enforce session manager preferences (idle timeouts, max duration, KMS encryption) to add extra layers of security and governance.






Comments
Loading comments…