Pulumi: Infrastructure as Real Code
I've been writing Terraform since 2016 and I still think it's a good tool. But there are problems I keep running into — complex conditionals, loops over resources with dynamic keys, generating configs programmatically — where HCL fights back. Every time I reach for count as a loop or use a null_resource to do something Terraform isn't really designed for, I think "I wish I could just write this in a real language."
Pulumi is built on exactly that premise. I've been experimenting with it for the last few weeks.
What Pulumi Is
Pulumi lets you define infrastructure using actual programming languages — Go, Python, TypeScript, JavaScript. Same concepts as Terraform: resources have properties, resources have dependencies, there's a state model, there's a plan-and-apply workflow. But instead of writing HCL, you write code.
This sounds like it should have existed years ago. The reason it didn't is that bridging a general-purpose language to a declarative infrastructure model is harder than it looks. Pulumi does it through a runtime that intercepts resource creation calls and builds a dependency graph, then sends that graph to a cloud provider. The language is the authoring surface; the actual execution is still declarative under the hood.
A Real Example: S3 + CloudFront in Go
Let me show a concrete example. Here's a static website setup — S3 bucket for assets, CloudFront distribution in front of it.
First, initialize a new Pulumi project:
mkdir my-infra && cd my-infra
pulumi new aws-go
This scaffolds a Go project with a Pulumi.yaml, a main.go, and a go.mod. The Pulumi.yaml defines the project name and runtime.
main.go:
package main
import (
"github.com/pulumi/pulumi-aws/sdk/go/aws/cloudfront"
"github.com/pulumi/pulumi-aws/sdk/go/aws/s3"
"github.com/pulumi/pulumi/sdk/go/pulumi"
"github.com/pulumi/pulumi/sdk/go/pulumi/config"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
cfg := config.New(ctx, "")
env := cfg.Require("env") // "staging" or "prod"
// S3 bucket for static assets
bucket, err := s3.NewBucket(ctx, "site-assets", &s3.BucketArgs{
Bucket: pulumi.String("myapp-" + env + "-assets"),
Acl: pulumi.String("private"),
Tags: pulumi.StringMap{
"Environment": pulumi.String(env),
"ManagedBy": pulumi.String("pulumi"),
},
})
if err != nil {
return err
}
// Origin access identity so CloudFront can read the private bucket
oai, err := cloudfront.NewOriginAccessIdentity(ctx, "site-oai", &cloudfront.OriginAccessIdentityArgs{
Comment: pulumi.String("OAI for " + env + " site assets"),
})
if err != nil {
return err
}
// CloudFront distribution
distribution, err := cloudfront.NewDistribution(ctx, "site-cdn", &cloudfront.DistributionArgs{
Enabled: pulumi.Bool(true),
DefaultRootObject: pulumi.String("index.html"),
Origins: cloudfront.DistributionOriginArray{
&cloudfront.DistributionOriginArgs{
DomainName: bucket.BucketRegionalDomainName,
OriginId: pulumi.String("s3-origin"),
S3OriginConfig: &cloudfront.DistributionOriginS3OriginConfigArgs{
OriginAccessIdentity: oai.CloudfrontAccessIdentityPath,
},
},
},
DefaultCacheBehavior: &cloudfront.DistributionDefaultCacheBehaviorArgs{
AllowedMethods: pulumi.StringArray{pulumi.String("GET"), pulumi.String("HEAD")},
CachedMethods: pulumi.StringArray{pulumi.String("GET"), pulumi.String("HEAD")},
TargetOriginId: pulumi.String("s3-origin"),
ViewerProtocolPolicy: pulumi.String("redirect-to-https"),
ForwardedValues: &cloudfront.DistributionDefaultCacheBehaviorForwardedValuesArgs{
QueryString: pulumi.Bool(false),
Cookies: &cloudfront.DistributionDefaultCacheBehaviorForwardedValuesCookiesArgs{
Forward: pulumi.String("none"),
},
},
},
Restrictions: &cloudfront.DistributionRestrictionsArgs{
GeoRestriction: &cloudfront.DistributionRestrictionsGeoRestrictionArgs{
RestrictionType: pulumi.String("none"),
},
},
ViewerCertificate: &cloudfront.DistributionViewerCertificateArgs{
CloudfrontDefaultCertificate: pulumi.Bool(true),
},
})
if err != nil {
return err
}
ctx.Export("bucketName", bucket.ID())
ctx.Export("cdnDomain", distribution.DomainName)
return nil
})
}
This is real Go. I can write a helper function, import a library, use conditionals, use loops. If I need to create 10 S3 buckets with slightly different configs, I write a loop. If I need a bucket only in production, I write an if statement. No HCL workarounds.
The CLI Workflow
Pulumi's CLI follows the same preview-then-apply model as Terraform:
# See what will change without changing anything
pulumi preview
# Apply the changes
pulumi up
# Tear everything down
pulumi destroy
pulumi preview output looks familiar if you've used Terraform:
Previewing update (dev):
Type Name Plan
+ pulumi:pulumi:Stack my-infra-dev create
+ ├─ aws:s3:Bucket site-assets create
+ ├─ aws:cloudfront:OriginAccessIdentity site-oai create
+ └─ aws:cloudfront:Distribution site-cdn create
Resources:
+ 4 to create
pulumi up prompts for confirmation before making changes. pulumi destroy shows you what will be deleted and asks you to confirm. You can pass --yes to skip interactive confirmation in CI.
State Management
Like Terraform, Pulumi needs to store state. You have two options.
The default is Pulumi's hosted backend at app.pulumi.com. It's free for individuals, has a web UI for viewing stacks and resources, and handles state locking automatically. If you're already comfortable trusting a SaaS with your Terraform state, this is fine. If you're not, or if you're in an organization with compliance requirements, use the self-managed option.
For self-managed state, you can use S3:
pulumi login s3://my-pulumi-state-bucket
Same deal as Terraform's S3 backend — you're responsible for bucket replication and locking (Pulumi uses DynamoDB for the latter, same as Terraform). The login command persists the backend config locally. In CI, you'd set PULUMI_BACKEND_URL=s3://my-pulumi-state-bucket.
I prefer S3-backed state for anything that matters. I don't want my infrastructure state in a third-party SaaS if I can avoid it.
Pulumi vs. Terraform
This is the real question. Here's how I think about it.
Pulumi wins when:
You need real loops and conditionals. Terraform's count and for_each (0.12+) are workable but awkward for complex cases. Pulumi's loops are just Go/Python loops.
You want to generate infrastructure configurations programmatically. Need to create the same stack in 20 regions with minor variations? Write a function and call it 20 times.
You want type safety. If you're using TypeScript or Go, your IDE can catch type errors in resource arguments at authoring time, not at apply time.
You prefer a full programming model. Functions, libraries, packages, tests. You can write unit tests for your infrastructure code.
Terraform wins when:
You have existing HCL investment. Rewriting working Terraform in Pulumi has a cost. If it ain't broke, the bar for switching is high.
Your team prefers declarative. Some engineers find HCL easier to read and reason about than a program. "What does this infrastructure look like?" is sometimes easier to answer from HCL than from code that builds the infrastructure.
You need provider breadth today. Terraform has more providers and more mature provider implementations right now. Pulumi is catching up, but the coverage gaps are real for less common providers.
Your organization has invested in tooling around HCL. Code review workflows, linters, policy-as-code tools (Sentinel, OPA for TF). That ecosystem is more mature.
Who Should Try Pulumi
If you're writing infrastructure code in a team that's comfortable with Go, Python, or TypeScript, Pulumi is worth a serious look. The productivity gain from having real loops, conditionals, and functions is real. Being able to write a helper library that your whole team shares — actual importable code — is better than copy-pasting HCL between repos.
If you're starting a greenfield project and don't have existing Terraform state to worry about, I'd try Pulumi. The tradeoffs are manageable and the authoring experience is genuinely better for non-trivial infrastructure.
If you're running Terraform in production and it's working, wait. Pulumi is early (they launched publicly earlier this year) and the ecosystem is still maturing. The state management and CLI are solid, but I've hit provider bugs and missing resource arguments that would have been a blocker in a production context. Give it another year.
The core idea is right. Infrastructure that can be expressed with all the tools of a real programming language is better than infrastructure expressed in a DSL that keeps growing special-case features every time someone needs a conditional. Pulumi is betting that the industry will agree. I think they're right.
