In today’s cloud-first world, infrastructure as code (IaC) has revolutionized how organizations deploy and manage their cloud resources. Two powerful contenders in this space—Terraform and Pulumi—offer compelling approaches to defining infrastructure programmatically. While both tools aim to solve similar problems, they take fundamentally different approaches that make each better suited for specific scenarios and team compositions.
This guide will help you navigate the decision between Terraform and Pulumi by examining their core approaches, key differences, and ideal use cases, complete with practical examples to illustrate their strengths.
Before diving into specific scenarios, it’s essential to understand the fundamental philosophies behind each tool.
HashiCorp’s Terraform uses a declarative domain-specific language called HashiCorp Configuration Language (HCL). With Terraform, you describe the desired end state of your infrastructure, and the tool determines the steps needed to achieve that state.
hcl# Terraform example - AWS S3 bucket
resource "aws_s3_bucket" "data_lake" {
bucket = "enterprise-data-lake"
acl = "private"
versioning {
enabled = true
}
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
tags = {
Environment = "Production"
Department = "Data Engineering"
}
}
Pulumi takes a different approach by allowing you to define infrastructure using familiar programming languages like TypeScript, Python, Go, C#, Java, and YAML. This “infrastructure as software” model leverages the full capabilities of programming languages.
python# Pulumi example - AWS S3 bucket in Python
import pulumi
import pulumi_aws as aws
data_lake = aws.s3.Bucket("data-lake",
bucket="enterprise-data-lake",
acl="private",
versioning=aws.s3.BucketVersioningArgs(
enabled=True,
),
server_side_encryption_configuration=aws.s3.BucketServerSideEncryptionConfigurationArgs(
rule=aws.s3.BucketServerSideEncryptionConfigurationRuleArgs(
apply_server_side_encryption_by_default=aws.s3.BucketServerSideEncryptionConfigurationRuleApplyServerSideEncryptionByDefaultArgs(
sse_algorithm="AES256",
),
),
),
tags={
"Environment": "Production",
"Department": "Data Engineering",
},
)
pulumi.export("bucket_name", data_lake.id)
To make an informed decision between these tools, consider these critical differences:
Terraform uses HCL, a declarative domain-specific language designed specifically for defining infrastructure:
- Simpler syntax focused on resource definitions
- Limited programming constructs (basic loops, conditionals)
- Consistent format across all providers
Pulumi leverages general-purpose programming languages:
- Full access to language features (classes, functions, loops, conditionals, error handling)
- Ability to use existing libraries and package managers
- Familiar tooling (IDEs, linters, type checkers)
Terraform:
- State files store the mapping between your configurations and the real-world resources
- Various backend options for state storage (local, S3, Azure Blob Storage, etc.)
- Built-in state locking mechanisms
Pulumi:
- Similar state management concept
- State stored in the Pulumi service by default (free tier available)
- Self-hosted option available for enterprise environments
Terraform:
- Mature ecosystem with a large community
- Extensive provider ecosystem with over 1,000 providers
- Widely adopted across industries
Pulumi:
- Growing community with strong developer focus
- Leverages existing Terraform providers
- Strong adoption in development-focused organizations
Terraform:
- Lower initial learning curve, especially for those new to IaC
- Simple, focused syntax for infrastructure definition
- Less conceptual overhead for non-developers
Pulumi:
- Leverages existing programming knowledge
- Higher initial complexity but potentially more maintainable for complex scenarios
- More natural for developers already familiar with the supported languages
Terraform is particularly well-suited for these scenarios:
When your team includes both developers and IT operations professionals with varied programming experience, Terraform’s focused syntax can be more accessible.
hcl# Define network infrastructure
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "3.14.0"
name = "three-tier-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = false
tags = {
Environment = var.environment
Project = "ThreeTierApp"
}
}
# Create database tier
module "db" {
source = "terraform-aws-modules/rds/aws"
version = "5.0.0"
identifier = "app-database"
engine = "postgres"
engine_version = "13.4"
instance_class = "db.t3.large"
allocated_storage = 100
db_name = "appdb"
username = var.db_username
password = var.db_password
port = "5432"
subnet_ids = module.vpc.private_subnets
vpc_security_group_ids = [aws_security_group.db_sg.id]
tags = {
Environment = var.environment
Project = "ThreeTierApp"
}
}
# Application tier
resource "aws_ecs_cluster" "app_cluster" {
name = "app-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_ecs_service" "app_service" {
name = "app-service"
cluster = aws_ecs_cluster.app_cluster.id
task_definition = aws_ecs_task_definition.app_task.arn
desired_count = 3
launch_type = "FARGATE"
network_configuration {
subnets = module.vpc.private_subnets
security_groups = [aws_security_group.app_sg.id]
}
load_balancer {
target_group_arn = aws_lb_target_group.app_tg.arn
container_name = "app"
container_port = 80
}
}
# Web tier (Load Balancer)
resource "aws_lb" "web_lb" {
name = "web-lb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.web_sg.id]
subnets = module.vpc.public_subnets
tags = {
Environment = var.environment
Project = "ThreeTierApp"
}
}
resource "aws_lb_listener" "front_end" {
load_balancer_arn = aws_lb.web_lb.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08"
certificate_arn = var.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app_tg.arn
}
}
This example demonstrates how Terraform’s straightforward syntax makes it easy for multi-disciplinary teams to understand and contribute to infrastructure definitions, even without deep programming knowledge.
When your infrastructure follows well-defined, relatively static patterns, Terraform’s declarative approach shines.
hcl# S3 Data Lake
resource "aws_s3_bucket" "data_lake" {
bucket = "${var.project_name}-data-lake-${var.environment}"
acl = "private"
versioning {
enabled = true
}
lifecycle_rule {
id = "archive-rule"
enabled = true
transition {
days = 90
storage_class = "GLACIER"
}
}
tags = var.standard_tags
}
# Redshift Data Warehouse
resource "aws_redshift_cluster" "data_warehouse" {
cluster_identifier = "${var.project_name}-dw-${var.environment}"
database_name = "analytics"
master_username = var.redshift_username
master_password = var.redshift_password
node_type = var.environment == "production" ? "ra3.4xlarge" : "ra3.xlplus"
cluster_type = var.environment == "production" ? "multi-node" : "single-node"
number_of_nodes = var.environment == "production" ? 4 : 1
encrypted = true
skip_final_snapshot = var.environment != "production"
tags = var.standard_tags
}
# EMR Cluster for Data Processing
resource "aws_emr_cluster" "data_processing" {
name = "${var.project_name}-emr-${var.environment}"
release_label = "emr-6.5.0"
applications = ["Spark", "Hive", "Presto"]
ec2_attributes {
subnet_id = var.private_subnet_id
instance_profile = aws_iam_instance_profile.emr_profile.arn
emr_managed_master_security_group = aws_security_group.emr_master.id
emr_managed_slave_security_group = aws_security_group.emr_slave.id
}
master_instance_group {
instance_type = var.environment == "production" ? "m5.xlarge" : "m5.large"
}
core_instance_group {
instance_type = var.environment == "production" ? "r5.2xlarge" : "r5.large"
instance_count = var.environment == "production" ? 10 : 3
ebs_config {
size = var.environment == "production" ? 100 : 50
type = "gp3"
volumes_per_instance = 1
}
}
tags = var.standard_tags
}
# Airflow for Orchestration (using MWAA)
resource "aws_mwaa_environment" "data_orchestration" {
name = "${var.project_name}-airflow-${var.environment}"
airflow_version = "2.2.2"
environment_class = var.environment == "production" ? "mw1.large" : "mw1.small"
source_bucket_arn = aws_s3_bucket.airflow_bucket.arn
execution_role_arn = aws_iam_role.airflow_role.arn
network_configuration {
security_group_ids = [aws_security_group.airflow_sg.id]
subnet_ids = var.private_subnet_ids
}
logging_configuration {
dag_processing_logs {
enabled = true
log_level = "INFO"
}
scheduler_logs {
enabled = true
log_level = "INFO"
}
webserver_logs {
enabled = true
log_level = "INFO"
}
worker_logs {
enabled = true
log_level = "INFO"
}
}
tags = var.standard_tags
}
This example demonstrates how Terraform effectively handles well-defined infrastructure patterns with simple conditionals based on environment variables.
When you need a tool with mature support across multiple cloud providers, Terraform’s extensive provider ecosystem provides an advantage.
hcl# AWS Resources
provider "aws" {
region = "us-east-1"
alias = "east"
}
provider "aws" {
region = "us-west-2"
alias = "west"
}
resource "aws_s3_bucket" "primary_storage" {
provider = aws.east
bucket = "multi-cloud-primary-storage"
acl = "private"
versioning {
enabled = true
}
}
# Azure Resources
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "backup" {
name = "multi-cloud-backup"
location = "East US"
}
resource "azurerm_storage_account" "backup_storage" {
name = "multicloudbackup"
resource_group_name = azurerm_resource_group.backup.name
location = azurerm_resource_group.backup.location
account_tier = "Standard"
account_replication_type = "GRS"
}
# Google Cloud Resources
provider "google" {
project = "multi-cloud-project-123"
region = "us-central1"
}
resource "google_storage_bucket" "analytics_storage" {
name = "multi-cloud-analytics-storage"
location = "US"
versioning {
enabled = true
}
}
# Cross-cloud data replication (conceptual)
resource "aws_lambda_function" "replication_function" {
provider = aws.east
function_name = "cross-cloud-replication"
role = aws_iam_role.replication_role.arn
handler = "index.handler"
runtime = "nodejs14.x"
environment {
variables = {
AZURE_STORAGE_CONNECTION = azurerm_storage_account.backup_storage.primary_connection_string
GCP_BUCKET_NAME = google_storage_bucket.analytics_storage.name
}
}
}
This example shows Terraform’s strength in managing resources across multiple cloud providers with a consistent syntax and workflow.
Pulumi is the stronger choice in these scenarios:
For teams with strong software development backgrounds, Pulumi allows leveraging existing programming skills and patterns.
typescriptimport * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
// Load configuration
const config = new pulumi.Config();
const environment = config.require("environment");
const serviceCount = config.requireNumber("serviceCount");
// Define environment-specific settings
const envSettings = {
dev: {
instanceType: "t3.small",
minSize: 1,
maxSize: 3,
dbInstanceClass: "db.t3.small",
},
staging: {
instanceType: "t3.medium",
minSize: 2,
maxSize: 5,
dbInstanceClass: "db.t3.medium",
},
production: {
instanceType: "m5.large",
minSize: 3,
maxSize: 10,
dbInstanceClass: "db.m5.large",
},
};
// Get settings for current environment
const settings = envSettings[environment as keyof typeof envSettings] || envSettings.dev;
// Create VPC
const vpc = new awsx.ec2.Vpc("app-vpc", {
cidrBlock: "10.0.0.0/16",
numberOfAvailabilityZones: 3,
subnetSpecs: [
{ type: awsx.ec2.SubnetType.Public, tags: { tier: "public" } },
{ type: awsx.ec2.SubnetType.Private, tags: { tier: "private" } },
],
natGateways: {
strategy: environment === "production" ?
awsx.ec2.NatGatewayStrategy.OnePerAz :
awsx.ec2.NatGatewayStrategy.Single,
},
});
// Create database
const dbSubnetGroup = new aws.rds.SubnetGroup("db-subnet-group", {
subnetIds: vpc.privateSubnetIds,
});
const dbSecurityGroup = new aws.ec2.SecurityGroup("db-security-group", {
vpcId: vpc.vpcId,
ingress: [{
protocol: "tcp",
fromPort: 5432,
toPort: 5432,
cidrBlocks: ["10.0.0.0/16"],
}],
});
const db = new aws.rds.Instance("app-db", {
engine: "postgres",
instanceClass: settings.dbInstanceClass,
allocatedStorage: 20,
dbSubnetGroupName: dbSubnetGroup.name,
vpcSecurityGroupIds: [dbSecurityGroup.id],
name: "appdb",
username: config.requireSecret("dbUsername"),
password: config.requireSecret("dbPassword"),
skipFinalSnapshot: environment !== "production",
});
// Dynamically create multiple microservices
const services = [];
for (let i = 0; i < serviceCount; i++) {
const serviceName = `service-${i}`;
// Create security group for service
const serviceSecurityGroup = new aws.ec2.SecurityGroup(`${serviceName}-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"],
}],
});
// Create load balancer for service
const lb = new awsx.lb.ApplicationLoadBalancer(`${serviceName}-lb`, {
securityGroups: [serviceSecurityGroup.id],
subnetIds: vpc.publicSubnetIds,
});
// Create ECS cluster for service
const cluster = new aws.ecs.Cluster(`${serviceName}-cluster`);
// Create ECS service
const service = new awsx.ecs.FargateService(`${serviceName}`, {
cluster: cluster.arn,
desiredCount: settings.minSize,
taskDefinitionArgs: {
container: {
image: `${config.require("imageRepository")}/${serviceName}:${config.require("imageTag")}`,
cpu: 256,
memory: 512,
essential: true,
portMappings: [{
containerPort: 80,
targetGroup: lb.defaultTargetGroup,
}],
environment: [
{ name: "SERVICE_NAME", value: serviceName },
{ name: "ENVIRONMENT", value: environment },
{ name: "DB_HOST", value: db.endpoint.apply(ep => ep.split(":")[0]) },
{ name: "DB_PORT", value: "5432" },
{ name: "DB_NAME", value: "appdb" },
],
secrets: [
{ name: "DB_USER", valueFrom: config.requireSecret("dbUserArn") },
{ name: "DB_PASSWORD", valueFrom: config.requireSecret("dbPasswordArn") },
],
},
},
networkConfiguration: {
subnets: vpc.privateSubnetIds,
securityGroups: [serviceSecurityGroup.id],
},
});
// Create auto-scaling for the service
const autoScaling = new aws.appautoscaling.Target(`${serviceName}-scaling-target`, {
maxCapacity: settings.maxSize,
minCapacity: settings.minSize,
resourceId: pulumi.interpolate`service/${cluster.name}/${service.name}`,
scalableDimension: "ecs:service:DesiredCount",
serviceNamespace: "ecs",
});
const scaleUpPolicy = new aws.appautoscaling.Policy(`${serviceName}-scale-up`, {
policyType: "StepScaling",
resourceId: autoScaling.resourceId,
scalableDimension: autoScaling.scalableDimension,
serviceNamespace: autoScaling.serviceNamespace,
stepScalingPolicyConfiguration: {
adjustmentType: "ChangeInCapacity",
cooldown: 60,
metricAggregationType: "Average",
stepAdjustments: [{
metricIntervalLowerBound: "0",
scalingAdjustment: 1,
}],
},
});
// Export service URL
services.push({
name: serviceName,
url: lb.loadBalancer.dnsName,
});
}
// Export outputs
export const databaseEndpoint = db.endpoint;
export const serviceEndpoints = services.map(s => ({ name: s.name, url: s.url }));
This example demonstrates Pulumi’s power in using TypeScript to dynamically create multiple microservices with environment-specific configurations, leveraging programming language features like loops, conditionals, and object manipulation.
When your infrastructure requires complex logic, data transformations, or integration with external systems, Pulumi’s full programming capabilities shine.
pythonimport pulumi
import pulumi_aws as aws
import json
import requests
import ipaddress
# Load configuration
config = pulumi.Config()
environment = config.require("environment")
region = config.require("region")
# Fetch IP ranges for third-party services dynamically
def get_external_service_ips(service_name):
response = requests.get("https://ip-ranges.amazonaws.com/ip-ranges.json")
ip_ranges = json.loads(response.text)["prefixes"]
return [item["ip_prefix"] for item in ip_ranges
if item["service"] == service_name and item["region"] == region]
# Aggregate and optimize IP ranges
def optimize_ip_ranges(ip_ranges):
networks = [ipaddress.ip_network(cidr) for cidr in ip_ranges]
optimized = ipaddress.collapse_addresses(networks)
return [str(network) for network in optimized]
# Get CloudFront IP ranges for allowlist
cloudfront_ips = get_external_service_ip("CLOUDFRONT")
optimized_cloudfront_ips = optimize_ip_ranges(cloudfront_ips)
# Create VPC with dynamic subnet calculation
class VpcWithSubnets(pulumi.ComponentResource):
def __init__(self, name, cidr_block, subnet_bits, az_count, opts=None):
super().__init__("custom:resource:VpcWithSubnets", name, {}, opts)
# Create VPC
self.vpc = aws.ec2.Vpc(f"{name}-vpc",
cidr_block=cidr_block,
enable_dns_hostnames=True,
enable_dns_support=True,
tags={
"Name": f"{name}-vpc",
"Environment": environment,
},
opts=pulumi.ResourceOptions(parent=self)
)
# Calculate subnet ranges
network = ipaddress.ip_network(cidr_block)
subnets = list(network.subnets(prefixlen_diff=subnet_bits))
if len(subnets) < az_count * 2: # Need public and private subnets for each AZ
raise ValueError(f"Not enough subnet space for {az_count} AZs with /{network.prefixlen + subnet_bits} subnets")
# Get availability zones
available_azs = aws.get_availability_zones(state="available")
azs = available_azs.names[:az_count]
# Create subnets
self.public_subnets = []
self.private_subnets = []
for i, az in enumerate(azs):
# Public subnet
public_subnet = aws.ec2.Subnet(f"{name}-public-{i}",
vpc_id=self.vpc.id,
cidr_block=str(subnets[i*2]),
availability_zone=az,
map_public_ip_on_launch=True,
tags={
"Name": f"{name}-public-{i}",
"Environment": environment,
"Tier": "public",
},
opts=pulumi.ResourceOptions(parent=self)
)
self.public_subnets.append(public_subnet)
# Private subnet
private_subnet = aws.ec2.Subnet(f"{name}-private-{i}",
vpc_id=self.vpc.id,
cidr_block=str(subnets[i*2+1]),
availability_zone=az,
tags={
"Name": f"{name}-private-{i}",
"Environment": environment,
"Tier": "private",
},
opts=pulumi.ResourceOptions(parent=self)
)
self.private_subnets.append(private_subnet)
# Create internet gateway
self.igw = aws.ec2.InternetGateway(f"{name}-igw",
vpc_id=self.vpc.id,
tags={
"Name": f"{name}-igw",
"Environment": environment,
},
opts=pulumi.ResourceOptions(parent=self)
)
# Create NAT gateways and routes
self.nat_gateways = []
for i, subnet in enumerate(self.public_subnets):
# Only create multiple NAT gateways in production
if i > 0 and environment != "production":
break
eip = aws.ec2.Eip(f"{name}-nat-eip-{i}",
vpc=True,
tags={
"Name": f"{name}-nat-eip-{i}",
"Environment": environment,
},
opts=pulumi.ResourceOptions(parent=self)
)
nat_gateway = aws.ec2.NatGateway(f"{name}-nat-{i}",
allocation_id=eip.id,
subnet_id=subnet.id,
tags={
"Name": f"{name}-nat-{i}",
"Environment": environment,
},
opts=pulumi.ResourceOptions(parent=self)
)
self.nat_gateways.append(nat_gateway)
# Create route tables
self.public_route_table = aws.ec2.RouteTable(f"{name}-public-rt",
vpc_id=self.vpc.id,
routes=[
aws.ec2.RouteTableRouteArgs(
cidr_block="0.0.0.0/0",
gateway_id=self.igw.id,
),
],
tags={
"Name": f"{name}-public-rt",
"Environment": environment,
},
opts=pulumi.ResourceOptions(parent=self)
)
# Associate public subnets with public route table
for i, subnet in enumerate(self.public_subnets):
aws.ec2.RouteTableAssociation(f"{name}-public-rta-{i}",
subnet_id=subnet.id,
route_table_id=self.public_route_table.id,
opts=pulumi.ResourceOptions(parent=self)
)
# Create private route tables (one per NAT gateway)
self.private_route_tables = []
for i, nat_gateway in enumerate(self.nat_gateways):
rt = aws.ec2.RouteTable(f"{name}-private-rt-{i}",
vpc_id=self.vpc.id,
routes=[
aws.ec2.RouteTableRouteArgs(
cidr_block="0.0.0.0/0",
nat_gateway_id=nat_gateway.id,
),
],
tags={
"Name": f"{name}-private-rt-{i}",
"Environment": environment,
},
opts=pulumi.ResourceOptions(parent=self)
)
self.private_route_tables.append(rt)
# Associate private subnets with private route tables
for i, subnet in enumerate(self.private_subnets):
# In non-production, all private subnets use the single NAT gateway
rt_index = 0 if environment != "production" or len(self.private_route_tables) == 1 else i
rt_index = min(rt_index, len(self.private_route_tables) - 1)
aws.ec2.RouteTableAssociation(f"{name}-private-rta-{i}",
subnet_id=subnet.id,
route_table_id=self.private_route_tables[rt_index].id,
opts=pulumi.ResourceOptions(parent=self)
)
# Register outputs
self.register_outputs({})
# Create intelligently designed VPC
app_vpc = VpcWithSubnets("app",
cidr_block="10.0.0.0/16",
subnet_bits=4, # /20 subnets
az_count=3
)
# Create security groups with optimized rules
web_security_group = aws.ec2.SecurityGroup("web-sg",
vpc_id=app_vpc.vpc.id,
description="Web tier security group",
ingress=[
aws.ec2.SecurityGroupIngressArgs(
protocol="tcp",
from_port=80,
to_port=80,
cidr_blocks=["0.0.0.0/0"],
),
aws.ec2.SecurityGroupIngressArgs(
protocol="tcp",
from_port=443,
to_port=443,
cidr_blocks=["0.0.0.0/0"],
),
] + [
# Add optimized CloudFront IP ranges
aws.ec2.SecurityGroupIngressArgs(
protocol="tcp",
from_port=80,
to_port=80,
cidr_blocks=[cidr],
description=f"CloudFront IP range {cidr}"
)
for cidr in optimized_cloudfront_ips
],
egress=[
aws.ec2.SecurityGroupEgressArgs(
protocol="-1",
from_port=0,
to_port=0,
cidr_blocks=["0.0.0.0/0"],
),
],
tags={
"Name": "web-sg",
"Environment": environment,
},
)
# Create application load balancer
app_lb = aws.lb.LoadBalancer("app-lb",
internal=False,
load_balancer_type="application",
security_groups=[web_security_group.id],
subnets=app_vpc.public_subnets,
tags={subnets=[subnet.id for subnet in app_vpc.public_subnets],
tags={
"Name": "app-lb",
"Environment": environment,
},
)
# Dynamically create target groups based on services
services = config.get_object("services") or [{"name": "default", "port": 80}]
target_groups = {}
for service in services:
service_name = service["name"]
service_port = service["port"]
health_check = service.get("health_check", "/health")
target_groups[service_name] = aws.lb.TargetGroup(f"{service_name}-tg",
port=service_port,
protocol="HTTP",
vpc_id=app_vpc.vpc.id,
health_check={
"path": health_check,
"port": "traffic-port",
"healthy_threshold": 3,
"unhealthy_threshold": 3,
"timeout": 5,
"interval": 30,
},
target_type="ip",
tags={
"Name": f"{service_name}-tg",
"Environment": environment,
"Service": service_name,
},
)
# Create listeners with dynamic routing rules
http_listener = aws.lb.Listener("http-listener",
load_balancer_arn=app_lb.arn,
port=80,
default_actions=[{
"type": "redirect",
"redirect": {
"port": "443",
"protocol": "HTTPS",
"status_code": "HTTP_301",
},
}],
)
# Get SSL certificate from parameter store or create one if needed
certificate_arn = config.get("certificateArn")
if not certificate_arn:
# Create a new certificate
certificate = aws.acm.Certificate("app-cert",
domain_name=f"app.{config.require('domain')}",
validation_method="DNS",
tags={
"Name": "app-cert",
"Environment": environment,
},
)
certificate_arn = certificate.arn
https_listener = aws.lb.Listener("https-listener",
load_balancer_arn=app_lb.arn,
port=443,
protocol="HTTPS",
ssl_policy="ELBSecurityPolicy-2016-08",
certificate_arn=certificate_arn,
default_actions=[{
"type": "forward",
"target_group_arn": target_groups["default"].arn,
}],
)
# Create rules for each service
for i, service in enumerate(services):
if service["name"] == "default":
continue
path_pattern = service.get("path_pattern", f"/{service['name']}*")
aws.lb.ListenerRule(f"{service['name']}-rule",
listener_arn=https_listener.arn,
priority=100 + i,
actions=[{
"type": "forward",
"target_group_arn": target_groups[service["name"]].arn,
}],
conditions=[{
"path_pattern": {
"values": [path_pattern],
},
}],
)
# Export outputs
pulumi.export("vpc_id", app_vpc.vpc.id)
pulumi.export("public_subnets", [subnet.id for subnet in app_vpc.public_subnets])
pulumi.export("private_subnets", [subnet.id for subnet in app_vpc.private_subnets])
pulumi.export("load_balancer_dns", app_lb.dns_name)
pulumi.export("target_groups", {name: tg.arn for name, tg in target_groups.items()})
This example illustrates how Pulumi leverages Python’s capabilities to fetch external IP ranges, perform network calculations, implement intelligent subnet allocation, and create a comprehensive application infrastructure with dynamically generated components based on configuration.
For teams that prioritize testing infrastructure code, Pulumi enables standard testing practices with familiar tools.
gopackage main
import (
"testing"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/stretchr/testify/assert"
)
// Mock function for testing
type mocks int
func (mocks) NewResource(args pulumi.MockResourceArgs) (string, resource map[string]interface{}, err error) {
// Return a fake ID and resource state
return args.Name + "_id", args.Inputs, nil
}
func (mocks) Call(args pulumi.MockCallArgs) (resource map[string]interface{}, err error) {
return map[string]interface{}{}, nil
}
// TestInfrastructureStack tests our infrastructure stack
func TestInfrastructureStack(t *testing.T) {
err := pulumi.RunErr(func(ctx *pulumi.Context) error {
// Call the function that creates our infrastructure
infra, err := createInfrastructure(ctx)
assert.NoError(t, err)
// Verify VPC configuration
ctx.Export("vpcTest", infra.Vpc.CidrBlock)
ctx.Export("publicSubnetTest", infra.PublicSubnets[0].CidrBlock)
// Verify database configuration
ctx.Export("dbTest", infra.Database.Engine)
// Verify security group rules
ctx.Export("sgTest", infra.WebSecurityGroup.Ingress)
return nil
}, pulumi.WithMocks("project", "stack", mocks(0)))
assert.NoError(t, err)
}
// Test the subnet allocation logic
func TestSubnetAllocation(t *testing.T) {
subnets, err := calculateSubnets("10.0.0.0/16", 8, 3)
assert.NoError(t, err)
assert.Equal(t, 6, len(subnets))
assert.Equal(t, "10.0.0.0/24", subnets[0])
assert.Equal(t, "10.0.1.0/24", subnets[1])
}
// Test environment-specific settings
func TestEnvironmentSettings(t *testing.T) {
// Test production settings
prodSettings := getEnvironmentSettings("production")
assert.Equal(t, "m5.large", prodSettings.InstanceType)
assert.Equal(t, 3, prodSettings.MinInstances)
// Test development settings
devSettings := getEnvironmentSettings("development")
assert.Equal(t, "t3.small", devSettings.InstanceType)
assert.Equal(t, 1, devSettings.MinInstances)
}
// Load test for subnet calculation
func BenchmarkSubnetCalculation(b *testing.B) {
for i := 0; i < b.N; i++ {
calculateSubnets("10.0.0.0/16", 8, 3)
}
}
This example demonstrates how Pulumi enables proper testing of infrastructure code using standard Go testing tools, including unit tests, mock testing of resources, and even benchmarks.
When your infrastructure needs to integrate tightly with application code or existing workflows, Pulumi’s use of standard programming languages provides seamless integration.
typescript// shared/config.ts - Used by both application and infrastructure
export interface DatabaseConfig {
host: string;
port: number;
name: string;
user: string;
}
export interface ServiceConfig {
name: string;
port: number;
environment: string;
replicas: number;
memoryLimit: string;
cpuLimit: string;
}
export function getDatabaseConfig(environment: string): DatabaseConfig {
return {
host: environment === "local" ? "localhost" : `db.${environment}.example.com`,
port: 5432,
name: `app_${environment}`,
user: `app_${environment}`,
};
}
export function getServiceSettings(service: string, environment: string): ServiceConfig {
const baseConfig = {
name: service,
port: getServicePort(service),
environment,
replicas: 1,
memoryLimit: "512Mi",
cpuLimit: "0.5",
};
if (environment === "production") {
return {
...baseConfig,
replicas: 3,
memoryLimit: "1Gi",
cpuLimit: "1.0",
};
}
return baseConfig;
}
function getServicePort(service: string): number {
const servicePorts: Record<string, number> = {
"api": 3000,
"auth": 3001,
"payments": 3002,
"notifications": 3003,
};
return servicePorts[service] || 8080;
}
// infrastructure/index.ts - Pulumi infrastructure code
import * as pulumi from "@pulumi/pulumi";
import * as k8s from "@pulumi/kubernetes";
import { getServiceSettings } from "../shared/config";
const config = new pulumi.Config();
const environment = config.require("environment");
// Create Kubernetes provider
const provider = new k8s.Provider("k8s-provider", {
kubeconfig: config.requireSecret("kubeconfig"),
});
// Get services to deploy from config
const services = config.requireObject<string[]>("services");
// Deploy each service
services.forEach(service => {
// Use shared configuration logic
const serviceConfig = getServiceSettings(service, environment);
// Create Kubernetes deployment
const deployment = new k8s.apps.v1.Deployment(`${service}-deployment`, {
metadata: {
name: `${service}-${environment}`,
labels: {
app: service,
environment: environment,
},
},
spec: {
replicas: serviceConfig.replicas,
selector: {
matchLabels: {
app: service,
environment: environment,
},
},
template: {
metadata: {
labels: {
app: service,
environment: environment,
},
},
spec: {
containers: [{
name: service,
image: `${config.require("registry")}/${service}:${config.require("version")}`,
ports: [{
containerPort: serviceConfig.port,
}],
resources: {
limits: {
cpu: serviceConfig.cpuLimit,
memory: serviceConfig.memoryLimit,
},
},
env: [
{ name: "SERVICE_NAME", value: service },
{ name: "ENVIRONMENT", value: environment },
{ name: "PORT", value: serviceConfig.port.toString() },
],
}],
},
},
},
}, { provider });
// Create service
const k8sService = new k8s.core.v1.Service(`${service}-service`, {
metadata: {
name: `${service}-${environment}`,
labels: {
app: service,
environment: environment,
},
},
spec: {
type: "ClusterIP",
ports: [{
port: serviceConfig.port,
targetPort: serviceConfig.port,
protocol: "TCP",
}],
selector: {
app: service,
environment: environment,
},
},
}, { provider });
});
This example demonstrates how Pulumi enables infrastructure code to share logic with application code through common modules, ensuring consistency between application configurations and infrastructure deployments.
Many organizations use hybrid approaches or migrate incrementally between tools:
typescriptimport * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as terraform from "@pulumi/terraform";
// Import existing Terraform state
const existingInfrastructure = new terraform.state.RemoteStateReference("existing", {
backendType: "s3",
bucket: "terraform-state-bucket",
key: "terraform/state/key",
region: "us-east-1",
});
// Reference existing resources
const existingVpc = existingInfrastructure.getOutput({
type: "aws:ec2/vpc:Vpc",
name: "main",
});
const existingSubnets = [
existingInfrastructure.getOutput({
type: "aws:ec2/subnet:Subnet",
name: "private_1",
}),
existingInfrastructure.getOutput({
type: "aws:ec2/subnet:Subnet",
name: "private_2",
}),
];
// Create new resources in Pulumi that reference the existing ones
const securityGroup = new aws.ec2.SecurityGroup("app-sg", {
vpcId: existingVpc.apply(vpc => vpc.id),
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"],
}],
tags: {
Name: "app-sg",
ManagedBy: "Pulumi",
},
});
// Create new application using existing infrastructure
const loadBalancer = new aws.lb.LoadBalancer("app-lb", {
internal: false,
loadBalancerType: "application",
securityGroups: [securityGroup.id],
subnets: existingSubnets.map(subnet => subnet.apply(s => s.id)),
tags: {
Name: "app-lb",
ManagedBy: "Pulumi",
},
});
// Export outputs
export const lbDns = loadBalancer.dnsName;
export const vpcId = existingVpc.apply(vpc => vpc.id);
This example demonstrates how organizations can adopt Pulumi incrementally by importing and referencing existing Terraform-managed infrastructure.
To help you decide between Terraform and Pulumi, consider this decision framework:
- Team Composition and Skills
- Multi-disciplinary teams with varied technical backgrounds → Terraform
- Development-focused teams with programming expertise → Pulumi
- Operations teams without programming experience → Terraform
- Infrastructure Complexity
- Standard infrastructure patterns → Terraform or Pulumi
- Complex, dynamic infrastructure → Pulumi
- Multi-cloud environments → Terraform (mature ecosystem) or Pulumi (programming power)
- Organizational Factors
- Existing investment in Terraform → Consider staying with Terraform or hybrid approach
- Strong software development culture → Pulumi
- Strong emphasis on testing and quality → Pulumi
- Need for accessibility across different teams → Terraform
- Project Requirements
- Need for advanced logic and programming constructs → Pulumi
- Integration with existing codebases → Pulumi
- Focus on readability and simplicity → Terraform
- Requirement for mature provider ecosystem → Terraform
Both Terraform and Pulumi offer powerful approaches to infrastructure as code, each with distinct advantages:
- Terraform provides a mature, widely-adopted solution with a focused domain-specific language, extensive provider ecosystem, and lower barrier to entry for teams with varied technical backgrounds. Its declarative HCL syntax makes infrastructure definitions clear and accessible, particularly for standard deployment patterns and multi-cloud environments.
- Pulumi brings the full power of general-purpose programming languages to infrastructure code, enabling complex logic, abstraction, code reuse, and proper testing. It excels for development-focused teams and scenarios requiring sophisticated infrastructure with dynamic components.
The “right” choice depends on your specific team composition, infrastructure complexity, and organizational requirements. Many teams find success with either tool, while others adopt hybrid approaches that leverage the strengths of both.
Regardless of which tool you choose, embracing infrastructure as code represents a significant step toward more reliable, consistent, and automated cloud infrastructure management. Both Terraform and Pulumi provide solid foundations for modern infrastructure practices that will serve your organization well as your cloud journey evolves.
Keywords: Terraform, Pulumi, Infrastructure as Code, IaC, HCL, programming languages, cloud automation, AWS, Azure, Google Cloud, multi-cloud, DevOps, Python, TypeScript, Go, infrastructure automation, cloud resources
#Terraform #Pulumi #InfrastructureAsCode #IaC #CloudAutomation #DevOps #MultiCloud #AWS #Azure #GoogleCloud #Python #TypeScript #Go #CloudInfrastructure #InfrastructureAutomation