The Pulumi Automation API
The Pulumi Automation API lets you drive preview, up, and destroy from a Python program instead of shelling out to the CLI, turning infrastructure provisioning into a library call you can embed in services, test suites, and self-service tooling. It is part of the broader Pulumi Patterns & Provider Management workflow, and it builds directly on the same stack and backend concepts covered in Pulumi Stack Architecture. This guide explains the execution model, the difference between inline and local programs, and how to operate stacks programmatically with proper error handling.
Two guides go deeper than this overview. Programmatic deployments with the Pulumi Automation API builds a single self-contained driver — inline program, typed outputs, failure handling — that you can drop into a test fixture or a pipeline step. Embedding Pulumi in a FastAPI service with the Automation API takes the same driver and puts it behind an HTTP endpoint, which is where the interesting operational constraints — request timeouts, background work, per-tenant stack naming — actually bite.
Problem Framing
Without the Automation API, every Pulumi operation runs through the pulumi CLI: a human types a command, or a CI job invokes the binary and parses its text or JSON output. That works for hand-driven deployments, but it breaks down the moment you want infrastructure to be a feature of an application — a "create me an environment" button, a per-tenant provisioning service, or a test that spins up real resources and tears them down. Wrapping a subprocess to scrape stdout is brittle, and you lose typed access to outputs and structured error information. The Automation API solves this by exposing the same engine the CLI uses as a first-class Python library, so you get stacks, configuration, and results as objects.
It is worth being precise about what the subprocess approach actually costs, because "brittle" undersells it. A wrapper around pulumi up has to decide what a non-zero exit code means, and the CLI uses the same code for a compile error, a provider rejection, and a lock held by another run — three situations with three different correct responses (fix the code, retry with different inputs, wait). It has to parse resource counts out of a table whose column widths change with terminal size, and it has to strip ANSI colour codes that appear or vanish depending on whether stdout is a TTY. It has to invent a way to get secret outputs out without them landing in a CI log. Every one of those is solved data-side by the Automation API: exceptions are typed, results are objects, and secret outputs carry a flag saying they are secret.
The second thing the CLI cannot give you is a deployment whose inputs are computed at runtime. A CLI-driven flow needs configuration to exist as a file before the command runs, so anything dynamic has to be templated into Pulumi.<stack>.yaml by a shell script beforehand. A driver program computes the values in Python — a tenant ID from a request body, a CIDR from an IPAM allocation, a size from a pricing lookup — and passes them straight into the stack object. That is the difference between infrastructure as a build artefact and infrastructure as a feature.
Prerequisites
- Python 3.9+ and the Pulumi CLI installed on the host (the Automation API still needs the engine binary on
PATH, even though it never spawns the interactive CLI). - The
pulumiPython package:pip install pulumi>=3.0. - A configured state backend and a passphrase or secrets provider. Verify with
pulumi whoamiandpulumi about. - Cloud credentials available in the environment (e.g.
AWS_PROFILEor OIDC), exactly as a normal Pulumi program expects.
# CLI: verify the engine and backend are reachable before driving them from Python
pulumi version
pulumi whoami
pulumi about --json | head -20
Two prerequisites are easy to miss because they are invisible until the first up. The first is disk: each workspace downloads provider plugins into ~/.pulumi/plugins, and the AWS provider alone is several hundred megabytes, so a container image that provisions AWS resources needs either a warm plugin cache baked in or a writable volume large enough to hold one. The second is a secrets provider. If the stack has no PULUMI_CONFIG_PASSPHRASE and no cloud KMS key configured, the first set_config(..., secret=True) fails rather than silently storing plaintext — which is the correct behaviour, but surprising the first time it happens inside a web request.
Execution Model: What Actually Runs
The Automation API is not a reimplementation of Pulumi in Python. It is a control surface over the same engine the CLI drives, and understanding the layers below it explains most of the behaviour that looks strange from the outside.
Reading top to bottom: your driver holds a Stack object, which holds a Workspace. The workspace knows where the project lives, what plugins are installed, and which backend it talks to. When you call up(), the workspace invokes the pulumi engine binary as a child process with a structured protocol, not a scraped terminal. The engine starts a language host which runs your Pulumi program — either the inline function you passed, executed in a fresh interpreter context, or the __main__.py in the project directory. The program registers resources; the engine diffs them against the checkpoint fetched from the backend, calls provider plugins to make the changes, and writes a new checkpoint.
Three practical consequences fall out of that picture. Because the engine is a child process, an inline program's exceptions cross a process boundary and arrive back as auto.errors.InlineSourceRuntimeError with the original traceback embedded in the message rather than as the live exception object — you cannot catch your own ValueError directly. Because the workspace owns plugin installation, a fresh workspace in a container will download plugins on the first run and stall for a minute or two before anything appears to happen. And because the checkpoint lives in the backend rather than in the driver, two driver processes operating the same stack are exactly as dangerous as two engineers running pulumi up at once, and are stopped by exactly the same lock.
Concept Explanation
Inline programs versus local programs
The Automation API supports two program sources. An inline program is a Python function passed directly to the stack — there is no __main__.py and no project directory; the function defines resources when the engine calls it. A local program points the Automation API at an existing on-disk Pulumi project, exactly the layout you already use with the CLI. Inline programs are ideal for embedding infrastructure in a service or test because everything lives in one process; local programs are best when you want to reuse a project that engineers also run by hand.
# deploy.py
# CLI: python deploy.py
import pulumi
import pulumi_aws as aws
from pulumi import automation as auto
def pulumi_program() -> None:
# Provider note: this runs inside the Pulumi engine, not at import time.
bucket = aws.s3.BucketV2("data")
# State implication: every resource declared here is tracked in the stack's checkpoint.
pulumi.export("bucket_name", bucket.bucket)
stack = auto.create_or_select_stack(
stack_name="dev",
project_name="automation-demo",
program=pulumi_program, # inline program: no project directory needed
)
An inline program still gets a project on disk — the workspace writes a temporary Pulumi.yaml into a directory under the system temp path so the engine has something to read. That directory is also where a Pulumi.dev.yaml appears if you set configuration, which matters for two reasons: the config is not durable unless your backend stores it (Pulumi Cloud does; a self-managed backend keeps stack settings alongside the checkpoint), and the temp directory is per-process, so two workers each hold their own copy.
The clearest signal for which source to use is whether a human ever needs to run the same code. If engineers debug the stack with pulumi preview at a terminal, use a local program so both paths execute the identical __main__.py. If the code only ever runs inside your service, an inline program removes a whole class of packaging problems — no project directory to ship in the container image, no relative-path bugs when the working directory differs between local and production.
Stacks and configuration as objects
create_or_select_stack returns a Stack object that owns its workspace. You set config and secrets through typed methods rather than editing Pulumi.<stack>.yaml by hand, which keeps secrets out of source files and lets a driver compute values at runtime.
# deploy.py (continued)
# CLI: python deploy.py
from pulumi import automation as auto
stack.set_config("aws:region", auto.ConfigValue(value="us-east-1"))
# State implication: secrets are encrypted by the stack's secrets provider before storage.
stack.set_config("app:dbPassword", auto.ConfigValue(value="s3cr3t", secret=True))
set_config writes one key at a time and each call touches the workspace, so setting a dozen values costs a dozen round trips. set_all_config takes a dictionary and applies them in one operation, which is the version you want inside a request handler:
# deploy.py (continued)
# CLI: python deploy.py
from pulumi import automation as auto
stack.set_all_config({
"aws:region": auto.ConfigValue(value="eu-west-1"),
"app:tenant": auto.ConfigValue(value="acme"),
# State implication: the secret is encrypted with the stack's secrets provider,
# so the ciphertext — never the plaintext — is what reaches the backend.
"app:apiToken": auto.ConfigValue(value=token, secret=True),
})
Configuration namespacing follows the same rule it does at the CLI: a key with no colon is scoped to the project name, so inside a project called automation-demo the key tenant and the key automation-demo:tenant are the same thing, while aws:region addresses the AWS provider's own namespace. Getting this wrong produces a program that reads pulumi.Config().require("tenant") and raises a missing-configuration error even though you clearly set something with a similar name.
Results are structured, not scraped
Operations like up() return a result object exposing summary, outputs, and per-resource change counts. You read outputs as Python values instead of parsing CLI text, which is what makes the Automation API safe to build services on. See Programmatic deployments with the Pulumi Automation API for a complete inline-program deployment with output capture and error handling.
The result shape is worth memorising because it is what your service returns to its caller. up_result.summary is an UpdateSummary carrying kind ("update", "refresh", "destroy"), result ("succeeded", "failed", "in-progress"), start and end timestamps, and a resource_changes mapping such as {"create": 3, "update": 1, "same": 12}. up_result.outputs maps each exported name to an OutputValue with .value and .secret; checking .secret before logging is the one line that keeps a database password out of your request log. up_result.stdout holds the human-readable transcript, which is worth persisting for an audit trail even though you no longer parse it.
Workspaces, Settings, and Secrets
The Workspace is the object that decides where a stack's files, plugins, environment variables, and backend live. Most drivers never construct one explicitly — create_or_select_stack builds a LocalWorkspace for you — but every non-trivial deployment eventually needs to configure one.
The most common reason to reach for LocalWorkspaceOptions is environment isolation. A service that provisions into several accounts must not rely on the process environment, because the process has one environment and the requests do not. Passing env_vars scopes credentials to the workspace instead:
# provisioner.py
# CLI: python provisioner.py
from pulumi import automation as auto
def stack_for_tenant(tenant: str, role_arn: str) -> auto.Stack:
project = auto.ProjectSettings(
name="tenant-platform",
runtime="python",
# State implication: this backend URL decides where the checkpoint is written;
# changing it later orphans every stack created against the old one.
backend=auto.ProjectBackend(url="s3://acme-pulumi-state"),
)
options = auto.LocalWorkspaceOptions(
project_settings=project,
secrets_provider="awskms://alias/pulumi-state?region=eu-west-1",
# Provider note: scoped to this workspace only — the parent process
# environment is untouched, so parallel tenants cannot cross-contaminate.
env_vars={
"AWS_REGION": "eu-west-1",
"PULUMI_ROLE_ARN": role_arn,
},
)
return auto.create_or_select_stack(
stack_name=f"tenant-{tenant}",
project_name="tenant-platform",
program=lambda: build_tenant_resources(tenant),
opts=options,
)
Secrets deserve a paragraph of their own. Pulumi encrypts secret configuration and secret outputs with a per-stack secrets provider, and the choice of provider is made once, at stack creation, and is painful to change afterwards. A passphrase provider is fine for tests and terrible for a service, because the passphrase has to be present in the process environment for every operation. A cloud KMS provider (awskms://, gcpkms://, azurekeyvault://) moves the trust to an IAM policy you can audit and rotate, and it means a leaked checkpoint file is ciphertext rather than a credential dump. The broader treatment of that decision lives under Pulumi secrets and configuration.
Step-by-Step Implementation
1. Install plugins and select the stack
The first time a workspace runs, it must install the provider plugins the program needs.
# deploy.py
# CLI: python deploy.py
from pulumi import automation as auto
stack = auto.create_or_select_stack(
stack_name="dev",
project_name="automation-demo",
program=pulumi_program,
)
# Provider note: install the AWS plugin into the workspace before previewing.
stack.workspace.install_plugin("aws", "v6.0.0")
stack.set_config("aws:region", auto.ConfigValue(value="us-east-1"))
Pin the plugin version explicitly rather than letting the workspace resolve whatever is newest. A driver that installs the latest provider on every cold start will, sooner or later, pick up a major release while a request is in flight and produce a diff nobody asked for. In a container, run install_plugin at image build time so the first request does not pay the download.
2. Preview, then apply
Run a preview to compute the diff, then up to apply it. Stream the engine's logs with on_output so a service or pipeline sees progress in real time.
# deploy.py
# CLI: python deploy.py
preview = stack.preview(on_output=print)
print("planned changes:", preview.change_summary)
up_result = stack.up(on_output=print)
# State implication: up() writes the new checkpoint to the configured backend.
print("bucket:", up_result.outputs["bucket_name"].value)
on_output gives you the human transcript line by line. When the consumer is a machine rather than a log tail, use on_event instead: it receives EngineEvent objects with fields such as resource_pre_event, diagnostic_event, and summary_event, which lets a service emit real progress ("4 of 11 resources created") instead of relaying terminal text.
# deploy.py
# CLI: python deploy.py
from pulumi import automation as auto
created: list[str] = []
def on_engine_event(event: auto.EngineEvent) -> None:
if event.resource_pre_event is not None:
metadata = event.resource_pre_event.metadata
if metadata.op == "create":
created.append(metadata.urn)
# State implication: refresh reconciles the checkpoint with reality first, so the
# subsequent diff is against the live cloud rather than a stale snapshot.
stack.refresh(on_output=print)
result = stack.up(on_event=on_engine_event)
print(f"created {len(created)} resources: {result.summary.resource_changes}")
3. Destroy when finished
For ephemeral environments — test fixtures, preview environments — tear everything down with destroy, optionally removing the stack record afterwards.
# deploy.py
# CLI: python deploy.py
stack.destroy(on_output=print)
# State implication: remove_stack deletes the now-empty stack from the backend.
stack.workspace.remove_stack("dev")
Order matters here and the failure is unpleasant. remove_stack on a stack that still has resources in its checkpoint either refuses or, if forced, deletes the ledger while the cloud resources continue to exist and continue to bill — orphaned infrastructure nobody has a record of. Always destroy first, assert that the result summary reports success, and only then remove. In a test fixture, put the destroy in a finally block so a failed assertion does not leak a VPC.
Driving Deployments From a Service
An HTTP request and a Pulumi update disagree about time. A request wants to finish in under a second; an update creating an RDS instance takes ten minutes. Every design for putting the Automation API behind an API is a way of resolving that disagreement.
The pattern that works is to make the deployment asynchronous and the API honest about it: accept the request, validate it, persist a job record, return 202 Accepted with a job identifier, and run the update elsewhere. "Elsewhere" is a background task for low volume and a queue with dedicated workers once concurrency matters, because an update pins a worker for its full duration and a handful of concurrent provisions will exhaust a small thread pool. The full worked example — endpoint, background task, per-tenant stack naming, and status polling — is in embedding Pulumi in a FastAPI service with the Automation API.
Two rules keep that design safe. First, derive the stack name from the tenant deterministically (tenant-{id}), so a retried request selects the existing stack instead of creating a second one; create_or_select_stack is idempotent precisely so that retries are cheap. Second, never let request-supplied data reach the program as anything but configuration. An inline program that interpolates a caller-supplied string into a bucket name is one creative input away from a resource in an unexpected account, and validating in the request handler — before the stack object exists — is the cheapest place to stop it.
Verification
Confirm the driver actually changed real state by reading the result counts and then cross-checking with the CLI against the same backend.
# tests/test_driver.py
# CLI: python -m pytest tests/test_driver.py -q
assert up_result.summary.kind == "update"
assert up_result.summary.result == "succeeded"
assert "bucket_name" in up_result.outputs
# State implication: an empty change set proves the program is idempotent —
# a second up() against unchanged inputs must plan nothing.
assert up_result.summary.resource_changes.get("create", 0) == 1
# CLI: the Automation API and CLI share one backend, so the stack is visible to both
pulumi stack ls
pulumi stack output bucket_name --stack dev
The strongest single check is a second run. Call stack.preview(expect_no_changes=True) immediately after a successful up; if the program is genuinely idempotent the call returns cleanly, and if it is not, it raises rather than quietly reporting a diff. Non-idempotence in an Automation API driver almost always traces to the same cause — a value computed at program run time, such as a timestamp or a random suffix, that differs on every invocation and therefore looks like a change to the engine.
For a driver embedded in a service, add one more verification that has nothing to do with Pulumi: assert that the checkpoint's backend URL is the one you expect. A misconfigured ProjectBackend silently writes state to a local file inside the container, the container is replaced, and the next request finds an empty stack and plans to create everything from scratch — against resources that already exist, whose names then collide.
Error Handling and Concurrency
Every update takes a lock on its stack, and almost every confusing Automation API failure is a lock story. The engine acquires the lock before it reads the checkpoint and releases it after it writes the new one; a process killed in between leaves the lock behind.
Catch the typed exceptions rather than inspecting message strings. auto.errors.ConcurrentUpdateError means another operation holds the lock and the correct response is to back off and retry, or to return a "deployment already in progress" status to the caller. auto.errors.StackNotFoundError and auto.errors.StackAlreadyExistsError distinguish the two failure directions of stack creation, which is why create_or_select_stack exists — it handles both. auto.errors.InlineSourceRuntimeError means your own program raised, and the message carries the original traceback. auto.errors.CommandError is the general case, and its stderr is where the provider's own message lives.
# provisioner.py
# CLI: python provisioner.py
import time
from pulumi import automation as auto
def deploy_with_retry(stack: auto.Stack, attempts: int = 3) -> auto.UpResult:
for attempt in range(attempts):
try:
return stack.up(on_output=print)
except auto.errors.ConcurrentUpdateError:
# State implication: another process holds the stack lock; the checkpoint
# is intact, so waiting is safe and force-unlocking would not be.
time.sleep(2 ** attempt * 5)
except auto.errors.InlineSourceRuntimeError as exc:
# Provider note: the program itself raised — retrying cannot help.
raise RuntimeError(f"program failed, not retrying: {exc}") from exc
raise TimeoutError("stack remained locked after 3 attempts")
Backing off is right; force-unlocking on a schedule is not. stack.cancel() removes the lock without any knowledge of whether an update is genuinely running, and cancelling a live update leaves the checkpoint describing resources whose creation may have half-completed. Reserve it for the case you have positively confirmed — the worker process is gone, the pod is terminated — and treat a subsequent refresh as mandatory before the next up, so the checkpoint is reconciled with what the cloud actually holds.
Troubleshooting
automation.errors.CommandError mentioning "no Pulumi.yaml found".
You used a local program but pointed it at a directory without a project file. Either pass work_dir to a real project root, or switch to an inline program with create_or_select_stack(..., program=...).
could not find plugin for provider aws.
The workspace has not installed the provider. Call stack.workspace.install_plugin("aws", "v6.0.0") before preview/up, or ensure the plugin is present on the host.
Operation hangs or fails with "the stack is currently locked".
A previous run or a concurrent CLI invocation holds the backend lock. Wait for it to finish, or run pulumi cancel --stack dev once you are certain no operation is in progress.
error: getting secrets manager: passphrase must be set with PULUMI_CONFIG_PASSPHRASE.
The stack uses the passphrase secrets provider and the variable is absent from the process the driver runs in. Supply it through LocalWorkspaceOptions(env_vars=...) rather than the ambient environment, or move the stack to a KMS-backed secrets provider so no shared passphrase is needed.
auto.errors.InlineSourceRuntimeError wrapping a NameError from your own function.
The inline program is executed by the engine in a separate context, so anything it references must be reachable from the function itself — a closure variable defined before the call, an import at module level, or a parameter bound by a factory. Values assigned after create_or_select_stack returns are not visible to it.
A second up() reports changes even though nothing was edited.
Something in the program is non-deterministic. Look for datetime.now(), uuid4(), a dictionary iterated in a changing order, or a config value read from an environment variable that differs between processes. Run preview(expect_no_changes=True) to turn this from a warning you can ignore into an exception you cannot.
FAQ
Do I still need the Pulumi CLI installed if I use the Automation API?
Yes. The Automation API drives the same engine binary the CLI uses, so the pulumi executable must be on PATH. What you avoid is invoking the interactive CLI yourself and parsing its text output — the engine is called as a library and returns structured results.
When should I use an inline program instead of a local one?
Use inline programs when infrastructure is part of an application or test and you want everything in one process with no separate project directory. Use local programs when you are wrapping an existing Pulumi project that engineers also run by hand with the CLI, so both paths share the same code.
Is the Automation API safe to call concurrently for different stacks?
Yes, as long as each stack is operated independently. The backend enforces a per-stack lock, so two drivers updating the same stack will conflict, but separate stacks can be provisioned in parallel. Build per-environment isolation as described in Pulumi Stack Architecture.
How do I get stack outputs back into my application?
up() returns a result whose outputs dictionary maps output names to typed values, including secrets you can unwrap. Read them directly rather than calling pulumi stack output and parsing the CLI. Check the .secret flag before writing an output to a log.
How long does an Automation API deployment hold a worker?
For as long as the slowest resource takes — minutes for an RDS instance or a managed Kubernetes control plane. Treat an update as background work with a job record and a status endpoint rather than something an HTTP handler waits on, and size the worker pool by concurrent deployments rather than by request rate.
Can I run the Automation API inside a container without a plugin download on every start?
Yes, and you should. Run pulumi plugin install resource aws 6.0.0 during the image build so ~/.pulumi/plugins is populated in the layer, then pin the same version in install_plugin. Otherwise the first request after every deploy pays a multi-hundred-megabyte download before any resource is touched.
Related
- Programmatic deployments with the Pulumi Automation API — a full inline-program deployment with output capture and error handling.
- Embedding Pulumi in a FastAPI service with the Automation API — the same driver behind an HTTP endpoint, with background tasks and per-tenant stacks.
- Pulumi Stack Architecture — stack isolation and backend concepts the Automation API depends on.
- Pulumi Patterns & Provider Management — the parent overview of Pulumi workflows and provider strategies.