Skip to main content

Maintaining evergreen EC2 bastions with Pulumi

· 8 min read
Akhan Zhakiyanov
Lead engineer

Bastion hosts are disposable infrastructure, yet they are often set up with the same weight as long-lived servers. Hardcoded AMI IDs, SSH key pairs, x86 instances. A bastion should be cheaper, more secure, and self-updating. Three practices get you there: Graviton for cost, Session Manager for access, and SSM parameters for always launching the latest AMI. Like evergreen browsers that update silently in the background, an evergreen bastion always runs the current latest image without anyone touching the config.

1. Graviton (ARM64) for cost

A bastion does not need x86 compatibility. It is a jump box. AWS Graviton instances are cheaper than the equivalent x86 generation, and the t4g.nano at around $3/month is hard to beat for something that sits idle most of the time.

import * as aws from "@pulumi/aws";

const bastion = new aws.ec2.Instance("bastion", {
instanceType: "t4g.nano",
// ...
});

The tradeoff is minor. If you have tooling that only ships x86 binaries, you need to test it on ARM64 first. For SSH tunneling, kubectl proxy, and AWS CLI usage, ARM64 is a non-issue.

2. Session Manager instead of SSH

Opening port 22 to the internet is a security risk that bastions were designed to solve, but the bastion itself still exposes SSH. AWS Systems Manager Session Manager removes the need for SSH entirely.

The SSM agent is pre-installed on Amazon Linux 2023. Connect with:

aws ssm start-session --target i-0abc123def456

No key pair. No security group ingress rule. No public IP. The instance connects to SSM through a shared NAT Gateway in the private subnet. The IAM role on the instance grants ssm:StartSession and related permissions.

const bastionRole = new aws.iam.Role("bastion-role", {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({
Service: "ec2.amazonaws.com",
}),
});

new aws.iam.RolePolicyAttachment("bastion-ssm-core", {
role: bastionRole.name,
policyArn: "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
});

const bastionProfile = new aws.iam.InstanceProfile("bastion-profile", {
role: bastionRole,
});

If you need port forwarding, Session Manager supports that too:

aws ssm start-session --target i-0abc123def456 \
--document-name AWS-StartPortForwardingSession \
--parameters '{"portNumber":["5432"], "localPortNumber":["5432"]}'

3. Dynamic latest AMI via SSM parameters

For a long time I used aws.ec2.getAmiOutput in Pulumi to resolve the latest Amazon Linux AMI at deploy time. It worked, but the name filter was always a guess.

// ❌ Fragile — breaks when kernel version changes
const ami = aws.ec2.getAmiOutput({
filters: [
{ name: "name", values: ["al2023-ami-2023.*-kernel-6.1-x86_64"] },
],
owners: ["amazon"],
mostRecent: true,
});

When Amazon Linux 2023 moved from kernel 6.1 to 6.12, the kernel-6.1 filter kept returning results — just the old ones. With mostRecent: true, it picks the last 6.1-based AMI ever published. There is no warning. Your infrastructure silently deploys a stale AMI.

SSM parameters solve this

AWS maintains public SSM parameters under /aws/service/ami-amazon-linux-latest/ that always resolve to the current latest AMI for a given OS and architecture in the region where you query them. A single GetParameter call returns the AMI ID. No filters, no guessing.

const bastionAmi = aws.ssm.getParameterOutput({
name: "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64",
});

Use getParameterOutput (not getParameter) so the AMI value is a Pulumi Output<string> and can be passed directly into resource arguments. This also preserves dependency tracking when lookup inputs depend on other Pulumi values.

For AWS-managed AMIs, the same parameter path works across supported regions and resolves to the region-specific AMI ID. AWS updates the parameter value when a new AMI is published, so the next pulumi up always picks up the latest image without code changes.

warning

Pulumi AWS v7+ enforces that getAmi must include owners or a filter matching by image-id/owner-id when mostRecent is true. If you are still using getAmi, make sure your filters are explicit.

Putting it all together

A bastion that combines all three practices:

import * as aws from "@pulumi/aws";

// 1. Dynamic latest ARM64 AMI via SSM parameter
const bastionAmi = aws.ssm.getParameterOutput({
name: "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64",
});

// 2. IAM role for Session Manager (no SSH key pair needed)
const bastionRole = new aws.iam.Role("bastion-role", {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({
Service: "ec2.amazonaws.com",
}),
});

new aws.iam.RolePolicyAttachment("bastion-ssm-core", {
role: bastionRole.name,
policyArn: "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
});

const bastionProfile = new aws.iam.InstanceProfile("bastion-profile", {
role: bastionRole,
});

// 3. Security group — no ingress rules, outbound HTTPS only
const bastionSg = new aws.ec2.SecurityGroup("bastion-sg", {
vpcId: vpc.id,
egress: [{
cidrBlocks: ["0.0.0.0/0"],
protocol: "tcp",
fromPort: 443,
toPort: 443,
}],
});

// 4. The instance — private subnet, NAT Gateway for outbound, Session Manager access
const bastion = new aws.ec2.Instance("bastion", {
ami: bastionAmi.value,
instanceType: "t4g.nano",
subnetId: privateSubnet.id,
iamInstanceProfile: bastionProfile.name,
vpcSecurityGroupIds: [bastionSg.id],
rootBlockDevice: {
volumeType: "gp3",
encrypted: true,
},
metadataOptions: {
httpTokens: "required", // IMDSv2 only — blocks SSRF credential theft
httpEndpoint: "enabled",
httpPutResponseHopLimit: 1,
},
tags: { Name: "bastion" },
});

No key pair. No port 22. No public IP. The bastion sits in a private subnet with outbound access through a shared NAT Gateway. Every pulumi up launches with the latest patched Amazon Linux 2023 ARM64 image. If AWS publishes a new AMI overnight, the next deploy picks it up automatically.

Multi-region bastions with Pulumi v7

Pulumi AWS provider v7 introduced Enhanced Region Support — a top-level region argument on most resources and data sources. You no longer need to create a separate aws.Provider for each region. One provider, per-resource region overrides.

This is where SSM parameters for AMI lookup really shine. The same parameter path resolves to a different AMI ID in each region automatically. A single loop deploys evergreen bastions everywhere.

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

const provider = new aws.Provider("aws", { region: "us-east-1" });

const regions = ["us-east-1", "eu-west-1", "ap-southeast-1"];

const bastionPrivateIps: Record<string, pulumi.Output<string>> = {};

for (const region of regions) {
// SSM parameter — same path, resolves to the correct AMI per region
const ami = aws.ssm.getParameterOutput({
name: "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64",
region, // v7: region on data source
});

const bastion = new aws.ec2.Instance(`bastion-${region}`, {
region, // v7: region on resource
ami: ami.value,
instanceType: "t4g.nano",
subnetId: privateSubnet.id,
iamInstanceProfile: bastionProfile.name,
vpcSecurityGroupIds: [bastionSg.id],
tags: { Name: `bastion-${region}` },
}, { provider });

bastionPrivateIps[region] = bastion.privateIp;
}

export { bastionPrivateIps };

Three regions, one loop, zero hardcoded AMI IDs. Each region resolves its own latest AMI from the same SSM parameter path. When AWS patches Amazon Linux, all three bastions pick up the new image on the next deploy.

note

The region argument is optional and defaults to the provider's configured region. Changing its value forces resource replacement. If your regions use different AWS accounts or credentials, the classic multi-provider pattern (one aws.Provider per region) is still the way to go. Enhanced Region Support is for same-account, multi-region deployments.

SSM parameter paths cheat sheet

OS / Use caseArchitectureSSM Parameter Path
Amazon Linux 2023x86_64/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
Amazon Linux 2023ARM64/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64
Amazon Linux 2x86_64/aws/service/ami-amazon-linux-latest/amzn2-ami-kernel-default-hvm-x86_64-gp2
Amazon Linux 2ARM64/aws/service/ami-amazon-linux-latest/amzn2-ami-kernel-5.10-hvm-arm64-gp2
ECS optimized (AL2023)x86_64/aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id
ECS optimized (AL2023)ARM64/aws/service/ecs/optimized-ami/amazon-linux-2023/arm64/recommended/image_id
EKS optimized (AL2023)x86_64/aws/service/eks/optimized-ami/<k8s-version>/amazon-linux-2023/x86_64/standard/recommended/image_id
EKS optimized (AL2023)ARM64/aws/service/eks/optimized-ami/<k8s-version>/amazon-linux-2023/arm64/standard/recommended/image_id
Ubuntu 24.04x86_64/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id
Ubuntu 24.04ARM64/aws/service/canonical/ubuntu/server/24.04/stable/current/arm64/hvm/ebs-gp3/ami-id
Windows Server 2022/aws/service/ami-windows-latest/Windows_Server-2022-English-Full-Base
tip

List all available AMI SSM parameters in your current region:

aws ssm get-parameters-by-path \
--path /aws/service/ami-amazon-linux-latest \
--query "Parameters[].Name"

Gotchas

AMI IDs are region-specific. The same SSM parameter path resolves to a different AMI ID in each region. This is a feature, not a bug. Your IaC tool handles it automatically when deployed per-region.

kernel-default vs pinned kernel. The kernel-default path always tracks the latest kernel AWS ships. Pinned paths like kernel-6.1 stop updating when AWS moves to a newer default kernel. Use kernel-default unless you have a reason to pin.

SSM parameters are free. These are AWS-managed public parameters published under the /aws/service/ namespace. They don't count against your account's SSM parameter quota, and GetParameter calls against them don't incur charges.

Summary

Three practices make a bastion evergreen:

  • Graviton (ARM64). Use t4g.nano or t4g.micro. A bastion does not need x86, and Graviton is cheaper.
  • Session Manager. No SSH key pair, no open port 22, no public IP. Connect through the SSM agent pre-installed on Amazon Linux 2023 via a shared NAT Gateway.
  • Dynamic AMI via SSM parameters. Use aws.ssm.getParameterOutput with /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64. Every deploy picks up the latest patched image automatically. No filter maintenance, no stale AMIs.

And a few more hardening defaults worth adding:

  • IMDSv2 enforcement. Set httpTokens: "required" to block SSRF-based credential theft.
  • Encrypted gp3 root volume. Cheaper and faster than gp2, encrypted at rest.
  • Minimal egress. Only outbound TCP 443 — the bastion has no business talking to anything else.