
Learn Infrastructure as Code with C# using Pulumi. Step-by-step .NET tutorial with runnable examples, best practices, and pitfalls. Start deploying today.
Infrastructure as Code (IaC) lets you define cloud resources—virtual machines, storage, databases, Kubernetes clusters—in files you can version, review, and test like any other software. For years, .NET teams had to leave C# behind to do this, writing Terraform HCL or ARM/Bicep JSON. Pulumi changes that: it lets you write infrastructure as code in C# using the same language, IDE, NuGet packages, and testing tools you already use for your applications. In this tutorial you'll learn how Pulumi works, deploy real Azure and AWS resources from a .NET project, and pick up the best practices and pitfalls that matter in production.
What Is Infrastructure as Code, and Why C#?
Infrastructure as Code means your cloud environment is described declaratively in source files, and a tool reconciles the real world with that description. The benefits are the same ones that make source control essential for application code: repeatability, code review, rollback, and automation through CI/CD.
Most infrastructure as code tools use a domain-specific language. Terraform uses HCL, Azure uses Bicep, AWS uses CloudFormation YAML. These DSLs are fine for simple stacks, but they get awkward fast: loops, conditionals, and abstractions are second-class citizens, and you can't unit test them with xUnit.
Pulumi takes a different approach. It is a general-purpose IaC engine that supports C#, F#, TypeScript, Python, Go, and Java. With Pulumi for .NET you get:
- Real language features — classes, LINQ, generics, async, and NuGet packages.
- IntelliSense and compile-time checking — misspelled property names fail at build time, not deploy time.
- Unit testing — test your infrastructure with xUnit or NUnit before it touches a cloud account.
- Multi-cloud providers — Azure, AWS, Google Cloud, Kubernetes, Cloudflare, and hundreds more from one program.
How Pulumi Works Under the Hood
Understanding the execution model prevents most beginner confusion. A Pulumi program is a normal .NET console app. When you run pulumi up, the CLI:
- Runs your C# program. Each resource constructor you call (e.g.
new Bucket(...)) registers a desired resource with the Pulumi engine. - Compares the desired state against the last known state stored in a state file (Pulumi Cloud, Azure Blob, S3, or a local file).
- Computes a plan: create, update, replace, or delete.
- Shows you a preview and, after confirmation, calls the cloud provider APIs.
The key mental shift: your C# code does not execute deployments—it declares them. Resource properties like an IP address or a connection string are not known until the cloud returns them, which is why Pulumi wraps them in Output<T>. We'll cover that shortly, because it's the single biggest source of confusion for .NET developers.
Setting Up Pulumi for .NET
You need the .NET SDK (8 or later), the Pulumi CLI, and credentials for a cloud provider. Install the CLI:
# Windows
winget install pulumi
# macOS
brew install pulumi/tap/pulumi
# Linux
curl -fsSL https://get.pulumi.com | sh
Then create a new project. Pulumi ships C# templates for every major cloud:
mkdir my-infra && cd my-infra
pulumi new azure-csharp # or aws-csharp, gcp-csharp, kubernetes-csharp
The template generates a Pulumi.yaml (project metadata), a Pulumi.dev.yaml (stack configuration), a .csproj, and a Program.cs. A stack is an isolated instance of your infrastructure—typically dev, staging, and prod—sharing the same code but with different configuration.
Your First Infrastructure as Code Program in C#
Here is a complete, runnable program that creates an Azure resource group, a storage account, and a static website, then exports the site URL. It uses the modern Pulumi.AzureNative provider, which is generated directly from the Azure Resource Manager API.
using Pulumi;
using Pulumi.AzureNative.Resources;
using Pulumi.AzureNative.Storage;
using Pulumi.AzureNative.Storage.Inputs;
return await Deployment.RunAsync(() =>
{
var resourceGroup = new ResourceGroup("web-rg");
var storageAccount = new StorageAccount("websa", new StorageAccountArgs
{
ResourceGroupName = resourceGroup.Name,
Sku = new SkuArgs { Name = SkuName.Standard_LRS },
Kind = Kind.StorageV2,
AllowBlobPublicAccess = true
});
var staticWebsite = new StorageAccountStaticWebsite("website", new StorageAccountStaticWebsiteArgs
{
AccountName = storageAccount.Name,
ResourceGroupName = resourceGroup.Name,
IndexDocument = "index.html",
Error404Document = "404.html"
});
// Upload an index page into the $web container
var indexHtml = new Blob("index.html", new BlobArgs
{
ResourceGroupName = resourceGroup.Name,
AccountName = storageAccount.Name,
ContainerName = staticWebsite.ContainerName,
Source = new FileAsset("./wwwroot/index.html"),
ContentType = "text/html"
});
// Stack outputs are visible via `pulumi stack output`
return new Dictionary<string, object?>
{
["endpoint"] = storageAccount.PrimaryEndpoints.Apply(e => e.Web)
};
});
Deploy it:
pulumi config set azure-native:location eastus
pulumi up
Notice several things a Terraform user will find familiar and a C# user will find new. Resource names like "web-rg" are logical names; Pulumi appends a random suffix to the physical cloud name (e.g. web-rg8f3a2c1) so two stacks never collide. And resourceGroup.Name is not a string—it's an Output<string>, because the actual name doesn't exist until Azure creates it.
Understanding Output<T> — The Concept Every .NET Developer Must Learn
Output<T> is Pulumi's version of a promise that also tracks dependencies. When you pass storageAccount.Name into the Blob, Pulumi knows the blob depends on the storage account and orders the deployment accordingly. You can't await an Output or read its value directly; instead, you transform it with Apply, much like Select in LINQ:
// Transform a single output
Output<string> url = storageAccount.PrimaryEndpoints.Apply(e => e.Web);
// Combine several outputs into one
Output<string> connectionString = Output.Tuple(resourceGroup.Name, storageAccount.Name)
.Apply(t => $"Endpoint=https://{t.Item2}.blob.core.windows.net;ResourceGroup={t.Item1}");
// Interpolate directly — the cleanest way
Output<string> endpoint = Output.Format($"https://{storageAccount.Name}.blob.core.windows.net");
Why this matters: a common mistake is trying to force a value out with .GetValueAsync() or by blocking on a task. That breaks previews (values aren't known yet) and hides dependencies from the engine. Keep everything inside Apply and let Pulumi resolve it at deploy time.
Configuration and Secrets
Hard-coding values makes stacks non-reusable. Pulumi's Config class reads per-stack values from Pulumi.<stack>.yaml, and secrets are encrypted at rest:
pulumi config set instanceSize Standard_B2s
pulumi config set --secret dbPassword 'S3cure!Pass'
var config = new Config();
var instanceSize = config.Get("instanceSize") ?? "Standard_B1s";
Output<string> dbPassword = config.RequireSecret("dbPassword");
// Any Output derived from a secret stays secret in state and logs
var server = new Pulumi.AzureNative.DBforPostgreSQL.Server("pg", new()
{
ResourceGroupName = resourceGroup.Name,
AdministratorLogin = "pgadmin",
AdministratorLoginPassword = dbPassword,
Sku = new Pulumi.AzureNative.DBforPostgreSQL.Inputs.SkuArgs { Name = "Standard_B1ms", Tier = "Burstable" },
Version = "16"
});
Building Reusable Infrastructure with Component Resources
This is where C# beats DSLs decisively. A ComponentResource is a class that groups multiple resources behind a clean API—the infrastructure equivalent of a well-designed service class. Here's an AWS example that bundles an S3 bucket with a CloudFront distribution, a pattern you'd otherwise copy-paste across every project:
using Pulumi;
using Pulumi.Aws.S3;
using Pulumi.Aws.CloudFront;
using Pulumi.Aws.CloudFront.Inputs;
public class StaticSiteArgs : ResourceArgs
{
public Input<string> IndexDocument { get; set; } = "index.html";
}
public class StaticSite : ComponentResource
{
[Output] public Output<string> BucketName { get; private set; }
[Output] public Output<string> CdnDomain { get; private set; }
public StaticSite(string name, StaticSiteArgs args, ComponentResourceOptions? options = null)
: base("csharpcoder:web:StaticSite", name, args, options)
{
// Children must set Parent = this so they show up nested in the preview
var childOpts = new CustomResourceOptions { Parent = this };
var bucket = new BucketV2($"{name}-bucket", new BucketV2Args(), childOpts);
var website = new BucketWebsiteConfigurationV2($"{name}-website", new()
{
Bucket = bucket.Id,
IndexDocument = new BucketWebsiteConfigurationV2IndexDocumentArgs { Suffix = args.IndexDocument }
}, childOpts);
var cdn = new Distribution($"{name}-cdn", new DistributionArgs
{
Enabled = true,
DefaultRootObject = args.IndexDocument,
Origins = new[]
{
new DistributionOriginArgs
{
OriginId = bucket.Arn,
DomainName = website.WebsiteEndpoint,
CustomOriginConfig = new DistributionOriginCustomOriginConfigArgs
{
HttpPort = 80, HttpsPort = 443,
OriginProtocolPolicy = "http-only",
OriginSslProtocols = new[] { "TLSv1.2" }
}
}
},
DefaultCacheBehavior = new DistributionDefaultCacheBehaviorArgs
{
TargetOriginId = bucket.Arn,
ViewerProtocolPolicy = "redirect-to-https",
AllowedMethods = new[] { "GET", "HEAD" },
CachedMethods = new[] { "GET", "HEAD" },
ForwardedValues = new DistributionDefaultCacheBehaviorForwardedValuesArgs
{
QueryString = false,
Cookies = new DistributionDefaultCacheBehaviorForwardedValuesCookiesArgs { Forward = "none" }
}
},
Restrictions = new DistributionRestrictionsArgs
{
GeoRestriction = new DistributionRestrictionsGeoRestrictionArgs { RestrictionType = "none" }
},
ViewerCertificate = new DistributionViewerCertificateArgs { CloudfrontDefaultCertificate = true }
}, childOpts);
BucketName = bucket.Bucket;
CdnDomain = cdn.DomainName;
RegisterOutputs();
}
}
Consuming it is one line, and you can publish the class as a NuGet package for your whole organization:
var site = new StaticSite("marketing");
return new Dictionary<string, object?> { ["cdn"] = site.CdnDomain };
Unit Testing Your Infrastructure as Code
Because it's plain C#, you can test infrastructure with xUnit using Pulumi's mocking API. No cloud credentials required—tests run in milliseconds in CI:
using Pulumi;
using Pulumi.Testing;
using Pulumi.AzureNative.Storage;
using Xunit;
class Mocks : IMocks
{
public Task<(string? id, object state)> NewResourceAsync(MockResourceArgs args)
=> Task.FromResult<(string?, object)>((args.Name + "-id", args.Inputs));
public Task<object> CallAsync(MockCallArgs args)
=> Task.FromResult<object>(new Dictionary<string, object>());
}
public class StackTests
{
[Fact]
public async Task StorageAccount_MustNotAllowPublicBlobAccess()
{
var resources = await Deployment.TestAsync<MyStack>(new Mocks(),
new TestOptions { IsPreview = false });
var accounts = resources.OfType<StorageAccount>().ToList();
Assert.NotEmpty(accounts);
foreach (var account in accounts)
{
var publicAccess = await account.AllowBlobPublicAccess.GetValueAsync();
Assert.False(publicAccess, "Storage accounts must not allow public blob access");
}
}
}
This example uses the class-based Stack style (MyStack : Stack), which is required for Deployment.TestAsync. Policy checks like "no public storage" or "all resources tagged" become failing tests instead of production incidents.
Pulumi vs Terraform: Which Should .NET Teams Choose?
Developers searching for "pulumi vs terraform" usually want a straight answer. Both are mature, multi-cloud, state-based tools. The differences that matter:
- Language: Terraform uses HCL; Pulumi uses C# (or other real languages). If your team is .NET-first, Pulumi removes a context switch and unlocks unit testing, IDE refactoring, and NuGet sharing.
- Provider ecosystem: Pulumi can bridge nearly every Terraform provider, so coverage is effectively equal.
- State management: both support self-managed backends (Azure Blob, S3) and hosted services (Pulumi Cloud, HCP Terraform).
- Licensing: Pulumi's engine is Apache 2.0; Terraform moved to the BSL license in 2023, which pushed some teams toward OpenTofu or Pulumi.
If you already have thousands of lines of working HCL, keep it. If you're starting fresh as a C# shop, Pulumi is the more natural fit.
Best Practices for Pulumi in .NET
- One stack per environment, one project per system. Don't create one giant program for your entire company; split by bounded context and use
StackReferenceto share outputs (like a VNet ID) between projects. - Never hard-code physical names. Let Pulumi auto-name resources so stacks can coexist and replacements don't collide. Only pin names where the cloud demands it (DNS zones, globally unique storage accounts).
- Use
Protect = trueon databases and storage. This option refuses to delete the resource even if it's removed from code—cheap insurance against a bad refactor. - Keep secrets in
RequireSecret. Anything derived from a secret Output stays encrypted in state and masked in logs. - Run
pulumi previewin pull requests. The plan is your infrastructure diff; reviewers should see it just like code changes. The official GitHub Action posts the preview as a PR comment. - Enable nullable reference types and treat warnings as errors. The compiler catches missing required properties before you waste a 10-minute deploy.
Common Pitfalls and How to Avoid Them
- Blocking on Outputs. Calling
.GetValueAsync().Resultdeadlocks or returns unknown values during preview. UseApply,Output.Tuple, orOutput.Format. - Forgetting
Parent = thisin components. Without it, child resources aren't linked to the component, and deleting the component leaves orphans. - Renaming a resource's logical name. Pulumi sees a delete + create, not a rename. Use the
Aliasesoption to preserve the existing resource when refactoring. - Mixing
Pulumi.Azure(classic) andPulumi.AzureNative. They are separate providers with different types. New projects should use AzureNative for full API coverage. - Running
pulumi upfrom laptops in production. Drift and "works on my machine" state corruption follow. Deploy from CI with a locked-down service principal. - Local file state for team projects. The default local backend has no locking. Use Pulumi Cloud or a blob backend with locking for anything shared.
Conclusion: Key Takeaways
Infrastructure as Code with C# is no longer a compromise. Pulumi gives .NET developers a first-class way to define cloud infrastructure with the language, tools, and testing practices they already trust. To recap:
- Pulumi programs are normal .NET console apps that declare resources; the engine computes and applies the diff.
Output<T>is the core abstraction—transform values withApplyand never block on them.ComponentResourcelets you package infrastructure patterns as reusable, NuGet-shippable classes.- Unit tests with
Deployment.TestAsynccatch security and configuration mistakes before deployment. - Use stacks per environment, protect stateful resources, keep secrets encrypted, and deploy from CI.
The fastest way to learn is to run pulumi new azure-csharp (or aws-csharp), deploy the example above, and then refactor it into a component. Within an afternoon you'll have a repeatable, reviewable, testable infrastructure as code pipeline written entirely in C#.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment