Pulumi with C#: Infrastructure as Real Code
Define and deploy cloud infrastructure using C# with Pulumi. Covers stacks, resources, outputs, component resources, Azure/AWS providers, secrets, CI/CD integration, and Pulumi vs Terraform.
Pulumi vs Terraform
Both define infrastructure as code. The key difference: Pulumi uses real programming languages — C#, Python, TypeScript, Go. Terraform uses HCL (a declarative DSL).
| | Pulumi | Terraform |
|---|---|---|
| Language | C#, Python, TS, Go | HCL |
| Logic | Real code (loops, conditionals, classes) | Limited expressions |
| State | Pulumi Cloud or S3/Azure Blob | Local or remote backend |
| Ecosystem | Growing | Mature (10,000+ providers) |
| Learning curve | Low for .NET devs | Medium (new DSL) |
| Testing | Unit test with xUnit | terraform validate only |
Choose Pulumi when: your team is .NET/Python/TS and wants real code abstractions, reusable component libraries, and proper unit testing of infrastructure.
Setup
# Install Pulumi CLI
winget install pulumi
# Login (uses Pulumi Cloud by default, or self-hosted)
pulumi login
# Create a new project
mkdir OrderFlow.Infrastructure && cd OrderFlow.Infrastructure
pulumi new azure-csharpProject structure:
OrderFlow.Infrastructure/
├── Program.cs ← stack entrypoint
├── Pulumi.yaml ← project metadata
├── Pulumi.dev.yaml ← dev stack config
└── Pulumi.prod.yaml ← prod stack configBasic Stack
// Program.cs
using Pulumi;
using Pulumi.AzureNative.Resources;
using Pulumi.AzureNative.Web;
using Pulumi.AzureNative.Sql;
return await Deployment.RunAsync(() =>
{
var config = new Config();
var env = config.Require("environment"); // from Pulumi.dev.yaml
// Resource Group
var rg = new ResourceGroup("orderflow-rg", new ResourceGroupArgs
{
ResourceGroupName = $"orderflow-{env}",
Location = "uksouth"
});
// SQL Server
var sql = new Server("orderflow-sql", new ServerArgs
{
ResourceGroupName = rg.Name,
Location = rg.Location,
ServerName = $"orderflow-sql-{env}",
AdministratorLogin = "sqladmin",
AdministratorLoginPassword = config.RequireSecret("sqlPassword"),
Version = "12.0"
});
// SQL Database
var db = new Database("orders-db", new DatabaseArgs
{
ResourceGroupName = rg.Name,
ServerName = sql.Name,
DatabaseName = "orders",
Sku = new SkuArgs { Name = "GP_S_Gen5", Tier = "GeneralPurpose", Capacity = 2 }
});
// Export outputs
return new Dictionary<string, object?>
{
["sqlServerName"] = sql.Name,
["sqlFqdn"] = sql.FullyQualifiedDomainName
};
});Component Resources (Reusable Modules)
Create reusable infrastructure modules as C# classes:
// Components/ContainerAppComponent.cs
public class ContainerAppArgs
{
public required Input<string> EnvironmentId { get; init; }
public required Input<string> ResourceGroupName { get; init; }
public required Input<string> Image { get; init; }
public required Input<string> Location { get; init; }
public int MinReplicas { get; init; } = 1;
public int MaxReplicas { get; init; } = 10;
public Dictionary<string, Input<string>> EnvVars { get; init; } = new();
}
public class ContainerAppComponent : ComponentResource
{
public Output<string> Url { get; private set; } = null!;
public Output<string> Name { get; private set; } = null!;
public ContainerAppComponent(
string name,
ContainerAppArgs args,
ComponentResourceOptions? opts = null)
: base("orderflow:index:ContainerApp", name, opts)
{
var app = new ContainerApp(name, new ContainerAppArgs
{
ResourceGroupName = args.ResourceGroupName,
ManagedEnvironmentId = args.EnvironmentId,
Location = args.Location,
Configuration = new ConfigurationArgs
{
Ingress = new IngressArgs
{
External = true,
TargetPort = 8080
}
},
Template = new TemplateArgs
{
Containers = new ContainerArgs
{
Name = name,
Image = args.Image,
Resources = new ContainerResourcesArgs
{
Cpu = 0.5,
Memory = "1Gi"
},
Env = args.EnvVars.Select(kv =>
new EnvironmentVarArgs { Name = kv.Key, Value = kv.Value }).ToList()
},
Scale = new ScaleArgs
{
MinReplicas = args.MinReplicas,
MaxReplicas = args.MaxReplicas
}
}
}, new CustomResourceOptions { Parent = this });
Url = app.LatestRevisionFqdn.Apply(fqdn => $"https://{fqdn}");
Name = app.Name;
RegisterOutputs(new Dictionary<string, object?>
{
["url"] = Url,
["name"] = Name
});
}
}Use it:
var ordersApi = new ContainerAppComponent("orders-api", new ContainerAppArgs
{
EnvironmentId = env.Id,
ResourceGroupName = rg.Name,
Location = rg.Location,
Image = $"{acr.LoginServer}/orders-api:{imageTag}",
MaxReplicas = 20,
EnvVars = new()
{
["ASPNETCORE_ENVIRONMENT"] = "Production",
["ConnectionStrings__Default"] = dbConnectionString
}
});Secrets
// Encrypt secrets in stack config
// pulumi config set --secret sqlPassword MyP@ssw0rd!
var sqlPassword = config.RequireSecret("sqlPassword");
// Use in resource — stays encrypted in state
var sql = new Server("sql", new ServerArgs
{
AdministratorLoginPassword = sqlPassword
});Stacks
Stacks = environments. Each has its own config and state.
pulumi stack init dev
pulumi stack init prod
# Switch stacks
pulumi stack select prod
# Stack config
pulumi config set environment prod
pulumi config set --secret sqlPassword ProdStr0ngP@ss!# Pulumi.prod.yaml
config:
azure-native:location: uksouth
orderflow:environment: prod
orderflow:sqlPassword:
secure: AAABAGq5... # encryptedUnit Testing
// Infrastructure.Tests/ContainerAppTests.cs
public class ContainerAppTests
{
[Fact]
public async Task ContainerApp_HasCorrectMinReplicas()
{
var resources = await Testing.RunAsync<ContainerAppStack>();
var app = resources.OfType<ContainerApp>().FirstOrDefault();
Assert.NotNull(app);
var minReplicas = await app.Template.Apply(t => t?.Scale?.MinReplicas);
Assert.Equal(1, minReplicas);
}
}
// Test stack
class ContainerAppStack : Stack
{
public ContainerAppStack()
{
new ContainerAppComponent("test-app", new ContainerAppArgs
{
EnvironmentId = Output.Create("/subscriptions/.../env"),
ResourceGroupName = Output.Create("test-rg"),
Location = Output.Create("uksouth"),
Image = Output.Create("test-image:latest"),
});
}
}CI/CD with GitHub Actions
- name: Deploy infrastructure
uses: pulumi/actions@v5
with:
command: up
stack-name: prod
work-dir: ./Infrastructure
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}Interview Questions
Q: What is the main advantage of Pulumi over Terraform for .NET teams? Real programming language — C# with full IDE support, loops, conditionals, classes, and xUnit testing. Reusable infrastructure modules are C# classes, not Terraform modules with limited HCL. Team members already know C#; no new DSL to learn.
Q: What is a Pulumi stack? A named deployment of a Pulumi program — equivalent to an environment (dev, staging, prod). Each stack has its own config and state. Switching stacks changes which environment you're targeting and which config values are active.
Q: How do you handle secrets in Pulumi?
pulumi config set --secret key value encrypts the value using Pulumi's secret management (KMS or passphrase). The encrypted value is stored in the stack config file and can be committed safely. In code, config.RequireSecret("key") returns an Output<string> — Pulumi ensures it's never logged or exposed in plain text.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.