Pulumi Phase 1: Fundamentals - Your First Infrastructure as Code
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:
- Language Host: Runs your program and captures resource declarations
- Engine: Compares desired state with current state, determines what changes are needed
- Providers: Make actual API calls to cloud providers
- 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 blocks2. 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 projectWhat 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 lsCreating 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 URLResource 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 OutputOutput<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 secretSetting 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 instanceTypeReading 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.comSelf-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-stateEnvironment 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-bucket7. 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 destroyUnderstanding 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-steps8. 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 websiteUrl10. 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 upProtect 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
- Static Website: Deploy a static website to S3 (completed above)
- Multi-Stack Setup: Create
devandprodstacks with different configurations - Stack References: Create a network stack and an application stack that references it
- Import Existing Resource: Import an existing S3 bucket into Pulumi state
Series Navigation:
- Previous: Pulumi Learning Roadmap
- Next: Phase 2: Cloud Providers →
- Related: Migrating Pulumi State to Azure Blob Storage
📬 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.