Back to blog

Pulumi Phase 1: Fundamentals - Your First Infrastructure as Code

pulumiinfrastructuredevopsiaccloudtypescript

Welcome to Phase 1 of the Pulumi Learning Roadmap! In this phase, you'll learn the fundamental concepts of Pulumi and deploy your first cloud resources using real programming languages.

This is Part 2 of the Pulumi Learning Roadmap

What You'll Learn

By the end of this phase, you'll be able to:

✅ Understand Pulumi's architecture and how it differs from other IaC tools
✅ Create and organize projects and stacks
✅ Define and deploy cloud resources
✅ Work with inputs, outputs, and resource dependencies
✅ Manage configuration and secrets securely
✅ Understand state management and backend options
✅ Use stack references for cross-stack communication

Prerequisites

Before starting, ensure you have:

  • A programming language installed (Node.js for TypeScript, Python, or Go)
  • Pulumi CLI installed (curl -fsSL https://get.pulumi.com | sh)
  • A cloud account (AWS, Azure, or GCP) with credentials configured
  • Basic understanding of cloud concepts (VMs, storage, networking)

1. Understanding Pulumi Architecture

How Pulumi Works

Pulumi uses a unique architecture that separates your program from the deployment engine:

┌─────────────────────────────────────────────────────────────┐
│                     Your Pulumi Program                      │
│   (TypeScript, Python, Go, C#, Java, YAML)                  │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                    Pulumi Language Host                      │
│   (Executes your program, tracks resource declarations)     │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                      Pulumi Engine                           │
│   (Compares desired state vs current state, plans changes)  │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                    Resource Providers                        │
│   (AWS, Azure, GCP, Kubernetes, etc. - make API calls)      │
└─────────────────────────────────────────────────────────────┘

Key Components:

  1. Language Host: Runs your program and captures resource declarations
  2. Engine: Compares desired state with current state, determines what changes are needed
  3. Providers: Make actual API calls to cloud providers
  4. State: Stores the current state of your infrastructure

Pulumi vs Terraform: The Key Difference

The fundamental difference is that Pulumi executes your code to build a resource graph, while Terraform interprets HCL declarations.

// Pulumi: This is real TypeScript code that executes
const buckets = [];
for (let i = 0; i < 3; i++) {
    buckets.push(new aws.s3.Bucket(`bucket-${i}`));
}
 
// You can use any language feature: loops, conditionals, functions, classes
# Terraform: This is HCL, a declarative configuration language
resource "aws_s3_bucket" "bucket" {
  count = 3
  bucket = "bucket-${count.index}"
}
 
# Limited to HCL constructs: count, for_each, dynamic blocks

2. Projects and Stacks

What is a Project?

A project is a directory containing your Pulumi program. It's defined by a Pulumi.yaml file:

# Pulumi.yaml
name: my-infrastructure
runtime: nodejs  # or python, go, dotnet, java
description: My first Pulumi project

What is a Stack?

A stack is an isolated instance of your project. Common use cases:

  • Environments: dev, staging, prod
  • Regions: us-east-1, eu-west-1
  • Tenants: customer-a, customer-b
# Create stacks for different environments
pulumi stack init dev
pulumi stack init staging
pulumi stack init prod
 
# Switch between stacks
pulumi stack select dev
 
# List all stacks
pulumi stack ls

Creating Your First Project

# Create a new project with TypeScript
mkdir my-first-pulumi && cd my-first-pulumi
pulumi new aws-typescript
 
# This creates:
# - Pulumi.yaml (project definition)
# - Pulumi.dev.yaml (stack configuration)
# - index.ts (your program)
# - package.json (dependencies)
# - tsconfig.json (TypeScript config)

The generated index.ts:

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
 
// Create an S3 bucket
const bucket = new aws.s3.Bucket("my-bucket");
 
// Export the bucket name
export const bucketName = bucket.id;

3. Resources: The Building Blocks

Understanding Resources

A resource represents a cloud infrastructure component. Every resource has:

  • Type: What kind of resource (e.g., aws:s3/bucket:Bucket)
  • Name: Logical name in your program (e.g., "my-bucket")
  • Arguments: Configuration options (inputs)
  • Properties: Values returned after creation (outputs)
import * as aws from "@pulumi/aws";
 
// Create an S3 bucket
const bucket = new aws.s3.Bucket("my-bucket", {
    // Arguments (inputs)
    website: {
        indexDocument: "index.html",
        errorDocument: "error.html",
    },
    tags: {
        Environment: "dev",
        Project: "my-app",
    },
});
 
// Access properties (outputs)
export const bucketName = bucket.bucket;        // The actual bucket name
export const bucketArn = bucket.arn;            // The bucket ARN
export const websiteEndpoint = bucket.websiteEndpoint;  // Website URL

Resource Naming

Pulumi generates physical names by combining your logical name with a random suffix:

// Logical name: "my-bucket"
const bucket = new aws.s3.Bucket("my-bucket");
// Physical name: "my-bucket-a1b2c3d" (auto-generated)
 
// You can specify an exact name (not recommended for most resources)
const bucket2 = new aws.s3.Bucket("my-bucket-2", {
    bucket: "my-exact-bucket-name-12345",  // Physical name
});

Why auto-generated names?

  • Enables easy updates and replacements
  • Avoids naming conflicts across stacks
  • Supports blue-green deployments

Resource Options

Every resource accepts optional ResourceOptions:

const bucket = new aws.s3.Bucket("my-bucket", {
    // Resource arguments
}, {
    // Resource options
    protect: true,           // Prevent accidental deletion
    dependsOn: [otherResource],  // Explicit dependency
    parent: parentResource,      // Organizational hierarchy
    provider: customProvider,    // Use specific provider configuration
    ignoreChanges: ["tags"],     // Ignore changes to specific properties
    deleteBeforeReplace: true,   // Delete old resource before creating new
});

4. Inputs and Outputs

Understanding the Type System

Pulumi has a special type system for handling values that may not be known until deployment:

  • Input<T>: A value that might be a promise or an Output
  • Output<T>: A value that will be available after resource creation
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
 
// When you create a resource, some values aren't known yet
const bucket = new aws.s3.Bucket("my-bucket");
 
// bucket.id is Output<string>, not string
// The actual value isn't known until AWS creates the bucket
console.log(bucket.id);  // Prints: OutputImpl { ... }, not the actual ID
 
// To use an Output value, you must use .apply()
bucket.id.apply(id => {
    console.log(`Bucket ID: ${id}`);  // Prints actual ID after creation
});

Working with Outputs

Using .apply() for transformations:

const bucket = new aws.s3.Bucket("my-bucket");
 
// Transform an output
const bucketUrl = bucket.bucket.apply(name => `https://${name}.s3.amazonaws.com`);
 
// Combine multiple outputs
const combined = pulumi.all([bucket.bucket, bucket.arn]).apply(([name, arn]) => {
    return `Bucket ${name} has ARN ${arn}`;
});
 
// Use pulumi.interpolate for string interpolation (cleaner syntax)
const message = pulumi.interpolate`Bucket: ${bucket.bucket}, ARN: ${bucket.arn}`;

Using outputs as inputs to other resources:

// Outputs can be directly used as inputs - Pulumi handles the dependency
const bucket = new aws.s3.Bucket("my-bucket");
 
const bucketPolicy = new aws.s3.BucketPolicy("my-bucket-policy", {
    bucket: bucket.id,  // Output<string> is automatically handled
    policy: bucket.arn.apply(arn => JSON.stringify({
        Version: "2012-10-17",
        Statement: [{
            Effect: "Allow",
            Principal: "*",
            Action: "s3:GetObject",
            Resource: `${arn}/*`,
        }],
    })),
});

Automatic Dependencies

Pulumi automatically tracks dependencies when you use outputs as inputs:

// Pulumi creates: VPC → Subnet → Security Group → EC2 Instance
// (in that order, automatically)
 
const vpc = new aws.ec2.Vpc("vpc", {
    cidrBlock: "10.0.0.0/16",
});
 
const subnet = new aws.ec2.Subnet("subnet", {
    vpcId: vpc.id,  // Depends on VPC
    cidrBlock: "10.0.1.0/24",
});
 
const securityGroup = new aws.ec2.SecurityGroup("sg", {
    vpcId: vpc.id,  // Depends on VPC
    ingress: [{
        protocol: "tcp",
        fromPort: 22,
        toPort: 22,
        cidrBlocks: ["0.0.0.0/0"],
    }],
});
 
const instance = new aws.ec2.Instance("instance", {
    ami: "ami-0c55b159cbfafe1f0",
    instanceType: "t2.micro",
    subnetId: subnet.id,              // Depends on Subnet
    vpcSecurityGroupIds: [securityGroup.id],  // Depends on Security Group
});

5. Configuration and Secrets

Stack Configuration

Each stack has its own configuration file (Pulumi.<stack>.yaml):

# Pulumi.dev.yaml
config:
  aws:region: us-east-1
  my-infrastructure:instanceType: t2.micro
  my-infrastructure:dbPassword:
    secure: AAABADQx...  # Encrypted secret

Setting Configuration

# Set a plain configuration value
pulumi config set instanceType t2.micro
 
# Set a secret (encrypted in state)
pulumi config set --secret dbPassword "my-secret-password"
 
# Set configuration for a specific provider
pulumi config set aws:region us-east-1
 
# View configuration
pulumi config
 
# Get a specific value
pulumi config get instanceType

Reading Configuration in Code

import * as pulumi from "@pulumi/pulumi";
 
// Create a Config object
const config = new pulumi.Config();
 
// Read values (throws if not set)
const instanceType = config.require("instanceType");
const dbPassword = config.requireSecret("dbPassword");
 
// Read values with defaults
const environment = config.get("environment") || "dev";
const port = config.getNumber("port") || 3000;
const enableFeature = config.getBoolean("enableFeature") || false;
 
// Read from a specific namespace
const awsConfig = new pulumi.Config("aws");
const region = awsConfig.require("region");

Secrets in Pulumi

Secrets are encrypted at rest and marked as secret in the state:

const config = new pulumi.Config();
 
// This is a secret Output<string>
const dbPassword = config.requireSecret("dbPassword");
 
// Secrets are automatically encrypted in state
// and redacted in logs
 
// When you use a secret, the result is also secret
const connectionString = pulumi.interpolate`postgres://admin:${dbPassword}@localhost:5432/mydb`;
// connectionString is also a secret
 
// You can mark any output as secret
const mySecret = pulumi.secret("sensitive-value");
 
// Or mark an existing output as secret
const secretArn = pulumi.secret(bucket.arn);

6. State Management

What is State?

Pulumi state is a JSON file that tracks:

  • All resources managed by Pulumi
  • Their current properties
  • Dependencies between resources
  • Encrypted secrets

State Backends

Pulumi Cloud (Default):

# Login to Pulumi Cloud (default)
pulumi login
 
# Your state is stored at app.pulumi.com

Self-Hosted Backends:

# AWS S3
pulumi login s3://my-pulumi-state-bucket
 
# Azure Blob Storage
pulumi login azblob://my-container
 
# Google Cloud Storage
pulumi login gs://my-pulumi-state-bucket
 
# Local filesystem (development only)
pulumi login --local
# or
pulumi login file://~/.pulumi-state

Environment Variables for Self-Hosted:

# For Azure Blob Storage
export AZURE_STORAGE_ACCOUNT=mystorageaccount
export AZURE_STORAGE_KEY=my-storage-key
# or use service principal
export AZURE_CLIENT_ID=...
export AZURE_CLIENT_SECRET=...
export AZURE_TENANT_ID=...
 
# For AWS S3
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=us-east-1
 
# For encryption passphrase (self-hosted backends)
export PULUMI_CONFIG_PASSPHRASE="my-secret-passphrase"

For a complete guide on migrating to self-hosted state, see Migrating Pulumi State to Azure Blob Storage.

State Operations

# View current state
pulumi stack export
 
# Import existing resources into state
pulumi import aws:s3/bucket:Bucket my-bucket my-existing-bucket-name
 
# Refresh state from cloud provider (detect drift)
pulumi refresh
 
# Delete a resource from state (without deleting the actual resource)
pulumi state delete urn:pulumi:dev::my-project::aws:s3/bucket:Bucket::my-bucket

7. The Pulumi Workflow

Preview, Update, Destroy

# Step 1: Preview changes (dry-run)
pulumi preview
 
# Step 2: Apply changes
pulumi up
 
# Step 3: View outputs
pulumi stack output
 
# Step 4: Destroy resources (when done)
pulumi destroy

Understanding the Preview Output

Previewing update (dev)
 
     Type                 Name              Plan
 +   pulumi:pulumi:Stack  my-project-dev    create
 +   ├─ aws:s3:Bucket     my-bucket         create
 +   └─ aws:s3:BucketPolicy my-bucket-policy create
 
Resources:
    + 3 to create
 
Do you want to perform this update? [y/n/details]

Symbols:

  • + create
  • - delete
  • ~ update
  • +- replace (delete then create)
  • ~+ replace (create then delete)

Viewing Differences

# Show detailed diff
pulumi preview --diff
 
# Show specific resource changes
pulumi preview --diff --show-replacement-steps

8. Stack References

Stack references allow you to share outputs between stacks:

Exporting Outputs

// In network-stack (Pulumi.yaml: name: network-stack)
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
 
const vpc = new aws.ec2.Vpc("vpc", {
    cidrBlock: "10.0.0.0/16",
});
 
const publicSubnet = new aws.ec2.Subnet("public-subnet", {
    vpcId: vpc.id,
    cidrBlock: "10.0.1.0/24",
});
 
// Export values for other stacks to use
export const vpcId = vpc.id;
export const publicSubnetId = publicSubnet.id;

Importing Outputs

// In app-stack
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
 
// Reference the network stack
const networkStack = new pulumi.StackReference("organization/network-stack/dev");
 
// Get outputs from the network stack
const vpcId = networkStack.getOutput("vpcId");
const subnetId = networkStack.getOutput("publicSubnetId");
 
// Use in your resources
const instance = new aws.ec2.Instance("app-server", {
    ami: "ami-0c55b159cbfafe1f0",
    instanceType: "t2.micro",
    subnetId: subnetId,  // From network stack
});

Stack Reference Patterns

// Get required output (throws if not found)
const vpcId = networkStack.requireOutput("vpcId");
 
// Get output with type
const port = networkStack.getOutput("port") as pulumi.Output<number>;
 
// Use in interpolation
const url = pulumi.interpolate`http://${networkStack.getOutput("loadBalancerDns")}`;

9. Hands-On Project: Static Website

Let's build a complete static website hosted on S3:

// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as fs from "fs";
import * as path from "path";
import * as mime from "mime";
 
// Configuration
const config = new pulumi.Config();
const siteName = config.get("siteName") || "my-static-site";
 
// Create S3 bucket for website hosting
const bucket = new aws.s3.Bucket(siteName, {
    website: {
        indexDocument: "index.html",
        errorDocument: "error.html",
    },
});
 
// Configure bucket for public access
const bucketPublicAccessBlock = new aws.s3.BucketPublicAccessBlock(`${siteName}-public-access`, {
    bucket: bucket.id,
    blockPublicAcls: false,
    blockPublicPolicy: false,
    ignorePublicAcls: false,
    restrictPublicBuckets: false,
});
 
// Bucket policy for public read access
const bucketPolicy = new aws.s3.BucketPolicy(`${siteName}-policy`, {
    bucket: bucket.id,
    policy: bucket.arn.apply(arn => JSON.stringify({
        Version: "2012-10-17",
        Statement: [{
            Effect: "Allow",
            Principal: "*",
            Action: "s3:GetObject",
            Resource: `${arn}/*`,
        }],
    })),
}, { dependsOn: [bucketPublicAccessBlock] });
 
// Upload website files
const wwwDir = "./www";
const files = fs.readdirSync(wwwDir);
 
for (const file of files) {
    const filePath = path.join(wwwDir, file);
    const contentType = mime.getType(filePath) || "application/octet-stream";
 
    new aws.s3.BucketObject(file, {
        bucket: bucket.id,
        source: new pulumi.asset.FileAsset(filePath),
        contentType: contentType,
    }, { dependsOn: [bucketPolicy] });
}
 
// Export the website URL
export const websiteUrl = bucket.websiteEndpoint;
export const bucketName = bucket.bucket;

Create the website content:

<!-- www/index.html -->
<!DOCTYPE html>
<html>
<head>
    <title>My Pulumi Website</title>
    <style>
        body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
        h1 { color: #6b46c1; }
    </style>
</head>
<body>
    <h1>Hello from Pulumi!</h1>
    <p>This static website is deployed using Infrastructure as Code.</p>
</body>
</html>
<!-- www/error.html -->
<!DOCTYPE html>
<html>
<head>
    <title>Error</title>
</head>
<body>
    <h1>404 - Page Not Found</h1>
    <p><a href="/">Go back home</a></p>
</body>
</html>

Deploy:

# Install dependencies
npm install @pulumi/aws mime
 
# Preview
pulumi preview
 
# Deploy
pulumi up
 
# Get the website URL
pulumi stack output websiteUrl

10. Best Practices for Phase 1

Naming Conventions

// Use descriptive, consistent names
const productionVpc = new aws.ec2.Vpc("production-vpc", { ... });
const stagingVpc = new aws.ec2.Vpc("staging-vpc", { ... });
 
// Use the stack name in resource names for clarity
const stackName = pulumi.getStack();
const bucket = new aws.s3.Bucket(`${stackName}-data-bucket`, { ... });

Organize with Comments and Sections

// ============================================================
// NETWORKING
// ============================================================
 
const vpc = new aws.ec2.Vpc("main-vpc", { ... });
const subnet = new aws.ec2.Subnet("main-subnet", { ... });
 
// ============================================================
// COMPUTE
// ============================================================
 
const instance = new aws.ec2.Instance("web-server", { ... });
 
// ============================================================
// OUTPUTS
// ============================================================
 
export const vpcId = vpc.id;
export const instanceIp = instance.publicIp;

Use Configuration for Environment Differences

const config = new pulumi.Config();
const environment = pulumi.getStack();
 
// Different instance sizes per environment
const instanceType = config.get("instanceType") ||
    (environment === "prod" ? "t3.large" : "t3.micro");
 
// Different replica counts
const replicaCount = config.getNumber("replicaCount") ||
    (environment === "prod" ? 3 : 1);

Always Use Preview

# Never run `pulumi up` without previewing first
pulumi preview
# Review the changes carefully
pulumi up

Protect Critical Resources

// Prevent accidental deletion of important resources
const database = new aws.rds.Instance("production-db", {
    // ... configuration
}, {
    protect: true,  // Cannot be deleted without removing protection first
});

Summary

In Phase 1, you learned:

Pulumi Architecture: How Pulumi executes your code to build resource graphs
Projects and Stacks: Organizing infrastructure by environment
Resources: Creating and configuring cloud resources
Inputs and Outputs: Working with Pulumi's type system
Configuration and Secrets: Managing environment-specific values securely
State Management: Understanding where Pulumi stores infrastructure state
Stack References: Sharing outputs between stacks

What's Next?

In Phase 2: Cloud Providers, you'll learn:

  • Deep dive into AWS infrastructure (VPC, EC2, RDS, Lambda)
  • Azure infrastructure (Resource Groups, VNets, VMs, Functions)
  • GCP infrastructure (VPC, Compute Engine, Cloud Storage)
  • Kubernetes deployment with Pulumi
  • Cross-cloud patterns and abstractions

🔗 Continue to Phase 2: Cloud Providers →

Practice Exercises

  1. Static Website: Deploy a static website to S3 (completed above)
  2. Multi-Stack Setup: Create dev and prod stacks with different configurations
  3. Stack References: Create a network stack and an application stack that references it
  4. Import Existing Resource: Import an existing S3 bucket into Pulumi state

Series Navigation:

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.