Pulumi: 30 Key Concepts in Simple English
Output<T>, config and secrets, state and backends, components, stack references, and the resource options that control a resource's lifecycle. The one that catches every C# developer is Output<T>: it is a promise, not a value.
What are the core building blocks?
Pulumi runs a real C# program. There is no separate configuration language, so the usual .NET tools apply: NuGet packages, classes, methods, your IDE and your debugger.
Basics
| # | Concept | What it means |
|---|---|---|
| 1 | Infrastructure as Code | Describe infrastructure in C#, compiled and run like any other program. |
| 2 | Project | A directory with a Pulumi.yaml file that defines the program. |
| 3 | Stack | "An isolated, independently configurable instance of a Pulumi program." |
| 4 | Stack config file | Pulumi.<stack-name>.yaml, one per stack. |
| 5 | Resources | The cloud objects Pulumi creates, updates and deletes. |
| 6 | Providers | Plugins that talk to AWS, Azure, Kubernetes and the rest. |
| 7 | Packages and SDKs | Providers arrive as normal NuGet packages, such as Pulumi.Aws. |
| 8 | Config | Per-stack settings, read in code with the Config class. |
using Pulumi;
using Pulumi.Aws.S3;
return await Deployment.RunAsync(() =>
{
var bucket = new Bucket("my-bucket", new BucketArgs
{
Website = new BucketWebsiteArgs
{
IndexDocument = "index.html",
},
});
return new Dictionary<string, object?>
{
["bucketName"] = bucket.Id,
};
});A project can hold as many stacks as you need, and each one carries its own configuration file and its own state. Because a stack is the unit of isolation, dev and prod are genuinely separate instances rather than two names pointing at the same place.
Why can you not just use a resource's value?
This is the concept to understand first, because everything else depends on it. The docs describe outputs as "values that are only known after a resource is created", and say they "work like promises or futures".
- 9. Inputs - the arguments you pass to a resource. They accept plain values or outputs from other resources.
- 10.
Output<T>- an asynchronous value produced by a resource. - 11.
Apply- the docs say it "allows you to access a single output's plain value". - 12.
Output.Format- builds a string from outputs without callingApplyyourself.
The trap for a C# developer is that an Output<string> is not a string, so the habits you have from normal code quietly produce the wrong thing. The docs are explicit: "you cannot directly print the value of an output using your language's string printing function".
// Wrong - this does not give you the endpoint
var url = "https://" + bucket.WebsiteEndpoint;
// Right - interpolation helper
var url = Output.Format($"https://{bucket.WebsiteEndpoint}");
// Right - transform inside Apply
var url = bucket.WebsiteEndpoint.Apply(endpoint => $"https://{endpoint}");.Result, .Wait() or await to turn the promise into a value. There is no supported way to do that, and there should not be: during a preview, the value genuinely does not exist yet, because the resource has not been created. Anything that depends on an output has to happen inside Apply, or be composed with Output.Format.How does Pulumi handle secrets?
- 13. Secrets - values marked secret and encrypted by Pulumi.
- 14. Secrets providers - what performs the encryption.
Unlike plain state files elsewhere, Pulumi encrypts these values. The docs put it directly: "Encryption ensures that these values never appear as plain text in your state file." Secrecy is also sticky - "as soon as an output is marked secret, the Pulumi engine encrypts it everywhere it gets stored."
var config = new Config();
// Plain setting
var instanceSize = config.Require("instanceSize");
// Encrypted in state, and stays encrypted wherever it flows
var dbPassword = config.RequireSecret("dbPassword");By default "the encryption method uses automatic, per-stack encryption keys provided by Pulumi Cloud". If you would rather hold the key yourself, you can choose a different provider when you create the stack: AWS KMS (awskms), Azure Key Vault (azurekeyvault), Google Cloud KMS (gcpkms), HashiCorp Vault Transit (hashivault) or a local passphrase.
Apply receives the decrypted value, which means your own code can leak it - log it, put it in a tag, hand it to a non-secret output, and the protection is gone.This is the clearest difference from Terraform, where marking an output sensitive only redacts the CLI display and leaves the value readable in state. I covered that in the Terraform concepts article. Pulumi encrypts by default, which is a better starting position, but the two caveats above still apply.
Where does Pulumi keep its state?
- 15. State - the docs describe it as the metadata "about your infrastructure so that it can manage your cloud resources".
- 16. Backends - Pulumi Cloud by default, or a self-managed store.
- 17.
pulumi refresh- reconciles state with reality.
The default backend is Pulumi Cloud, managed or self-hosted. The alternative the docs call a DIY backend is "a 'Do It Yourself' backend that stores state in an object store you manage" - S3, Azure Blob Storage, Google Cloud Storage, an S3-compatible server, PostgreSQL, or the local filesystem.
One limitation is worth knowing before you choose. The docs note that DIY backends "cannot transparently recover from certain kinds of partial failures", because blob storage does not give Pulumi everything it needs.
For drift, pulumi refresh "queries each resource in your stack from the cloud provider" and brings state back in line. Importantly, it "updates only the state" and "does not modify your Pulumi program or apply any changes to your infrastructure". If something was deleted outside Pulumi, refresh drops it from state.
What does the everyday workflow look like?
Workflow commands
| # | Command | What it does |
|---|---|---|
| 18 | pulumi new | Scaffold a project from a template, such as aws-csharp. |
| 19 | pulumi preview | Show what would change, without changing anything. |
| 20 | pulumi up | Preview, then apply once you confirm. |
| 21 | pulumi destroy | Remove everything in the stack. |
| 22 | pulumi stack | Create, select, list and inspect stacks. |
Because the program is ordinary C#, a compile error stops you before any of this runs. That is a real difference in feedback: a typo in a property name is a build failure in your IDE, not a message from the engine halfway through a deployment.
How do you structure and reuse code?
- 23. Component resources - the docs define a component as "a logical grouping of Pulumi resources that is exposed as a single Pulumi resource".
- 24.
Parent- the option that attaches a child resource to its component. - 25. Stack references - read another stack's outputs.
A component is a normal C# class deriving from ComponentResource. The base constructor takes a type token in the form <package>:<module>:<name>, where the docs note "the module portion of this type name is always index and is a required implementation detail". Children take Parent = this, and the constructor ends by registering its outputs.
class StaticPage : ComponentResource
{
public Output<string> Endpoint { get; private set; }
public StaticPage(string name, StaticPageArgs args, ComponentResourceOptions? opts = null)
: base("sample-components:index:StaticPage", name, args, opts)
{
var bucket = new Bucket($"{name}-bucket", new BucketArgs(),
new CustomResourceOptions { Parent = this });
this.Endpoint = bucket.WebsiteEndpoint;
this.RegisterOutputs(new Dictionary<string, object?>
{
["endpoint"] = bucket.WebsiteEndpoint,
});
}
}To consume another stack's outputs, use a stack reference. Names "must be fully qualified, including the organization, project, and stack name components, in the format <organization>/<project>/<stack>".
var infra = new StackReference($"mycompany/infra/{Deployment.Instance.StackName}");
// RequireOutput fails at deployment time if the output is missing
var kubeConfig = infra.RequireOutput("KubeConfig").Apply(v => v.ToString());Prefer RequireOutput over GetOutput. The docs recommend it for most cases, because it fails at deployment time when the named output is missing. GetOutput is for when a missing output is an expected case your program handles.
How do you control a resource's lifecycle?
These are resource options, passed as the third argument when you create a resource. The descriptions below are the ones the documentation gives.
Lifecycle and safety options
| # | Option | What the docs say it does |
|---|---|---|
| 26 | Protect | "Prevent accidental deletion by marking the resource as protected." |
| 27 | RetainOnDelete | "Retain the resource in the cloud provider when Pulumi deletes it." |
| 28 | IgnoreChanges | "Ignore changes to specified properties during a diff." |
| 29 | DependsOn | "Specify explicit dependencies in addition to those in the dependency graph." |
| 30 | ImportId | "Bring an existing cloud resource under Pulumi management." |
var db = new Instance("db", new InstanceArgs { /* ... */ },
new CustomResourceOptions
{
Protect = true,
IgnoreChanges = { "tags" },
DependsOn = { network },
});Protect and RetainOnDelete are not the same thing. Protect blocks the delete: Pulumi refuses to remove the resource, and the operation fails until you unprotect it. RetainOnDelete allows the delete to proceed in Pulumi but leaves the real resource alive in the cloud, so Pulumi simply forgets about it. Use Protect to stop a mistake, and RetainOnDelete to hand a resource over to something else.Note also that DependsOn is for dependencies Pulumi cannot see. When you pass one resource's output into another resource's arguments, the dependency is already in the graph and you do not need to declare it.
When should you use each?
Decision guide
| Situation | Use this | Not this |
|---|---|---|
| Building a string from a resource value | Output.Format($"...") | String concatenation or .Result |
| Transforming one output | Apply | Reading the value outside the callback |
| Storing a password | config.RequireSecret | A plain config value |
| Separating dev from prod | Two stacks | Branching inside one stack |
| Reading another stack's output | RequireOutput | GetOutput, unless absence is expected |
| Stopping an accidental delete | Protect = true | RetainOnDelete, which still deletes in Pulumi |
| Handing a resource to another system | RetainOnDelete = true | Protect, which blocks the operation |
| Ordering two unrelated resources | Pass an output as an input | DependsOn, unless nothing links them |
| Grouping resources for reuse | A ComponentResource class | Copying the same block between projects |
Frequently asked questions
Because an Output is asynchronous. The docs describe outputs as values that are only known after a resource is created, and say they work like promises or futures. During a preview the value does not exist yet, so there is nothing to read. Use Output.Format to build strings, or Apply to work with the value inside a callback.
Yes. The docs say encryption ensures that these values never appear as plain text in your state file, and that once an output is marked secret the engine encrypts it everywhere it gets stored. Two gaps remain: a resource physical ID is always stored in plain text and cannot be encrypted, and the callback inside Apply receives the decrypted value, so your own code can still leak it.
Protect prevents accidental deletion by marking the resource as protected, so the delete fails until you unprotect it. RetainOnDelete lets Pulumi drop the resource from its management while leaving the real resource alive in the cloud provider. Protect stops a mistake; RetainOnDelete is how you hand a resource over to something else.
The docs define it as an isolated, independently configurable instance of a Pulumi program. A project can have as many stacks as you need, and each one has its own configuration file, named Pulumi.<stack-name>.yaml, and its own state. Stacks are the normal way to separate dev, staging and production.
No. Pulumi Cloud is the default backend, but you can use a self-managed backend that stores state in an object store you manage, such as AWS S3, Azure Blob Storage, Google Cloud Storage, PostgreSQL or the local filesystem. The docs do note that these backends cannot transparently recover from certain kinds of partial failures, which is the main trade-off.
No. It queries each resource in your stack from the cloud provider and updates the state to match. The docs state that refresh updates only the state, and that it does not modify your Pulumi program or apply any changes to your infrastructure. If a resource was deleted outside Pulumi, refresh removes it from state.