Skip to main content

Pulumi Ep 6: Loops and Conditional Infrastructure

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
pulumi - This article is part of a series.
Part 6: This Article
If you need to create 3 Subnets across 3 Availability Zones, do you copy and paste the aws.ec2.Subnet class three times? No. In Terraform, you would use HCL’s for_each meta-argument. In Pulumi, you simply write a standard JavaScript for loop. Let’s explore how to generate infrastructure programmatically.

1. Native Loops for Infrastructure
#

Let’s assume we need to deploy a 3-tier architecture. We need a web, app, and db subnet inside a VPC.

In Pulumi, we can define our architecture purely as standard JavaScript data structures (arrays and objects), and then iterate over them.

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

// 1. Create the VPC
const vpc = new aws.ec2.Vpc("main-vpc", {
    cidrBlock: "10.0.0.0/16",
});

// 2. Define our desired architecture as data
const subnetsConfig = [
    { name: "web", cidr: "10.0.1.0/24", az: "us-east-1a" },
    { name: "app", cidr: "10.0.2.0/24", az: "us-east-1b" },
    { name: "db",  cidr: "10.0.3.0/24", az: "us-east-1c" }
];

// 3. Iterate and create!
const subnets: aws.ec2.Subnet[] = [];

for (const config of subnetsConfig) {
    const subnet = new aws.ec2.Subnet(`${config.name}-subnet`, {
        vpcId: vpc.id,
        cidrBlock: config.cidr,
        availabilityZone: config.az,
        tags: {
            Name: `Pulumi-${config.name}-subnet`,
            Tier: config.name
        }
    });
    
    subnets.push(subnet);
}

The Power of Logical Naming in Loops
#

Notice this line: new aws.ec2.Subnet("${config.name}-subnet", ...)

Because the loop executes 3 times, we must ensure the Logical Name passed as the first argument is unique on every iteration (web-subnet, app-subnet, db-subnet).

If you hardcoded "my-subnet", Pulumi would try to register the exact same logical resource three times and crash with a Duplicate resource URN error.


2. Dynamic Array Properties (.map)
#

In AWS, a Security Group accepts an array of Ingress rules. What if we want to open a list of ports, but the list of ports changes based on the environment?

We can use the native JavaScript .map() function to generate the AWS Input objects dynamically.

const config = new pulumi.Config();
// Assume this config returns [80, 443, 8080]
const portsToOpen = config.requireObject<number[]>("openPorts");

const webSg = new aws.ec2.SecurityGroup("web-sg", {
    vpcId: vpc.id,
    
    // Dynamically map an array of numbers into an array of AWS Ingress Objects!
    ingress: portsToOpen.map(port => {
        return {
            protocol: "tcp",
            fromPort: port,
            toPort: port,
            cidrBlocks: ["0.0.0.0/0"],
        }
    }),
});

This is incredibly powerful. You no longer have to fight with HCL’s dynamic blocks and content iterators. It is just pure, native JavaScript array manipulation.


3. Conditional Infrastructure (If Statements)
#

Often, you want to deploy a specific resource only if you are in the Production environment. For example, maybe you only want to provision a highly-available Multi-AZ RDS cluster in Production, but use a cheap single-node EC2 database in Development.

In Terraform, you would do this with count = var.environment == "prod" ? 1 : 0. This is an ugly hack.

In Pulumi, you use a standard if statement.

const isProd = pulumi.getStack() === "prod";

let dbAddress: pulumi.Output<string>;

if (isProd) {
    // Deploy expensive multi-node Aurora Cluster
    const cluster = new aws.rds.Cluster("prod-db", {
        engine: "aurora-mysql",
        // ... heavy configuration
    });
    dbAddress = cluster.endpoint;
    
} else {
    // Deploy cheap single instance for Dev
    const db = new aws.ec2.Instance("dev-db", {
        instanceType: "t3.micro",
        // ... install mysql via user_data
    });
    dbAddress = db.privateIp;
}

// Regardless of the branch, we can pass dbAddress to our Web Server
const web = new aws.ec2.Instance("web-server", {
    // ...
    userData: pulumi.interpolate`#!/bin/bash
    echo "DB_URL=${dbAddress}" > /etc/config
    `
});

The Pulumi Engine handles this seamlessly. If you run this in the dev stack, the engine never even sees an intent to create an aws.rds.Cluster.


4. Reading Existing Infrastructure (Data Sources)
#

What if you need to deploy an EC2 instance, but the VPC wasn’t created by this Pulumi project? Perhaps it was created years ago by a Networking team using Terraform, and you just need to reference its ID.

In Terraform, this is a data block. In Pulumi, it is a .get function on the specific resource class.

// 1. Fetch the default VPC created by AWS
const defaultVpc = aws.ec2.getVpc({ default: true });

// 2. Fetch a specific Subnet by its Tags
const appSubnet = aws.ec2.getSubnet({
    filters: [{
        name: "tag:Tier",
        values: ["app"]
    }]
});

// 3. Deploy our instance into the existing subnet!
const instance = new aws.ec2.Instance("legacy-app", {
    ami: "ami-0fc5d935ebf8bc3bc",
    instanceType: "t3.micro",
    // Note: Data source functions return Promises!
    subnetId: appSubnet.then(subnet => subnet.id),
});

Notice that .get() functions query the live AWS API during the execution of index.ts. They return standard JavaScript Promises, not Outputs (because the data actually exists right now). You can resolve them using .then() or await (if you are inside an async function).


Troubleshooting & Common Errors
#

  1. Duplicate resource URN

    • Root Cause: You created a for loop to generate resources, but you hardcoded the Logical Name (the first string argument of the resource class). Pulumi tried to register two resources with the exact same name.
    • Solution: Always inject the loop index or a unique identifier into the Logical Name (e.g., new aws.ec2.Subnet("subnet-${i}", ...)).
  2. await expression is only allowed within an async function

    • Root Cause: You tried to use const vpc = await aws.ec2.getVpc(...) at the top level of your index.ts file, but your tsconfig.json is configured for an older version of Node.js that does not support Top-Level Await.
    • Solution: Either use .then(), wrap your code in an async IIFE, or update your tsconfig.json to "target": "es2022".

Conclusion & Next Steps
#

By leveraging native TypeScript control flow (Loops, Arrays, Conditionals), you have eliminated the verbose hacks required by legacy IaC tools. Your infrastructure code is now drastically shorter, cleaner, and strictly typed.

However, writing new aws.ec2.Vpc and new aws.ec2.Subnet directly in index.ts works for small projects, but it does not scale for large enterprise architectures.

What if you want to bundle a VPC, 3 Subnets, a NAT Gateway, and an Internet Gateway into a single, reusable class called SecureNetwork that your entire company can install via NPM?

In Episode 7: Abstraction with ComponentResources, we will learn how to author custom Pulumi components to build enterprise-grade abstractions.

pulumi - This article is part of a series.
Part 6: This Article