VPC Endpoints for SSM without Internet Access

AWS SSM Web (Services System Manager), is a way to manage your EC2 instances via the SSM tool, this provides a number of different features, the one I commonly use is SSM for reaching the console of an EC2 instance; without needing SSH. With SSM you can get to the console and a command line, all without having to provide Internet access to the EC2 instance, and that is what this article is about.

SSM requires that the EC2 instance (assuming it has the SSM Agent installed) to be able to reach the SSM endpoints to register and therefore be remotely controlled (if you are using the console access). If your EC2 instance can reach the Internet this is no problem, but what if your EC2 instance is on a private subnet with no Internet access, what then?

Well, you can use a VPC Endpoint and publish the various needed endpoints directly within the VPC (and any subnets), what this means is that when the EC2 instance’s SSM Agent attempts to register, it will resolve the endpoint names but instead of attempting to reach these via the Internet, it will “short-circuit” its way directly from the VPC to the endpoint. Thus allowing it to work without Internet access.

Here’s the Terraform for how to enable the VPC Endpoint for SSM:

locals {
  example_services2 = {
    "ec2messages" : {
      "name" : "com.amazonaws.${var.region}.ec2messages"
    },
    "ssm" : {
      "name" : "com.amazonaws.${var.region}.ssm"
    },
    "ssmmessages" : {
      "name" : "com.amazonaws.${var.region}.ssmmessages"
    }
  }
}

resource "aws_vpc_endpoint" "example_ssm_endpoint2" {
  for_each            = local.example_services2
  vpc_id              = aws_vpc.test_workload_2.id
  service_name        = each.value.name
  vpc_endpoint_type   = "Interface"
  security_group_ids  = [aws_security_group.sg_test_workload_2-Instance-1.id]
  private_dns_enabled = true
  ip_address_type     = "ipv4"
  subnet_ids          = [aws_subnet.test_workload_2-Private-Subnet-A.id, aws_subnet.test_workload_2-Private-Subnet-B.id]
}

You’ll notice in that you need to create a Security Group to govern the traffic that can reach the SSM endpoints and also specify which subnets the VPC Endpoints will be published into.

For reference, here is a more complete example:

// Security Group ---------------------------------------------------------------------------------------------------------------------------

resource "aws_security_group" "sg_test_workload_2-Instance-1" {
  name   = "sg_test_workload_2-Instance-1"
  vpc_id = aws_vpc.test_workload_2.id

  ingress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }
}

// EC2 Instances ----------------------------------------------------------------------------------------------------------------------------

# User data: install nginx, make self-signed cert, HTTPS server block + redirect
locals {
  example_user_data2 = <<-EOF
    #!/bin/bash
    set -eux
    dnf -y install httpd
    cat >/var/www/html/index.html <<'HTML'
    <!doctype html>
    <html><head><meta charset="utf-8"><title>Hello from EC2</title></head>
    <body style="font-family: system-ui; margin: 3rem">
      <h1>It works 🎉</h1>
      <p>Served by EC2 directly with nothing else!</p>
    </body></html>
    HTML
    systemctl enable --now httpd
  EOF
}

// EC2 Instance 1

resource "aws_instance" "test_workload_2-Instance-1" {
  ami                    = "ami-008ea0202116dbc56"
  instance_type          = "t2.micro"
  tenancy                = "default"
  availability_zone      = "eu-west-2a"
  key_name               = ""
  subnet_id              = aws_subnet.test_workload_2-Private-Subnet-A.id
  vpc_security_group_ids = [aws_security_group.sg_test_workload_2-Instance-1.id]
  iam_instance_profile   = aws_iam_instance_profile.ec2_profile2.name
  user_data              = local.example_user_data2

  tags = {
    Name = "test_workload_2-Instance-1"
  }
}

// EC2 IAM Role -----------------------------------------------------------------------------------------------------------------------------------------

resource "aws_iam_role" "ec2_role2" {
  name = "ec2-ssm-role2"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = ""
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      },
    ]
  })
}
resource "aws_iam_role_policy_attachment" "custom2" {
  role       = aws_iam_role.ec2_role2.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ec2_profile2" {
  name = "ec2-ssm-profile2"
  role = aws_iam_role.ec2_role2.name
}

# # // VPC Endpoint (for SSM) -------------------------------------------------------------------------------------------------------

locals {
  example_services2 = {
    "ec2messages" : {
      "name" : "com.amazonaws.${var.region}.ec2messages"
    },
    "ssm" : {
      "name" : "com.amazonaws.${var.region}.ssm"
    },
    "ssmmessages" : {
      "name" : "com.amazonaws.${var.region}.ssmmessages"
    }
  }
}

resource "aws_vpc_endpoint" "example_ssm_endpoint2" {
  for_each            = local.example_services2
  vpc_id              = aws_vpc.test_workload_2.id
  service_name        = each.value.name
  vpc_endpoint_type   = "Interface"
  security_group_ids  = [aws_security_group.sg_test_workload_2-Instance-1.id]
  private_dns_enabled = true
  ip_address_type     = "ipv4"
  subnet_ids          = [aws_subnet.test_workload_2-Private-Subnet-A.id, aws_subnet.test_workload_2-Private-Subnet-B.id]
}

Leave a comment