Embed Pulumi in a FastAPI Service with the Automation API

The Pulumi Automation API turns pulumi up into a library call, which means you can drive deployments from an HTTP service instead of a CLI. This guide — under Pulumi patterns and provider management — builds a small FastAPI endpoint that provisions a per-tenant stack on demand, the pattern behind self-service infrastructure portals.

Why This Matters

A CLI is fine for engineers, but product teams often need infrastructure created in response to an event: a new customer signs up, a preview environment is requested, a batch job needs a bucket. Wrapping programmatic deployments in an API lets those events trigger real, stateful deployments with full preview and rollback.

Request to deployment Request to deployment: Client → FastAPI → Automation API → Cloud. Client FastAPI Automation API Cloud POST /envs up() create outputs result 202 + id
The API accepts the request, runs the stack asynchronously, and returns a job id the client polls.

The key design constraint is that deployments are slow and stateful, so the HTTP layer must treat them as background work, not a synchronous request you block on.

There is a second constraint that is easier to miss. The Automation API is not a client library talking to a remote service — it drives the pulumi binary on the same host, and for inline programs it hosts a gRPC language server inside your Python process so the engine can call your program function back. A FastAPI service embedding it is therefore running three things at once: the web server, a subprocess, and a Pulumi language runtime that shares its interpreter. Every design decision below follows from that arrangement: how many deployments can run concurrently, where credentials must live, what happens on SIGTERM, and why an exception inside a tenant's program can take the whole API down if you let it.

Prerequisites

Prerequisites Prerequisites: layered from fastapi down to Cloud. fastapi uvicorn Automation API Cloud
Prerequisites: the building blocks this section assembles.
  • pulumi>=3.0 and fastapi, uvicorn installed in the service virtualenv
  • The Pulumi CLI on the host (the Automation API shells out to it) and a configured state backend
  • Cloud credentials available to the service process via its execution role, not baked into the image
# CLI: the Automation API needs the pulumi binary on PATH
pulumi version && python -c "from pulumi.automation import create_or_select_stack"

The binary version matters more than it does for CLI use. LocalWorkspace invokes whatever pulumi is first on PATH, so an image rebuilt six months later can pick up a newer CLI with different plan output while the Python SDK stays pinned in requirements.txt. Pin both, in the same place — a Dockerfile that installs an exact CLI version alongside an exact pulumi wheel — or you will eventually debug a behaviour change that appears in production and is unreproducible locally.

Decide the secrets provider before the first stack exists. A stack's encryption provider is fixed at creation; changing it later means pulumi stack change-secrets-provider, run per stack, which is painful once there are a few hundred tenants. Pass it explicitly rather than accepting the default:

# workspace.py — workspace options shared by every tenant stack
# CLI: python -c "import workspace; print(workspace.OPTS.secrets_provider)"
from typing import Final
from pulumi import automation as auto

BACKEND_URL: Final[str] = "s3://acme-pulumi-state/tenants"

OPTS: Final[auto.LocalWorkspaceOptions] = auto.LocalWorkspaceOptions(
    secrets_provider="awskms://alias/pulumi-tenant-state?region=us-east-1",
    project_settings=auto.ProjectSettings(
        name="tenant-envs",
        runtime="python",
        backend=auto.ProjectBackend(url=BACKEND_URL),
    ),
    env_vars={"PULUMI_SKIP_UPDATE_CHECK": "true"},
)
# State implication: secrets_provider is recorded in the stack's own config file
# at creation and cannot be changed by editing this value afterwards.

env_vars are injected into the CLI subprocess only, not into the service process, which makes them the right place for per-tenant credentials when one API server deploys into several accounts.

Defining an Inline Program

The Automation API can run an inline program — a Python function instead of a project directory — which is ideal for embedding. Each tenant gets its own stack name so state stays isolated.

Service architecture Service architecture: layered from FastAPI endpoint (async) down to Cloud provider. FastAPI endpoint (async) Automation API driver Inline Pulumi program Per-tenant state backend Cloud provider
Each layer isolates concerns from the HTTP boundary down to the cloud.
# service.py — a FastAPI endpoint that provisions a per-tenant bucket
# CLI: uvicorn service:app --port 8080
from fastapi import FastAPI, BackgroundTasks
from pulumi import automation as auto
import pulumi_aws as aws

app = FastAPI()

def _program(tenant: str):
    def build():
        bucket = aws.s3.BucketV2(f"{tenant}-data")
        return {"bucket": bucket.bucket}
    return build

def _deploy(tenant: str) -> None:
    stack = auto.create_or_select_stack(
        stack_name=f"tenant-{tenant}",
        project_name="tenant-envs",
        program=_program(tenant),
    )
    stack.workspace.install_plugin("aws", "v6.0.0")  # Provider note: pin the plugin version
    stack.set_config("aws:region", auto.ConfigValue("us-east-1"))
    stack.up(on_output=print)  # State implication: writes to the tenant's own state file

@app.post("/envs/{tenant}", status_code=202)
def create_env(tenant: str, bg: BackgroundTasks):
    bg.add_task(_deploy, tenant)
    return {"status": "accepted", "stack": f"tenant-{tenant}"}

The closure returned by _program is what makes this an inline program: there is no project directory, no Pulumi.yaml on disk for the tenant, and no __main__.py. create_or_select_stack writes a minimal project and stack settings file under the workspace's working directory, then hands the engine a reference to the callable. When the engine is ready to evaluate the program it calls back over gRPC and the closure executes in the service process.

Inline program execution inside the service process Inline program execution inside the service process: FastAPI worker → LocalWorkspace → pulumi CLI → Language host. FastAPI worker LocalWorkspace pulumi CLI Language host up() spawn subprocess gRPC Run execute program fn RegisterResource stream stdout UpResult
The inline program runs back inside the same Python process the API server is using.

That callback path explains several behaviours that look strange from the outside. Output printed by the program lands in the service's own stdout unless you intercept it with on_output. An exception raised inside build() does not produce a Python traceback at the up() call site — the engine catches it, marks the update failed, and the SDK re-raises it wrapped as InlineSourceRuntimeError with the original message in the string. And because Pulumi's Python SDK keeps runtime settings in module-level state, two inline programs executing at the same instant in the same interpreter will interfere with each other regardless of which stacks they target.

Running Deployments Off the Request Path

BackgroundTasks is the right shape for a demonstration and the wrong shape for production: the task dies with the worker, there is no record of what happened, and nothing prevents two requests for the same tenant from overlapping. Replace it with an explicit job record and a per-stack lock.

# jobs.py — serialise deployments per stack and record the outcome
# CLI: uvicorn service:app --port 8080 --workers 1
import asyncio
from dataclasses import dataclass, field
from typing import Literal, Optional
from pulumi import automation as auto

Status = Literal["queued", "running", "succeeded", "failed"]


@dataclass
class Job:
    tenant: str
    status: Status = "queued"
    detail: str = ""
    outputs: dict[str, str] = field(default_factory=dict)
    log: list[str] = field(default_factory=list)


JOBS: dict[str, Job] = {}
_LOCKS: dict[str, asyncio.Lock] = {}
_ENGINE = asyncio.Semaphore(2)   # cap concurrent CLI subprocesses


def _lock_for(stack_name: str) -> asyncio.Lock:
    return _LOCKS.setdefault(stack_name, asyncio.Lock())


async def run_up(job: Job) -> None:
    stack_name = f"tenant-{job.tenant}"
    async with _lock_for(stack_name), _ENGINE:
        job.status = "running"
        try:
            result: auto.UpResult = await asyncio.to_thread(_blocking_up, job)
        except auto.ConcurrentUpdateError as exc:
            job.status, job.detail = "failed", f"stack busy: {exc}"
            return
        except auto.errors.InlineSourceRuntimeError as exc:
            job.status, job.detail = "failed", str(exc)
            return
        job.status = "succeeded"
        job.detail = str(result.summary.resource_changes)
        job.outputs = {k: v.value for k, v in result.outputs.items() if not v.secret}
        # State implication: secret outputs stay encrypted in state; never copy
        # them into an in-memory job record that an unauthenticated poll can read.

asyncio.to_thread matters because stack.up() is fully synchronous — it blocks until the subprocess exits. Calling it directly from an async def handler stalls the event loop and every other request served by that worker. The semaphore is the second half of the protection: each concurrent up() is a pulumi subprocess plus a provider plugin process plus whatever memory the program itself allocates, so two or three at once is a realistic ceiling for a modest container, not a hundred.

Stream progress rather than discarding it. on_output receives the CLI's rendered lines; on_event receives structured EngineEvent objects, which is what you want if the UI needs a resource-by-resource progress bar rather than a log tail.

# service.py — the blocking half, with output captured into the job record
# CLI: curl -X POST localhost:8080/envs/acme
from pulumi import automation as auto
import workspace


def _blocking_up(job: Job) -> auto.UpResult:
    stack = auto.create_or_select_stack(
        stack_name=f"tenant-{job.tenant}",
        project_name="tenant-envs",
        program=_program(job.tenant),
        opts=workspace.OPTS,
    )
    stack.set_config("aws:region", auto.ConfigValue("us-east-1"))
    stack.set_config("tenant:tier", auto.ConfigValue("standard"))
    return stack.up(
        on_output=job.log.append,
        color="never",          # Provider note: ANSI codes otherwise pollute the job log
        parallel=8,
        message=f"api deploy for {job.tenant}",
    )

color="never" is not cosmetic — without it every captured line carries escape sequences that break JSON log shipping and make the stored output unreadable in a web UI. message shows up in the stack's update history, which turns "who changed this" into a question the state backend can answer.

Error Handling and Retry Semantics

The Automation API raises a small, well-defined exception hierarchy, and treating all of it as one generic failure is what produces retry storms. Match on the type.

Automation API exceptions and the right response Automation API exceptions and the right response: comparison across Cause, Safe to retry. Exception Cause Safe to retry ConcurrentUpdateError Stack lock already held Yes, after backoff InlineSourceRuntimeError Program function raised No, fix the code StackNotFoundError select on a missing stack No, create first CommandError CLI exited non-zero Only after reading stderr
Each Automation API failure class needs a different recovery path; retrying blindly compounds a lock conflict.

ConcurrentUpdateError is the one that surfaces most often in a multi-replica deployment, because the per-process lock above protects one replica and not the fleet. The message comes straight from the backend — for Pulumi Cloud it reads [409] Conflict: Another update is currently in progress, and for a file or S3 backend it reports the lock file and suggests pulumi cancel. Retry it with backoff; do not call stack.cancel() automatically, because cancelling an update that is genuinely mid-apply leaves resources created but unrecorded, which is the worst state to be in.

CommandError is the catch-all for a non-zero CLI exit that the SDK could not classify — a missing plugin, an expired credential, a provider that refused the request. Its string carries the CLI's stderr, so log it whole rather than summarising; the actionable detail is almost always in the last few lines.

Verification

Exercise the endpoint and confirm a stack and state file appear, then destroy it to prove the reverse path works.

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.
# CLI: trigger a deployment and inspect the resulting stack
curl -X POST localhost:8080/envs/acme
pulumi stack ls          # expect: tenant-acme listed
pulumi -s tenant-acme destroy --yes

The CLI check proves the stack exists. Prove the service agrees by reading the same information back through the Automation API, which is the path your API actually uses:

# check.py — assert the service's view of a tenant stack matches reality
# CLI: python check.py acme
import sys
from pulumi import automation as auto
import workspace

tenant: str = sys.argv[1]
stack = auto.select_stack(
    stack_name=f"tenant-{tenant}",
    project_name="tenant-envs",
    program=lambda: None,          # no-op: we are only reading state
    opts=workspace.OPTS,
)

info = stack.info()
assert info is not None, "stack has no update history — it was never deployed"
print(info.kind, info.result, info.start_time)

outputs = stack.outputs()
print({k: ("<secret>" if v.secret else v.value) for k, v in outputs.items()})

preview = stack.preview()
# State implication: a clean stack previews as `same` only. Any `create` or
# `update` here means the recorded state has drifted from the program.
assert preview.change_summary.get("create", 0) == 0, preview.change_summary

select_stack with a no-op program is the standard read-only entry point — the engine never evaluates it for outputs() or info(), and preview() will report the whole stack as pending deletion if you pass an empty program by mistake, which is why the assertion above checks for creates rather than for an empty summary.

A useful negative test: send two POSTs for the same tenant within a second and confirm the second job reports stack busy rather than starting a parallel update. If both proceed, the lock is not doing its job and the next real incident will be a corrupted state file.

Gotchas & Edge Cases

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks Credentials auth & region State lock & drift Types schema mismatch Ordering dependency graph
Gotchas & Edge Cases: the boundaries where things break and what to check.

Concurrency corrupts state. Two overlapping up() calls on the same stack will collide on the state lock. Serialise per-stack work with a queue or a per-stack lock; never run two deployments of the same stack at once.

Long deployments outlive requests. A 30-second HTTP timeout will kill a deployment mid-flight if you run it synchronously. Always use background tasks or a worker queue and return a job id.

Plugin installs are slow. install_plugin downloads on first run; pre-bake plugins into the container image so cold starts do not time out.

An inline program shares the interpreter. A sys.exit(), a signal.alarm, or an unhandled os._exit() inside a tenant's program terminates the API server, not just that deployment. Keep the program function narrow: build resources, return outputs, and push anything that might crash into a separate process.

Uvicorn with multiple workers breaks the in-process lock. --workers 4 forks four independent interpreters with four independent _LOCKS dictionaries. Either run one worker per container and scale horizontally with an external lock, or move deployments to a dedicated worker pool consuming from a queue.

SIGTERM during an update leaves a lock behind. A rolling deploy that kills the pod mid-up() terminates the CLI subprocess, and the backend lock outlives it. Trap SIGTERM, stop accepting new jobs, and give in-flight updates a grace period longer than your slowest deployment — or accept that operators will be running pulumi cancel by hand.

Stack config is not request state. set_config writes to the stack's settings file and persists. Passing a per-request value through config rather than through the program closure means the value silently becomes the default for every later deployment of that stack.

Operational Notes

Size the container for the deployment, not for the web server. A pulumi up spawns the CLI, one plugin process per provider in use, and holds the resource graph in the service's own heap while the inline program runs. A single AWS deployment of modest size comfortably needs 512 MB beyond the FastAPI baseline, and the plugin processes are what dominate. Two concurrent updates in a 512 MB container is how you get an OOM kill that looks, from the outside, like a random deployment failure.

Pre-install plugins at image build time. pulumi plugin install resource aws v6.0.0 in the Dockerfile removes the download from the request path entirely, and the install_plugin call in the program then becomes a no-op that verifies rather than fetches. Without it the first deployment after every scale-up pays a multi-hundred-megabyte download while a client is waiting.

Credentials belong to the process, not the image. The service should assume a role through its execution environment — an EKS service account, an ECS task role, an Azure workload identity — and let the provider plugin pick it up from the ambient chain. Where one API server deploys into many customer accounts, put the per-tenant role ARN in LocalWorkspaceOptions.env_vars so it reaches the subprocess without ever being set on the parent, which keeps a bug in one tenant's handler from borrowing another tenant's credentials.

Retire stacks properly. stack.destroy() removes the resources but leaves the stack and its history in the backend; stack.workspace.remove_stack(name) deletes the record. Doing only the first leaves a growing list of empty stacks that slow stack ls and confuse billing exports. Doing only the second orphans live cloud resources with no state to describe them, which is considerably worse — always destroy first, and check result.summary.result == "succeeded" before removing.

FAQ

Can I preview instead of deploying from the API?

Yes — call stack.preview() and return the change summary. This is how self-service portals show a plan before a human approves the real up().

Where does state live for inline programs?

In whatever backend the workspace is configured for. Point it at S3 or Pulumi Cloud so multiple service replicas share state, as discussed in choosing a state backend.

Is the Automation API the same as the CLI?

It drives the same engine and state, so a stack created via the API is fully manageable from the CLI and vice versa.

How do I return deployment progress to the caller without holding the connection open?

Accumulate on_output lines into the job record and expose a GET /jobs/{id} endpoint the client polls, or push the same lines onto a server-sent-events stream. Both work because on_output is called synchronously from the reader thread as the CLI emits each line, so there is no buffering delay to design around.

What happens if the service restarts while an update is running?

The CLI subprocess is killed with its parent and the backend keeps the stack lock. The next up() for that stack raises ConcurrentUpdateError until the lock is cleared. Recovery is a pulumi cancel followed by pulumi up --refresh, because the update may have created resources the state file never recorded.

Can one service instance deploy into several cloud accounts?

Yes, and LocalWorkspaceOptions.env_vars is the mechanism — set the account-specific role or subscription per stack so the value reaches the CLI subprocess and nothing else. Avoid mutating os.environ in the request handler; it is process-global and will leak across concurrent deployments.