Managing AWS Systems Manager with Terraform
Learn how to set up AWS Systems Manager with Terraform, including Session Manager for bastion-less EC2 access, Parameter Store for configuration, and Patch Manager for automated patching
AWS Systems Manager (SSM) is the operational backbone for managing EC2 fleets without exposing SSH or RDP: Session Manager gives you a shell into an instance with no open inbound ports and a full audit trail, Parameter Store centralizes configuration and secrets, and Patch Manager automates OS patching. This guide covers all three with Terraform.
Prerequisites
- AWS CLI configured with appropriate permissions
- Terraform installed (version 1.0.0 or later)
- An existing VPC (with or without internet access - both are covered below)
Project Structure
aws-systems-manager-terraform/
├── main.tf
├── variables.tf
├── outputs.tf
└── terraform.tfvars
Session Manager: Bastion-Less EC2 Access
Session Manager works by having the SSM Agent on the instance poll outbound to the SSM service - no inbound security group rule, bastion host, or SSH key is required. The agent ships preinstalled on Amazon Linux 2/2023, Ubuntu’s official AMIs, and Windows Server AMIs.
# main.tf
provider "aws" {
region = var.aws_region
}
# IAM role that lets the SSM Agent register the instance and lets Session
# Manager connect to it
resource "aws_iam_role" "ssm_instance" {
name = "${var.project_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_core" {
role = aws_iam_role.ssm_instance.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ssm_instance" {
name = "${var.project_name}-ssm-instance-profile"
role = aws_iam_role.ssm_instance.name
}
# Security group with NO inbound rules at all - Session Manager doesn't
# need one, since the agent only makes outbound connections
resource "aws_security_group" "ssm_managed" {
name = "${var.project_name}-ssm-managed"
description = "Instances managed via Session Manager - no inbound rules needed"
vpc_id = var.vpc_id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-ssm-managed"
}
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "managed" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = var.subnet_id
iam_instance_profile = aws_iam_instance_profile.ssm_instance.name
vpc_security_group_ids = [aws_security_group.ssm_managed.id]
tags = {
Name = "${var.project_name}-managed-instance"
}
}
Connect with:
aws ssm start-session --target $(terraform output -raw instance_id)
Private Access via VPC Endpoints
If the instance’s subnet has no NAT Gateway or Internet Gateway, the SSM Agent can’t reach the SSM service over the public internet. Add these three Interface endpoints so it can reach it privately instead:
resource "aws_security_group" "vpc_endpoints" {
name = "${var.project_name}-vpce-sg"
description = "Allow HTTPS from the VPC to SSM VPC endpoints"
vpc_id = var.vpc_id
ingress {
description = "HTTPS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.vpc_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_vpc_endpoint" "ssm" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.ssm"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
resource "aws_vpc_endpoint" "ssmmessages" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.ssmmessages"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
# Required for Session Manager specifically (not just agent registration)
resource "aws_vpc_endpoint" "ec2messages" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.ec2messages"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
Session Logging
For audit and compliance, log every session to S3 and/or CloudWatch Logs by configuring the special SSM-SessionManagerRunShell document:
resource "aws_cloudwatch_log_group" "sessions" {
name = "/aws/ssm/${var.project_name}-sessions"
retention_in_days = 90
}
resource "aws_s3_bucket" "session_logs" {
bucket = "${var.project_name}-ssm-session-logs"
}
resource "aws_s3_bucket_public_access_block" "session_logs" {
bucket = aws_s3_bucket.session_logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
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 = "sessions/"
s3EncryptionEnabled = true
cloudWatchLogGroupName = aws_cloudwatch_log_group.sessions.name
cloudWatchEncryptionEnabled = false
cloudWatchStreamingEnabled = true
}
})
}
The instance role’s AmazonSSMManagedInstanceCore policy already grants the permissions needed to write to CloudWatch Logs; if you enable the S3 destination, also attach an inline policy granting s3:PutObject on the log bucket.
Parameter Store
Parameter Store centralizes configuration values and, via SecureString, encrypted secrets - a lighter-weight alternative to Secrets Manager when you don’t need automatic rotation.
resource "aws_ssm_parameter" "app_config" {
name = "/${var.project_name}/${var.environment}/log-level"
type = "String"
value = "INFO"
}
resource "aws_kms_key" "parameters" {
description = "KMS key for SecureString parameters"
deletion_window_in_days = 7
enable_key_rotation = true
}
resource "aws_ssm_parameter" "db_password" {
name = "/${var.project_name}/${var.environment}/db-password"
type = "SecureString"
key_id = aws_kms_key.parameters.arn
value = var.db_password
tags = {
Environment = var.environment
}
}
Read it back in another resource with the aws_ssm_parameter data source, or at runtime via aws ssm get-parameter --name /project/env/db-password --with-decryption.
Patch Manager
resource "aws_ssm_patch_baseline" "linux" {
name = "${var.project_name}-linux-baseline"
description = "Baseline for Amazon Linux instances"
operating_system = "AMAZON_LINUX_2023"
approval_rule {
approve_after_days = 7
patch_filter {
key = "CLASSIFICATION"
values = ["Security"]
}
patch_filter {
key = "SEVERITY"
values = ["Critical", "Important"]
}
}
}
resource "aws_ssm_patch_group" "linux" {
baseline_id = aws_ssm_patch_baseline.linux.id
patch_group = "${var.project_name}-linux"
}
resource "aws_ssm_maintenance_window" "patching" {
name = "${var.project_name}-patch-window"
schedule = "cron(0 2 ? * SUN *)"
duration = 3
cutoff = 1
}
resource "aws_ssm_maintenance_window_target" "linux" {
window_id = aws_ssm_maintenance_window.patching.id
resource_type = "INSTANCE"
targets {
key = "tag:PatchGroup"
values = ["${var.project_name}-linux"]
}
}
resource "aws_ssm_maintenance_window_task" "patch" {
window_id = aws_ssm_maintenance_window.patching.id
task_type = "RUN_COMMAND"
task_arn = "AWS-RunPatchBaseline"
priority = 1
service_role_arn = aws_iam_role.maintenance_window.arn
max_concurrency = "1"
max_errors = "1"
targets {
key = "WindowTargetIds"
values = [aws_ssm_maintenance_window_target.linux.id]
}
task_invocation_parameters {
run_command_parameters {
parameter {
name = "Operation"
values = ["Install"]
}
}
}
}
resource "aws_iam_role" "maintenance_window" {
name = "${var.project_name}-maintenance-window-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ssm.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "maintenance_window" {
role = aws_iam_role.maintenance_window.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonSSMMaintenanceWindowRole"
}
Instances need the PatchGroup tag to match, and tagging drives both the maintenance window target above and the patch baseline association:
resource "aws_instance" "patched" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = var.subnet_id
iam_instance_profile = aws_iam_instance_profile.ssm_instance.name
vpc_security_group_ids = [aws_security_group.ssm_managed.id]
tags = {
Name = "${var.project_name}-patched-instance"
PatchGroup = "${var.project_name}-linux"
}
}
Variables Configuration
variable "aws_region" {
description = "AWS region"
type = string
default = "us-west-2"
}
variable "project_name" {
description = "Project name"
type = string
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
variable "vpc_id" {
description = "VPC ID"
type = string
}
variable "vpc_cidr" {
description = "CIDR block of the VPC, for the VPC endpoint security group"
type = string
}
variable "subnet_id" {
description = "Subnet ID for the example instance"
type = string
}
variable "private_subnet_ids" {
description = "Private subnet IDs for the VPC endpoints"
type = list(string)
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "db_password" {
description = "Database password to store as a SecureString parameter"
type = string
sensitive = true
}
Best Practices
-
Access Control
- Attach
AmazonSSMManagedInstanceCoreand nothing broader to the instance role - Scope who can start sessions with IAM policies on
ssm:StartSession, restricted by resource tag - Enable session logging for every environment that isn’t purely ephemeral/local
- Attach
-
Network Design
- Use the three VPC endpoints for fully private subnets rather than adding a NAT Gateway just for SSM traffic
- Keep the managed-instance security group free of inbound rules - if you find yourself adding one for “just this once,” that’s a sign something else is misconfigured
-
Patching
- Use tag-based patch groups so newly launched instances are covered automatically
- Set
approve_after_dayson the patch baseline so patches get a short soak period before critical instances install them
Conclusion
Systems Manager replaces three separate operational headaches - bastion hosts, ad hoc config files, and manual patching - with one IAM-controlled, audited service. Session Manager in particular is worth adopting even if you change nothing else: it removes SSH key management and open inbound ports from your threat model entirely.
Remember to:
- Keep the SSM Agent up to date (it auto-updates via SSM itself on supported AMIs)
- Review session logs as part of your access audit process
- Test patch baselines in a non-production patch group before applying broadly