Skip to main content

Pulumi Ep 3: Declaring Resources in TypeScript

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 3: This Article
The true power of Pulumi lies in its strong typing. Because we are using TypeScript, every AWS resource is a Class, and every configuration parameter is strictly defined by an Interface. If you pass an integer to a property that expects a string, your IDE will flag it with a red underline before you even run the CLI.

1. The Anatomy of a Pulumi Resource
#

In Pulumi, every physical piece of cloud infrastructure is represented by a TypeScript Class.

To create a resource, you instantiate the class using the new keyword. Every resource class follows the exact same architectural signature:

new provider.namespace.ResourceClass("logical-name", { args }, { options });
  1. "logical-name": The string identifier Pulumi uses to track the resource in the state file. It must be unique across all resources of the same type in your project.
  2. { args }: An object matching a specific TypeScript Interface that contains the configuration for the resource (e.g., instanceType, ami).
  3. { options }: (Optional) A CustomResourceOptions object used to configure Pulumi engine behavior, such as preventing destruction or specifying explicit dependencies.

2. Practice: Building an AWS VPC
#

Let’s build a small network architecture. We will create a Virtual Private Cloud (VPC), a Subnet, and a Security Group.

Open index.ts and clear out the default S3 bucket code.

Step 2.1: The VPC and Subnet
#

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",
    enableDnsHostnames: true,
    tags: {
        Name: "Pulumi-Main-VPC",
    },
});

// 2. Create the Subnet
const subnet = new aws.ec2.Subnet("public-subnet", {
    // Notice how we pass the VPC ID!
    vpcId: vpc.id,
    cidrBlock: "10.0.1.0/24",
    mapPublicIpOnLaunch: true,
    tags: {
        Name: "Pulumi-Public-Subnet",
    },
});

The Magic of Implicit Dependencies
#

In the Subnet arguments, look closely at this line: vpcId: vpc.id.

We are passing a property from the vpc object directly into the subnet object. When Pulumi executes this code, it builds a Dependency Graph. It sees that the Subnet relies on the VPC’s ID. Therefore, Pulumi knows it MUST wait for AWS to finish creating the VPC before it even attempts to create the Subnet.

You do not need to write dependsOn manually; Pulumi infers it from the variable references!

Step 2.2: The Security Group
#

Next, let’s create a Security Group to allow SSH (Port 22) and HTTP (Port 80) traffic.

// 3. Create a Security Group
const webSecurityGroup = new aws.ec2.SecurityGroup("web-sg", {
    vpcId: vpc.id,
    description: "Allow SSH and HTTP",
    ingress: [
        {
            protocol: "tcp",
            fromPort: 22,
            toPort: 22,
            cidrBlocks: ["0.0.0.0/0"],
        },
        {
            protocol: "tcp",
            fromPort: 80,
            toPort: 80,
            cidrBlocks: ["0.0.0.0/0"],
        }
    ],
    egress: [
        {
            protocol: "-1", // All traffic
            fromPort: 0,
            toPort: 0,
            cidrBlocks: ["0.0.0.0/0"],
        }
    ]
});

Notice the ingress property. In Terraform, this would be a nested block. In Pulumi TypeScript, it is simply an Array of Objects. This makes it incredibly easy to map over data arrays to generate rules programmatically (which we will cover in Episode 6).


3. Practice: Deploying an EC2 Instance
#

Finally, let’s deploy an EC2 instance into our new Subnet and attach the Security Group.

// 4. Create the EC2 Instance
const webServer = new aws.ec2.Instance("web-server", {
    // We will hardcode an Ubuntu 22.04 AMI for this example
    ami: "ami-0fc5d935ebf8bc3bc", 
    instanceType: "t3.micro",
    
    // Wire up the dependencies
    subnetId: subnet.id,
    vpcSecurityGroupIds: [webSecurityGroup.id],
    
    tags: {
        Name: "Pulumi-Web-Server",
    },
});

// 5. Export the Public IP so we can see it in the terminal
export const serverPublicIp = webServer.publicIp;

Run pulumi up.

Pulumi will determine the exact order of operations:

  1. Create VPC
  2. Create Subnet & Security Group (in parallel)
  3. Create EC2 Instance (waits for Subnet and SG)

Expected Terminal Output:

Outputs:
  + serverPublicIp: "3.234.12.98"

4. Explicit Dependencies (The dependsOn Option)
#

In 99% of cases, passing an id or arn from one resource to another creates a sufficient Implicit Dependency.

However, sometimes you have two resources that do not share any data, but one must absolutely be created before the other due to cloud provider quirks (e.g., an IAM Role Policy Attachment must exist before a Lambda Function can boot up).

In these rare cases, you use the third argument of the resource class: the Resource Options object.

const iamRole = new aws.iam.Role("my-role", { ... });

const rolePolicy = new aws.iam.RolePolicyAttachment("my-policy", {
    role: iamRole.name,
    policyArn: "arn:aws:iam::aws:policy/AdministratorAccess",
});

const myLambda = new aws.lambda.Function("my-lambda", {
    role: iamRole.arn,
    // ... other args
}, {
    // 3rd Argument: Resource Options
    // Force Pulumi to wait for the Policy Attachment to finish
    dependsOn: [rolePolicy], 
});

Troubleshooting & Common Errors
#

  1. Type 'number' is not assignable to type 'string'

    • Root Cause: This is not a Pulumi error; it is a TypeScript compiler error. You passed the wrong data type to an argument (e.g., fromPort: "80" instead of fromPort: 80).
    • Solution: Listen to your IDE. Hover over the argument to read the TypeScript interface definition and provide the correct type.
  2. DependencyViolation: resource vpc-123 has dependent object

    • Root Cause: When running pulumi destroy, AWS refused to delete the VPC because something else (created outside of Pulumi) is running inside it.
    • Solution: Log into the AWS Console, find the rogue resource in the VPC, delete it manually, and run pulumi destroy again.

Conclusion & Next Steps
#

You have successfully mapped AWS infrastructure logic into object-oriented TypeScript classes. Your IDE is now acting as a real-time validation engine, drastically reducing the feedback loop compared to standard YAML/HCL development.

However, in this episode, we hardcoded the instanceType: "t3.micro" directly into the code. If we want to deploy this to Production, we need a way to dynamically inject a larger instance type (like m5.large) without modifying the index.ts file.

In Episode 4: Configuration and Secrets Management, we will learn how to inject environment-specific variables and securely manage sensitive passwords using the Pulumi Config system.

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