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

  1. Access Control

    • Attach AmazonSSMManagedInstanceCore and 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
  2. 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
  3. Patching

    • Use tag-based patch groups so newly launched instances are covered automatically
    • Set approve_after_days on 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