Manage External SaaS Resources with Pulumi Dynamic Providers

Not every resource you depend on has a first-class Pulumi provider — feature flags, DNS records at a niche registrar, a SaaS project, or a monitoring dashboard often live behind a plain REST API. This guide, part of the dynamic providers and custom resources cluster under Pulumi patterns and provider management, shows how to wrap such an API in a typed Python dynamic provider so the external resource participates in pulumi up, state, and previews like any other.

Why This Matters

When a resource lives outside your state file, it drifts silently: someone edits it in a web console, and nothing in your infrastructure code notices. A dynamic provider brings that resource under the same lifecycle as your cloud resources — create, read, update, delete — so its desired shape is version-controlled and every change shows up in a preview diff.

Dynamic provider CRUD Dynamic provider CRUD: create then read/diff then update then delete create POST read/diff GET update PATCH delete DELETE
Pulumi maps each lifecycle event onto an HTTP verb against the SaaS API.

The cost is that you own the CRUD logic. Pulumi calls your create, diff, update, and delete methods; you translate those into REST calls and return an id plus the outputs Pulumi should record. Getting the diff and delete semantics right is what separates a toy from something you can run in production.

Prerequisites

Prerequisites Prerequisites: layered from Python down to SDK. Python Pulumi SDK
Prerequisites: the building blocks this section assembles.
  • Python 3.9+ with pulumi>=3.0 and requests>=2.31 pinned in your project
  • An API token for the SaaS platform exported as an environment variable (never hard-coded)
  • A resource with a stable id returned on creation — the id is how Pulumi finds it again

Verify your interpreter and SDK before writing any provider code:

# CLI: confirm the Pulumi Python SDK is importable
python -c "import pulumi, requests; print(pulumi.__version__)"

Implementing the CRUD Provider

A dynamic provider is a class implementing pulumi.dynamic.ResourceProvider. Each method receives and returns plain dictionaries; Pulumi handles serialisation into state. Keep the HTTP client thin and pass the token through resource inputs marked secret.

Provider call sequence Provider call sequence: pulumi up → Provider → SaaS API. pulumi up Provider SaaS API create POST id+url register
On create, Pulumi calls the provider, which POSTs to the API and registers the returned id.
# saas_flag.py — a dynamic provider for a feature-flag SaaS resource
# CLI: pulumi up   (Pulumi invokes these methods; do not call them yourself)
from typing import Any
import requests
import pulumi
from pulumi.dynamic import ResourceProvider, CreateResult, DiffResult, UpdateResult, Resource

API = "https://api.example-flags.com/v1/flags"

class _FlagProvider(ResourceProvider):
    def create(self, props: dict[str, Any]) -> CreateResult:
        r = requests.post(API, json={"key": props["key"], "on": props["on"]},
                          headers={"Authorization": f"Bearer {props['token']}"}, timeout=15)
        r.raise_for_status()
        flag = r.json()
        # Provider note: the returned id must be stable for the life of the resource.
        return CreateResult(id_=flag["id"], outs={**props, "url": flag["url"]})

    def diff(self, id_: str, old: dict, new: dict) -> DiffResult:
        changed = [k for k in ("on", "key") if old.get(k) != new.get(k)]
        # State implication: 'key' is immutable upstream, so a change forces replacement.
        return DiffResult(changes=bool(changed), replaces=["key"] if "key" in changed else [])

    def update(self, id_: str, old: dict, new: dict) -> UpdateResult:
        requests.patch(f"{API}/{id_}", json={"on": new["on"]},
                       headers={"Authorization": f"Bearer {new['token']}"}, timeout=15).raise_for_status()
        return UpdateResult(outs={**new})

    def delete(self, id_: str, props: dict) -> None:
        requests.delete(f"{API}/{id_}",
                        headers={"Authorization": f"Bearer {props['token']}"}, timeout=15)

class FeatureFlag(Resource):
    url: pulumi.Output[str]
    def __init__(self, name: str, key: str, on: bool, token: pulumi.Input[str], opts=None):
        super().__init__(_FlagProvider(), name,
                         {"key": key, "on": on, "token": token, "url": None}, opts)

Verification

Run a preview first: it should show the resource as a create with no errors, and a second pulumi up with no code changes should report zero updates — proof your diff is stable.

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: preview, apply, then confirm the second preview is a no-op
pulumi preview --diff
pulumi up --yes
pulumi preview   # expect: no changes

If the second preview wants to update every time, your diff is comparing fields the API normalises (e.g. it lower-cased the key). Normalise both sides before comparing.

Gotchas & Edge Cases

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks pulumi.Output. watch this boundary DELETE watch this boundary Edge Cases watch this boundary Output watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

Secrets leak into state. Inputs passed to a dynamic resource are serialised into state. Mark the token secret with pulumi.Output.secret or configure it through pulumi config set --secret so it is encrypted, as covered in securing Pulumi secrets.

Delete must be idempotent. If the resource was already removed upstream, a DELETE returning 404 should not fail the destroy. Swallow the not-found case.

Provider code is pickled. Pulumi serialises the provider class; keep module-level imports at the top and avoid closures over unpicklable objects.

FAQ

Do dynamic providers support import?

Yes, but you must implement the read method so pulumi import can hydrate state from the live resource. Without read, import and refresh cannot reconcile external changes.

How is this different from a ComponentResource?

A ComponentResource groups existing resources; a dynamic provider defines a brand-new resource type with its own CRUD against an external system.

Can I unit test the provider?

Yes — the CRUD methods are plain functions taking dicts. Mock requests and assert the payloads, exactly as in unit testing Pulumi programs with mocks.