Managing AWS EC2 Instances with Pulumi
Learn how to launch EC2 instances with Pulumi and TypeScript, including AMI lookups, an instance profile, and user data
This guide covers launching an EC2 instance with Pulumi - looking up the latest AMI dynamically, attaching an IAM instance profile, and passing user data - building on the VPC from the Pulumi VPC guide.
Prerequisites
- A VPC with at least one public subnet and a security group (see the Pulumi VPC guide)
Looking Up an AMI
Hardcoding an AMI ID bakes in a value that’s specific to one region and goes stale as AWS publishes new images. Pulumi’s data sources (“invokes”) have an Output-returning variant that lets you use the result directly in other resources without leaving the world of Output<T>:
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const ami = aws.ec2.getAmiOutput({
mostRecent: true,
owners: ["amazon"],
filters: [{
name: "name",
values: ["al2023-ami-*-x86_64"],
}],
});
getAmiOutput (as opposed to the plain getAmi, which returns a Promise) is the pattern to reach for whenever a data source’s filter arguments might depend on another resource’s Output - it composes with .apply()/pulumi.interpolate the same way resource properties do.
IAM Instance Profile
const instanceRole = new aws.iam.Role("web-instance-role", {
assumeRolePolicy: JSON.stringify({
Version: "2012-10-17",
Statement: [{
Action: "sts:AssumeRole",
Effect: "Allow",
Principal: { Service: "ec2.amazonaws.com" },
}],
}),
});
new aws.iam.RolePolicyAttachment("ssm-core", {
role: instanceRole.name,
policyArn: "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
});
const instanceProfile = new aws.iam.InstanceProfile("web-instance-profile", {
role: instanceRole.name,
});
Attaching AmazonSSMManagedInstanceCore enables Session Manager, so this instance can be reached without SSH keys, a bastion host, or any inbound security group rule - see the Systems Manager Terraform guide on this site for the full pattern (the same one applies here, just via Pulumi resources instead of resource blocks).
The Instance
const userData = `#!/bin/bash
dnf install -y httpd
systemctl enable --now httpd
echo "Hello from Pulumi" > /var/www/html/index.html
`;
const webInstance = new aws.ec2.Instance("web", {
ami: ami.id,
instanceType: "t3.micro",
subnetId: publicSubnets[0].id,
vpcSecurityGroupIds: [appSecurityGroup.id],
iamInstanceProfile: instanceProfile.name,
userData: userData,
tags: { Name: "web-instance" },
});
export const instanceId = webInstance.id;
export const publicIp = webInstance.publicIp;
Auto Scaling Group
For anything beyond a single instance, wrap the same AMI/instance-type/security-group configuration in a Launch Template and an Auto Scaling Group rather than creating aws.ec2.Instance resources in a loop:
const launchTemplate = new aws.ec2.LaunchTemplate("web", {
imageId: ami.id,
instanceType: "t3.micro",
vpcSecurityGroupIds: [appSecurityGroup.id],
iamInstanceProfile: { name: instanceProfile.name },
userData: Buffer.from(userData).toString("base64"),
});
const asg = new aws.autoscaling.Group("web", {
vpcZoneIdentifiers: publicSubnets.map(s => s.id),
minSize: 2,
maxSize: 6,
desiredCapacity: 2,
launchTemplate: {
id: launchTemplate.id,
version: "$Latest",
},
tags: [{
key: "Name",
value: "web-asg",
propagateAtLaunch: true,
}],
});
LaunchTemplate.userData must be base64-encoded (unlike Instance.userData, which Pulumi encodes for you) - this mirrors the same requirement in the underlying Terraform resource this is bridged from.
Best Practices
- Never hardcode AMI IDs - use
getAmiOutput(or a fixed, pinned Golden AMI ID your pipeline publishes) so instances always launch with a current, patched image. - Prefer Session Manager over SSH - attach
AmazonSSMManagedInstanceCoreand skip key-pair management and inbound port 22 entirely. - Use an Auto Scaling Group for anything customer-facing - a standalone
aws.ec2.Instancehas no self-healing if the instance or its AZ fails.
Conclusion
Pulumi’s aws.ec2.Instance, LaunchTemplate, and autoscaling.Group map argument-for-argument onto the same Terraform resources covered elsewhere on this site - the difference is entirely in how you get the AMI ID and user data into those arguments, where TypeScript’s async/await and template literals replace Terraform’s data blocks and heredocs.
For more Pulumi topics, check out: