Skip to main content

Infrastructure as Code with C#: Pulumi .NET Tutorial

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:

  1. Runs your C# program. Each resource constructor you call (e.g. new Bucket(...)) registers a desired resource with the Pulumi engine.
  2. Compares the desired state against the last known state stored in a state file (Pulumi Cloud, Azure Blob, S3, or a local file).
  3. Computes a plan: create, update, replace, or delete.
  4. 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 StackReference to 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 = true on 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 preview in 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().Result deadlocks or returns unknown values during preview. Use Apply, Output.Tuple, or Output.Format.
  • Forgetting Parent = this in 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 Aliases option to preserve the existing resource when refactoring.
  • Mixing Pulumi.Azure (classic) and Pulumi.AzureNative. They are separate providers with different types. New projects should use AzureNative for full API coverage.
  • Running pulumi up from 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 with Apply and never block on them.
  • ComponentResource lets you package infrastructure patterns as reusable, NuGet-shippable classes.
  • Unit tests with Deployment.TestAsync catch 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#.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...