Skip to content
Cloud & Infra Advanced Tutorial

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.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Jul 2, 2026 · 7 min read
Provision a Production AWS Stack with Pulumi and TypeScript

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.xbrew install pulumi on 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_REGION set, 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.Listener on port 443 with an ACM certificate and an HTTP-to-HTTPS redirect listener.
  • Secrets injection: Pass dbPassword to the container via aws.secretsmanager.Secret and the ECS task definition's secrets field 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.Instance with aws.rds.Cluster and serverlessv2ScalingConfiguration for autoscaling capacity with pay-per-ACU billing.
  • CI/CD: The official pulumi/actions GitHub Action runs pulumi preview on PRs and pulumi up on merge to main.
Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading