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.

Automation API orchestration flow A driver program calls the Automation API, which creates or selects a stack and runs preview, up, and destroy against cloud providers and the state backend, without invoking the CLI. Driver program app.py / pytest Automation API create_or_select preview / up / destroy Cloud providers AWS / GCP / Azure State backend S3 / Pulumi Cloud no CLI
The driver program calls the Automation API, which orchestrates stack operations against cloud providers and the state backend with no CLI process involved.

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.

Problem Framing Problem Framing: Problem Framing with 4 facets. Problem Framing pulumi key element Problem key element Automation API key element JSON key element
Problem Framing: how pulumi, Problem, Automation API relate in this pattern.

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

Prerequisites Prerequisites: layered from PATH down to Pulumi CLI. PATH pulumi AWS_PROFILE Python Pulumi CLI
Prerequisites: the building blocks this section assembles.
  • 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 pulumi Python package: pip install pulumi>=3.0.
  • A configured state backend and a passphrase or secrets provider. Verify with pulumi whoami and pulumi about.
  • Cloud credentials available in the environment (e.g. AWS_PROFILE or 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.

What sits below a Stack object What sits below a Stack object: layered from Driver program down to State backend. Driver program your app.py, pytest fixture, or queue worker Automation API LocalWorkspace and Stack objects pulumi engine binary diff, plan, checkpoint read and write Language host runs the inline function or __main__.py Provider plugins pulumi-resource-aws, installed per workspace State backend S3, Pulumi Cloud, or a local file
The Automation API is a control surface, not a reimplementation: every layer below it is the same code the CLI drives.

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

Concept Explanation Concept Explanation: Inline programs then Stacks and then Results are Inline programs Stacks and Results are
Concept Explanation: the stages run left to right — Inline programs, Stacks and, Results are.

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.

Inline program versus local program Inline program versus local program: comparison across Inline program, Local program. Property Inline program Local program Where code lives a Python function a Pulumi.yaml directory Project file written to a temp dir read from work_dir Run by hand? no yes, with the CLI Best for services, tests, per-tenant stacks wrapping an existing project Failure arrives as InlineSourceRuntimeError CommandError with stderr
Pick by whether a human ever runs the same code: if engineers debug it at a terminal, keep it on disk.

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

Step by Step Implementation Step by Step Implementation: 1. Install plugins then 2. Preview, then then 3. Destroy when 1. Install plugins 2. Preview, then 3. Destroy when
Step by Step Implementation: the stages run left to right — 1. Install plugins, 2. Preview, then, 3. Destroy when.

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.

How should a request trigger an update? How should a request trigger an update?: choose among 3 options. HTTP request asks forinfrastructure seconds reject: 202 plus ajob id minutes in-processbackground task at scale queue plus dedicatedworkers
An update pins a worker for its whole duration, so the only sustainable answer is asynchronous.

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.

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# 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.

The stack lock around one update The stack lock around one update: acquire lock → run program → call providers → write checkpoint → release lock → repeat. acquire lock run program call providers write checkpoint release lock
A worker killed between acquire and release leaves the lock behind; that is what ConcurrentUpdateError reports.

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

Troubleshooting Troubleshooting: Where it breaks with 3 facets. Where it breaks work_dir watch this boundary preview watch this boundary Pulumi watch this boundary
Troubleshooting: the boundaries where things break and what to check.

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.