Provision a Production AWS Stack with Pulumi and TypeScript
Deploy a VPC, ECS Fargate service, and RDS PostgreSQL instance on AWS using Pulumi and TypeScript, with networking and IAM handled by AWSX 2.x components.
What you'll build
A deployable AWS stack: a VPC with public and private subnets across two AZs, an Application Load Balancer forwarding traffic to ECS Fargate tasks, and a PostgreSQL RDS instance locked inside private subnets. No ClickOps, no CloudFormation YAML, one command to deploy.
Prerequisites
- Node.js 18+ and npm
- Pulumi CLI 3.x —
brew install pulumion macOS, or follow the official install guide - AWS CLI configured with credentials that can manage EC2, ECS, ELB, RDS, and IAM. Verify with
aws sts get-caller-identity. AWS_DEFAULT_REGIONset, or a default region in your AWS config
This tutorial targets @pulumi/awsx 2.x, which has breaking API changes from 1.x. If you've used AWSX before, the VPC and ALB interfaces are different.
1. Scaffold the project
mkdir fargate-stack && cd fargate-stack
pulumi new aws-typescript
Accept defaults for project name and stack name (dev). Enter your target region when prompted. Pulumi writes index.ts, package.json, Pulumi.yaml, tsconfig.json, and runs npm install.
2. Install additional packages
npm install @pulumi/awsx@^2.0.0
@pulumi/aws and @pulumi/pulumi are already in package.json from scaffolding. AWSX 2.x provides higher-level constructs that handle subnet routing tables, internet gateways, NAT gateways, and ALB target group wiring automatically.
3. Store the database password as a secret
pulumi config set --secret dbPassword $(openssl rand -base64 16 | tr -d '=+/')
Pulumi encrypts this value using the stack's encryption key. It never appears in plaintext in Pulumi.yaml or state.
4. Write the infrastructure
Replace index.ts entirely:
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
const config = new pulumi.Config();
const dbPassword = config.requireSecret("dbPassword");
const appImage = config.get("appImage") ?? "nginx:latest";
// --- Networking ---
// Single NAT Gateway halves hourly cost during dev.
// Switch to NatGatewayStrategy.OnePerAz for production.
const vpc = new awsx.ec2.Vpc("vpc", {
numberOfAvailabilityZones: 2,
natGateways: { strategy: awsx.ec2.NatGatewayStrategy.Single },
});
const lbSg = new aws.ec2.SecurityGroup("lb-sg", {
vpcId: vpc.vpcId,
ingress: [{ protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] }],
egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
});
// App tier only accepts traffic from the ALB.
const appSg = new aws.ec2.SecurityGroup("app-sg", {
vpcId: vpc.vpcId,
ingress: [{ protocol: "tcp", fromPort: 80, toPort: 80, securityGroups: [lbSg.id] }],
egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
});
// DB tier only accepts traffic from the app tier.
const dbSg = new aws.ec2.SecurityGroup("db-sg", {
vpcId: vpc.vpcId,
ingress: [{ protocol: "tcp", fromPort: 5432, toPort: 5432, securityGroups: [appSg.id] }],
egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
});
// --- Load Balancer ---
const lb = new awsx.lb.ApplicationLoadBalancer("lb", {
subnetIds: vpc.publicSubnetIds,
securityGroups: [lbSg.id],
});
// --- ECS ---
const cluster = new aws.ecs.Cluster("cluster", {
settings: [{ name: "containerInsights", value: "enabled" }],
});
const service = new awsx.ecs.FargateService("app", {
cluster: cluster.arn,
desiredCount: 2,
networkConfiguration: {
subnets: vpc.privateSubnetIds,
securityGroups: [appSg.id],
assignPublicIp: false,
},
taskDefinitionArgs: {
containers: {
app: {
image: appImage,
cpu: 256,
memory: 512,
essential: true,
portMappings: [{
containerPort: 80,
targetGroup: lb.defaultTargetGroup,
}],
},
},
},
});
// --- RDS ---
const dbSubnetGroup = new aws.rds.SubnetGroup("db-subnets", {
subnetIds: vpc.privateSubnetIds,
});
const db = new aws.rds.Instance("postgres", {
engine: "postgres",
engineVersion: "15.4",
instanceClass: "db.t3.micro",
allocatedStorage: 20,
dbName: "appdb",
username: "appuser",
password: dbPassword,
dbSubnetGroupName: dbSubnetGroup.name,
vpcSecurityGroupIds: [dbSg.id],
storageEncrypted: true,
multiAz: false, // set true for automatic failover
skipFinalSnapshot: true, // flip this off before going to production
deletionProtection: false,
});
// --- Outputs ---
export const lbUrl = pulumi.interpolate`http://${lb.loadBalancer.dnsName}`;
export const dbEndpoint = db.endpoint;
export const clusterName = cluster.name;
export const serviceName = service.service.name;
The targetGroup: lb.defaultTargetGroup line in the port mapping is an AWSX 2.x convenience. It wires the container port to the ALB's default target group and handles the ECS service loadBalancers block automatically — you don't write that boilerplate yourself.
Pulumi auto-names AWS resources with a random suffix (e.g., the ECS service becomes app-f3a2b1c4, not app), which is why we export serviceName directly from the resource rather than hardcoding a string.
For engineVersion, RDS minor versions rotate. If 15.4 isn't available in your region, check aws rds describe-db-engine-versions --engine postgres --query 'DBEngineVersions[*].EngineVersion' and substitute accordingly.
5. Deploy
pulumi up
Pulumi previews ~35-40 resources before asking for confirmation. Type yes. First deploy takes 8-12 minutes; the bottleneck is RDS initialization and NAT Gateway association. Subsequent deploys are fast for targeted changes.
To deploy a different container image without touching the code:
pulumi config set appImage myrepo/myapp:v2
pulumi up
Verify it works
pulumi stack output lbUrl
# http://your-alb-dns.us-east-1.elb.amazonaws.com
curl $(pulumi stack output lbUrl)
# nginx welcome page (or your app's response)
Confirm both Fargate tasks are running:
aws ecs describe-services \
--cluster $(pulumi stack output clusterName) \
--services $(pulumi stack output serviceName) \
--query "services[0].{desired:desiredCount,running:runningCount}"
Expected output: {"desired": 2, "running": 2}. Container Insights metrics also appear in the ECS console under the cluster within a minute or two.
Troubleshooting
Tasks stop with CannotPullContainerError
Fargate tasks in private subnets pull images through the NAT Gateway. If the NAT Gateway hasn't fully propagated routes yet, pulls fail. Wait 2-3 minutes and run pulumi up again — it's idempotent and the task definition is already registered.
RDS creation fails with InvalidVpcState
RDS requires subnets in at least two AZs. This error usually means one of the selected AZs doesn't support RDS in your region. Set numberOfAvailabilityZones: 3 to let AWS pick two supported ones, or try a different region.
pulumi up fails with access denied on IAM
The deployer needs iam:CreateRole and iam:AttachRolePolicy in addition to the service permissions. AWSX creates execution roles for ECS tasks automatically. For a CI role, scope it to ec2:*, ecs:*, elasticloadbalancing:*, rds:*, iam:CreateRole, iam:AttachRolePolicy, iam:PassRole, and logs:*.
Config error: dbPassword not set
You're on a stack that hasn't had the secret configured. Run pulumi stack select to confirm the active stack, then repeat step 3.
Next steps
- TLS: Add an
aws.lb.Listeneron port 443 with an ACM certificate and an HTTP-to-HTTPS redirect listener. - Secrets injection: Pass
dbPasswordto the container viaaws.secretsmanager.Secretand the ECS task definition'ssecretsfield rather than an environment variable. - Multi-stack separation: Split networking into its own Pulumi stack and reference it from the app stack via
pulumi.StackReference. The VPC then outlives individual service deployments. - Aurora Serverless v2: Replace
aws.rds.Instancewithaws.rds.Clusterandserverlessv2ScalingConfigurationfor autoscaling capacity with pay-per-ACU billing. - CI/CD: The official pulumi/actions GitHub Action runs
pulumi previewon PRs andpulumi upon merge to main.
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 0
No comments yet
Be the first to weigh in.