---
title: "Strongly Typed Infrastructure with Pulumi and C#"
description: "How we use Pulumi and shared C# projects to keep generated configuration, constants and Cloudflare Worker bindings aligned with the app."
published: "2026-08-11"
author: "Jeffrey Needles"
canonical: "https://htmlcsstoimage.com/blog/strongly-typed-infrastructure-pulumi-csharp"
---


Infrastructure as code can feel like big-company overhead. When you are a small team and somebody can create a bucket in the AWS console in two minutes, building a project, stack structure and deployment pipeline around it is not obviously the better choice.

We are a small company, and we started by creating infrastructure in web consoles. That was fine for a while. Our first attempts to migrate pieces into infrastructure as code worked, but they were definitely not smooth. Moving our database setup to PlanetScale was one of the projects that made the problem obvious. We had settings in consoles, values copied into deployment variables and code that quietly depended on both.

By then the application also depended on the exact name of a DynamoDB index, generated endpoints, a growing collection of IAM roles and a secret assembled from several deployments. The console was no longer the simple option. We were spending too much time remembering how everything connected before we could safely change any of it.

HCTI now uses Pulumi with C# for most of that work. Being able to write infrastructure with loops, methods and normal IDE support is nice. The bigger advantage for us is that the Pulumi project lives beside the application and can reference the same C# projects.

The DynamoDB index created by Pulumi and the index queried by the application use the same constant. Redis endpoints produced during deployment become a typed object. The final secret document is checked against the same `SecretConfig` class the application loads at startup.

Not every value stays strongly typed all the way through. Pulumi outputs are asynchronous, Secrets Manager stores JSON and our Cloudflare Worker is written in TypeScript. There are real boundaries in the middle. We have tried to keep those boundaries small and validate them as early as we can.

## Project Setup

HCTI is mostly C#, but it is not all in one project. We split the solution based on where code can run and what it is allowed to reference.

Here is the simplified project layout:

```text
App.csproj
├── SharedClasses.csproj
│   ├── SharedClasses.Client.csproj
│   └── IacClasses.csproj
└── TemplateEditor.csproj
    └── SharedClasses.Client.csproj

Lambdas.csproj
└── SharedClasses.csproj
    ├── SharedClasses.Client.csproj
    └── IacClasses.csproj

Iac.csproj
├── IacClasses.csproj
└── IacHelpers.csproj
    └── IacClasses.csproj
```

`App` is the ASP.NET application. `SharedClasses` has the server-side services used by the API, background jobs and renderers. `Lambdas` references that same server code for the smaller AWS Lambda entry points.

`SharedClasses.Client` is the browser-safe set of models shared with `TemplateEditor`. The template editor project is what gets compiled into the Blazor WebAssembly application and downloaded by the browser. The [template editor architecture post](/blog/building-a-visual-template-editor-in-blazor-wasm) goes much deeper into that split.

The interesting project for this post is `IacClasses`. It contains normal C# types used on both sides of the infrastructure boundary: environment and stack enums, shared constants, bucket metadata, Redis endpoints, Lambda information and the application's secret model.

It does not contain Pulumi resources. That is important. The application can reference `IacClasses` without bringing the Pulumi SDK and a pile of cloud provider packages into the runtime.

`Iac` is the actual Pulumi program. It references `IacClasses`, creates the resources and fills in those shared models with real outputs. `IacHelpers` has the less interesting deployment and packaging work that we did not want mixed into every stack.

The client split follows the same idea. The template editor can use our template models, validation and rendering rules, but it should not download an AWS client or `SecretConfig` into the browser. The project graph prevents that accidentally.

With Pulumi, sharing these types is just a project reference. The infrastructure project and application compile against the same `IacClasses` assembly.

## Pulumi Stacks

Our Pulumi project has three environments:

```csharp
public enum IacEnvironment
{
    Shared,
    Staging,
    Production
}
```

`Shared` is for the pieces that do not belong to one application environment. `Staging` and `Production` are the two complete HCTI deployments.

Within those environments, the project is divided into six stack types:

| Stack | Environment | Depends on | What it owns |
| --- | --- | --- | --- |
| **Shared** | Shared | Nothing | VPCs, Tailscale, shared ECR, secret containers and CI foundations |
| **Shared CI Runners** | Shared | Shared | Image Builder, autoscaling instances and configuration for our self-hosted CI runners |
| **Core** | Staging / Production | Shared | PlanetScale, DynamoDB, S3, ECS, Redis configuration, roles and most environment resources |
| **Lambda** | Staging / Production | Core | Render functions, versions, aliases and execution roles |
| **Cloudflare Worker** | Staging / Production | Core | Worker build, versions, deployment, KV/R2 bindings and custom domain |
| **Infra-After** | Staging / Production | Shared, Core, Lambda and any other stack exporting secrets | Cross-stack role assignments, PlanetScale passwords and final application secrets |

This table is about references, not a command that deploys every stack in order. Once the environment exists, we update the stack that owns the thing being changed. Cross-stack relationships that would otherwise introduce a dependency in the wrong direction belong in Infra-After.

Every stack inherits from `StackBase`, which holds its `Env`, stack references and common output helpers. The Pulumi entry point gets both the environment and stack type from a name such as `staging-core` or `production-core`, then creates the same `CoreStack` class with the appropriate environment.

That keeps us from maintaining a staging version and production version of every resource. Most declarations are identical. When production needs more capacity or a different parameter, the difference is a small switch on `Env`:

```csharp
RetentionInDays = Env switch
{
    IacEnvironment.Staging => 7,
    IacEnvironment.Production => 60,
    _ => throw new UnreachableException()
};
```

We use the same pattern for ECS sizing, Lambda concurrency, Redis limits, domains and the few features that should only exist in staging. The resource code stays in one place, so fixing a policy or adding a tag changes both environments.

### Orchestrating Stack Startup

When we run `pulumi up`, execution begins in the IaC project's `Program.cs`. To make the dynamic stack routing work, we parse the stack name into an environment and stack type. `staging-core`, for example, becomes `IacEnvironment.Staging` and `StackType.Core`.

From there, startup is just a switch:

```csharp
// Shortened for readability.

return await Deployment.RunAsync(async () =>
{
    var stackName = Deployment.Instance.StackName;
    var (env, stackType) = ParseStackName(stackName);

    StackBase stack = stackType switch
    {
        StackType.Shared           => new SharedStack(),
        StackType.SharedCiRunners  => new SharedCiRunnerStack(),
        StackType.Core             => new CoreStack(env),
        StackType.Lambda           => new LambdaStack(env),
        StackType.CloudflareWorker => new CloudflareWorkerStack(env),
        StackType.InfraAfter       => new InfraAfterStack(env),
        _                          => throw new ArgumentOutOfRangeException()
    };

    await stack.Go();
    return BuildOutputs(stack);
});
```

`ParseStackName` also handles the plain `shared` stack name, which does not need an environment prefix.

Every stack gets the same two places to publish its results:

```csharp
public abstract class StackBase
{
    public Dictionary<string, object?> Outputs { get; } = [];
    public List<SecretItem> Secrets { get; } = [];

    public abstract Task Go();
}
```

Normal outputs are added directly to `Outputs`. If a stack produces application configuration, it adds typed `SecretItem` values to `Secrets`. After `Go()` completes, `Program.cs` serializes that list into one protected stack output for Infra-After to collect.

```csharp
if (stack.Secrets.Count > 0)
{
    stack.Outputs["~~SECRETS~~"] = Output.CreateSecret(
        Output.JsonSerialize(Output.Create(stack.Secrets)));
}
```

The stack implementations do not each need their own startup or output plumbing. Adding another stack means adding its enum value, class and switch case. The rest of the deployment follows the same path.

Shared is the foundation. It exports VPC metadata, repository information and secret IDs so the other stacks do not need to rediscover them. When something should exist only once, like an OIDC provider, or needs to be mutually exclusive, like VPC CIDR ranges, we create it in Shared.

::: card
### Moving CI Runners
The CI runners used to live in Shared. Creating their machine image is slow, and it was annoying to drag that work into unrelated Shared updates, so they now have their own `shared-ci_runners` stack. Because of the common stack setup and inheritance, separating them mostly involved renaming a few classes and running a bunch of `pulumi state move` commands. The runner resources kept their existing cloud infrastructure while Pulumi moved their ownership from Shared into the new stack.
:::

Lambda is separate for almost the opposite reason from what you might expect. The Lambda infrastructure changes less often than Core. Core is where most application resources and policies evolve, while the render functions tend to keep running with the same shape. Giving Lambda its own stack avoids planning all of it during ordinary Core changes. Lambda still depends on Core because Core creates the security group used by the functions.

Finally, we have a stack named **Infra-After** (naming things is easy).

This stack handles work that can only happen after several other stacks exist. A policy may be created beside a resource in Core but need to be attached to a role owned by Lambda. The application's final secret needs outputs gathered from almost everywhere.

Our Pulumi setup began as a much simpler three-stack system: Shared, Staging and Production. As the environment stacks grew, we split them into the smaller, more focused Core, Lambda and Cloudflare Worker stacks. That made each stack easier to work with, but some of the dependencies and cross-stack coordination became harder to follow. Adding one unified “end” stack for that work made the whole setup much simpler.

Infra-After gives that coordination a home. The resource stacks create the things they own. Infra-After connects them and assembles the final configuration when we run it.

## Where Strong Typing Pays Off

All that Pulumi setup is nice. Here is why we actually like having it.

### Secrets and Environment Variables

A lot of infrastructure configuration is not known until deployment. S3 bucket names need to be globally unique, Redis gives us endpoints, AWS gives us ARNs and we use Pulumi to generate random passwords and byte keys. PlanetScale gives us database branches and credentials. Other secrets, such as Auth0, Postmark or Google API keys, are issued outside Pulumi and imported through encrypted Pulumi configuration.

The application should not care where each value originated. It needs one config with the right shape.

Structured values stay structured in `IacClasses`:

```csharp
public record RedisEndpoint(string address, int port);

public record RedisEndpoints(
    RedisEndpoint main,
    RedisEndpoint[] readers);

public record RenderLambdaInfo(
    string render_latest_arn,
    string big_render_latest_arn);
```

The application config brings those smaller models together:

```csharp
public class SecretConfig
{
    public required S3BucketInfo s3_info { get; init; }
    public required RedisEndpoints hf_redis { get; init; }
    public required CloudflareInfo cf_info { get; init; }
    public required RenderLambdaInfo render_lambda_info { get; init; }
    public required MySqlDBInfo mysql_db_info { get; init; }

    public required string google_client_secret { get; init; }
    public required byte[] jwt_secret { get; init; }
    public required string content_pipeline_secret { get; init; }
}
```

Each stack keeps a list of the config values it produces. The names come from a `SecretType` enum, and values are kept as secret Pulumi outputs.

The Lambda stack, for example, combines two alias ARNs and exports the result:

```csharp
var renderInfo = Output
    .Tuple(renderAlias.Arn, bigRenderAlias.Arn)
    .Apply(x => new RenderLambdaInfo(x.Item1, x.Item2));

Secrets.Add(new SecretItem(
    SecretType.RENDER_LAMBDA_INFO,
    Output.JsonSerialize(renderInfo)));
```

Not everything is generated by Pulumi. We have a list of the third-party values it expects to find in encrypted Pulumi config:

```csharp
// Shortened for readability.

var config = new Pulumi.Config();

SecretType[] importedSecrets =
[
    SecretType.GOOGLE_CLIENT_SECRET,
    SecretType.AUTH0_CLIENT_SECRET,
    SecretType.POSTMARK_KEY,
    SecretType.PEXELS_API_KEY
];

foreach (var type in importedSecrets)
{
    Secrets.Add(new SecretItem(
        type,
        config.RequireSecret(type.ToConfigKey())));
}
```

`RequireSecret` fails the deployment when a value is missing and keeps the output marked as secret. `SecretType` gives the imported value the same name everywhere else. We do not have `postmark_api_key` in one stack, `POSTMARK_KEY` in Secrets Manager and `PostmarkToken` in the application unless we deliberately map them that way.

Others are generated during deployment:

```csharp
var jwtSecret = new RandomBytes("jwt-secret", new()
{
    Length = 64
});

Secrets.Add(new SecretItem(SecretType.JWT_SECRET, jwtSecret.Base64)
{
    is_bytes = true
});
```

Pulumi keeps those outputs marked as secret while they pass through `Apply`, serialization and stack outputs. We do not need to turn a generated password into an ordinary string just to move it between stacks.

PlanetScale uses both sides of this setup. Core owns the database branches. Infra-After reads those outputs, creates the environment-specific passwords and turns the writer and reader endpoints into `MySqlDBInfo`. To the application, it is just another required property on `SecretConfig`.

#### Putting It All Together

Infra-After reads the secret output from each stack and adds the values that come directly from Pulumi config. It then builds the final `SecretConfig` document.

This part uses reflection. For every property in `SecretConfig`, we check that there is a matching `SecretType` and a value. Nullable properties may be missing. Structured JSON is parsed as the property's real type, and byte values have to decode correctly.

I am perfectly fine with using reflection here. Could I make it fancier? Sure! But this is deployment code that only I use. It is not in the application path or a library API we are asking customers to depend on. It runs once, catches mistakes and gets the job done.

At the end, we serialize the whole thing and immediately try to deserialize it again:

```csharp
var serialized = configJson.ToJsonString();

_ = JsonSerializer.Deserialize<SecretConfig>(
    serialized,
    new JsonSerializerOptions
    {
        RespectRequiredConstructorParameters = true,
        TypeInfoResolver = IacJsonContext.Default
    });
```

If I add a required property to `SecretConfig` but forget to produce it in Pulumi, Infra-After fails. If I forget to run Infra-After, CI catches it before staging deploys. The same happens when one of the structured values contains the wrong JSON shape. I would much rather find that out during the deployment than when the application eventually reaches that feature.

The final JSON goes into AWS Secrets Manager under one `SECRET_CONFIG` key. At startup, the app loads it into the same type and registers the object with dependency injection:

```csharp
var values = JsonSerializer.Deserialize<Dictionary<string, string>>(
    secret.SecretString)!;

var config = JsonSerializer.Deserialize<SecretConfig>(
    values["SECRET_CONFIG"],
    new JsonSerializerOptions
    {
        RespectRequiredConstructorParameters = true,
        TypeInfoResolver = HCTIJsonSerializerContext.Default
    })!;

services.AddSingleton(config);
```

From there, a service asks for `SecretConfig`. It does not have to know the Secrets Manager layout or remember whether `HF_REDIS` contains a hostname, a connection string or some JSON we invented two years ago.

We still use a few environment variables. The application needs enough information to bootstrap itself before it can load the full secret, and local development needs some URL overrides. Their names are constants in `IacClasses`, so at least the container definition and application agree on the spelling.

#### Why Not Normal .NET Configuration?

We have explored putting Secrets Manager behind `IConfiguration` and binding sections through the normal [.NET options pattern](https://learn.microsoft.com/en-us/dotnet/core/extensions/options){target=_blank}. It has never bought us much for this setup.

The hard work is assembling and validating the cross-stack config before the application starts. By the time the app retrieves `SECRET_CONFIG`, it already has one complete object matching `SecretConfig`. Putting it back into a hierarchy of configuration keys adds another layer without changing how we use it.

Registering the object as a singleton is not a special security mechanism, but it is not inherently less secure than `IOptions<T>` either. Once Secrets Manager returns the decrypted values, they exist in application memory. Dependency injection does not expose them outside the process.

There are some real downsides to our approach:

- Any service that requests `SecretConfig` can see the whole object, even if it only needs one value.
- We load it once during startup and do not check Secrets Manager again. Rotating a value requires a rolling restart of the application.
- Those strings remain in process memory and could appear in a debugger or memory dump. We are careful not to log the config or include it in exception details.
- One large secret means the workload role can read the whole application config rather than receiving permission for a smaller secret per subsystem.

We actually started with separate secrets for each application. It was much more work, and our monolith needed most of them anyway.

The standard options APIs could help if we wanted smaller config objects or live reloads. AWS also provides a [.NET Secrets Manager cache](https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_cache-net.html){target=_blank} that can refresh values periodically. So far, most of these values only change with a deployment, and a startup snapshot has been simpler.

If we start rotating credentials independently of deployments, this is one of the first parts I would revisit. For now, the important thing is to be clear about the tradeoff: typed DI makes the config pleasant to consume, while IAM, Secrets Manager and our logging discipline provide the actual security boundaries.

### Constants

Once we had a typed path from Pulumi into the application, the obvious temptation was to send every infrastructure value through it. We do not.

Our rule is pretty simple. If Pulumi generates a value, we pass it to the application instead of trying to predict it. Structured or sensitive values go into `SecretConfig`. A small, non-sensitive value that the application needs during startup can be an environment variable.

If the value is truly fixed, we put a `const` in `IacClasses.Constants` and move on. DynamoDB GSI names are a good example. The table name changes with the environment, but the index name does not. Pulumi needs it when creating the index, and the application needs the exact same value when querying it. Piping that name through deployed config would add machinery without giving us anything useful.

::: tabs
@tab Defining the const
```csharp
public const string IMAGES_V2_GSI_ORG_IMAGES_LISTING =
    "org_id_created_at-listing-idx";
```

@tab Creating the GSI
```csharp
// Shortened for readability.
new DynamoIndexInfo(
    primaryKey: "org_id",
    sortKey: "created_at",
    nameOverride: Constants.IMAGES_V2_GSI_ORG_IMAGES_LISTING);
```
@tab Using the GSI
```csharp
// Shortened for readability.
var images = await dynamo
    .Query<ImageIndexedByOrgId>()
    .FromIndex(Constants.IMAGES_V2_GSI_ORG_IMAGES_LISTING)
    // Additional filters and pagination removed.
    .ToListAsync();
```
:::

Environment-variable names work the same way. Pulumi puts the render proxy URL into ECS and Lambda environments, while the application reads it during startup:

```csharp
// IacClasses
public const string ENV_VAR_NAME_RenderProxyBaseUrl =
    "RENDER_PROXY_BASE_URL";

// Iac
container.AddEnvVar(
    Constants.ENV_VAR_NAME_RenderProxyBaseUrl,
    $"https://{RenderProxySubDomain}.{TopLevelDomain}");

// App
var renderProxyBaseUrl = Environment.GetEnvironmentVariable(
    Constants.ENV_VAR_NAME_RenderProxyBaseUrl);
```

My favorite example is a CloudWatch metric. When the application exhausts its attempts to find an available renderer, it publishes `RENDER_AGENT_FIND_FAILURE`. Core creates an alarm for the same metric and uses it to scale out the render fleet.

```csharp
public const string RENDER_AGENT_FIND_FAILURE_METRIC_NAME = "RENDER_AGENT_FIND_FAILURE";
```

That name connects application behavior to infrastructure behavior. A typo on either side would break autoscaling. Sharing the constant is a very small bit of code, but it removes a fairly unpleasant failure mode.

### Cloudflare Workers

Our Cloudflare Worker is the awkward example because it is TypeScript, not C#. It's been around since day 0 of HCTI and has always been a little more fragile than the rest of our app. We don't write much TypeScript backend code, and the Worker is difficult to try out locally, so we keep it isolated and simple on purpose.

Pulumi can't quite do the whole job by itself. The Cloudflare provider creates the Worker, versions, bindings and custom domain. We still need Wrangler to bundle the TypeScript and generate its types. `pulumi up` should be the command, though. We didn't want a little deployment ritual where you have to remember to run Wrangler first and hope its config matches.

The Worker needs the API hostname, render proxy hostname, KV namespace, R2 bucket and a few environment flags. Those already exist as Pulumi values. The Worker stack uses them directly when it creates the bindings:

```csharp
Bindings =
[
    new WorkerVersionBindingArgs
    {
        Name = "KV",
        Type = "kv_namespace",
        NamespaceId = kvNamespace
    },
    new WorkerVersionBindingArgs
    {
        Name = "STATIC_BUCKET",
        Type = "r2_bucket",
        BucketName = staticBucket.Name
    },
    new WorkerVersionBindingArgs
    {
        Name = "API_BASE",
        Type = "plain_text",
        Text = apiBase
    },
    new WorkerVersionBindingArgs
    {
        Name = "ENV_NAME",
        Type = "plain_text",
        Text = env.ToString()
    }
];
```

#### Running Wrangler from Pulumi

Pulumi's [`Command`](https://www.pulumi.com/registry/packages/command/api-docs/local/command/){target=_blank} resource lets us put Wrangler inside the stack without running it on every update.

`Triggers` is the important part for us. The command itself stays the same when a TypeScript file or binding changes, so Pulumi cannot notice either change on its own.

We start by writing `wrangler.jsonc` from the same serialized Pulumi values used for the real bindings. The small updater writes the file and then prints its new MD5 hash. Pulumi keeps that hash in the command's `Stdout` output:

```csharp
// Shortened for readability. UpdateWorkerJson.cs prints only the
// hash of wrangler.jsonc after writing the file.
var writeWranglerConfig = new Command(
    "write-wrangler-config",
    new CommandArgs
    {
        Dir = "Worker",
        Create = workerConfigJson.Apply(json =>
            $"""dotnet run UpdateWorkerJson.cs '{json}'"""),
        Triggers = [workerConfigJson]
    });
```

The other build inputs can be hashed immediately. We include the TypeScript source, `package.json` and `tsconfig.json`. The generated `wrangler.jsonc` hash comes from `writeWranglerConfig.Stdout`, so the build cannot use the old file by accident:

```csharp
var workerEnvironment = Env.ToString().ToLowerInvariant();

var buildInputs = Directory
    .GetFiles("Worker/src", "*.ts")
    .Concat(
    [
        "Worker/package.json",
        "Worker/tsconfig.json"
    ])
    .OrderBy(path => path);

var sourceHash = await HashFiles(buildInputs);

var buildWorker = new Command("build-worker", new CommandArgs
{
    Dir = "Worker",
    Create = $"""
        npx wrangler deploy --dry-run \
          --outdir dist/{workerEnvironment} \
          --minify \
          --env {workerEnvironment}
        """,
    Triggers = [sourceHash, writeWranglerConfig.Stdout]
}, new CustomResourceOptions
{
    DependsOn = [writeWranglerConfig]
});
```

This is MD5, which is fine here. We only care that changing a file produces a different trigger. Source changes update `sourceHash`; binding changes rewrite `wrangler.jsonc` and update its hash. An unrelated S3 or ECS change leaves both alone.

The command name is a little misleading. `wrangler deploy --dry-run` doesn't deploy anything. We're using Wrangler as the compiler: it bundles and minifies the TypeScript, then writes `index.js` into the environment's output directory. The Pulumi resource reads that file when it creates the Worker version:

```csharp
Modules =
[
    new WorkerVersionModuleArgs
    {
        Name = "index.js",
        ContentType = "application/javascript+module",
        ContentFile = $"Worker/dist/{workerEnvironment}/index.js"
    }
];

// The Worker version also has DependsOn = [buildWorker].
```

In staging, `wrangler types` also depends on `writeWranglerConfig` and uses the config hash as its trigger. Source changes rebuild the Worker. Configuration changes rewrite its config and types. Nothing needs a separate command before `pulumi up`.

#### Staging vs. Production

Our Cloudflare Worker is one place where we deliberately treat staging and production differently. In staging, `pulumi up` creates a version and sends 100% of traffic to it. In production, it creates the version but does not move any traffic. We still make that final production decision manually in the Cloudflare Workers dashboard.

```csharp
if (Env == IacEnvironment.Staging)
{
    _ = new WorkersDeployment("cf-worker-deploy", new()
    {
        AccountId = cloudflareAccountId,
        ScriptName = worker.Name,
        Strategy = "percentage",
        Versions =
        [
            new WorkersDeploymentVersionArgs
            {
                VersionId = workerVersion.Id,
                Percentage = 100
            }
        ]
    }, new CustomResourceOptions
    {
        RetainOnDelete = true
    });
}
```

We also run `wrangler types` only in staging. The bindings have the same shape in both environments, so we only need to generate the interface once. Production still goes through the Wrangler build and Pulumi creates a real version. Deploying that version stays manual.

## Where the Types Stop

None of this makes configuration impossible to get wrong.

A URL is still a string. The compiler cannot tell whether it points to the right service. Pulumi's `Output<T>` keeps its type, but the value does not exist until the deployment runs. Secrets Manager gives the application JSON, even if we validate that JSON on both sides.

Sharing the contracts also couples infrastructure and application changes. That is intentional for HCTI, but it has to be handled during rolling deployments. We sometimes keep an old property around as nullable or obsolete until every service has moved to the new shape.

The small shared project needs some discipline too. If Pulumi resources or AWS clients start creeping into `IacClasses`, the dependency boundary stops being useful. We keep provider-specific work in `Iac` and pass plain models across the line.

For us, the goal is not to compile the entire cloud. It is to stop treating every infrastructure boundary as an unrelated collection of strings.

## Conclusion

Pulumi started as a way to make our infrastructure repeatable and consistent. The bonus was that keeping it in C# also gave the application a direct relationship with the infrastructure. The code creating a resource and the code using it no longer have to agree through comments, documentation and copied strings.

Clicking around AWS is faster on day one. It is not faster six months later, when we need to remember why a role exists, what shape a secret has or whether changing a resource name will break the app. Having those answers in code has been worth the setup.
