2.3. Resources

In this lab you declare your first real-world resources using the Docker provider — a local nginx container with a published port.

Step 1 — Add the Docker provider

Open requirements.txt and add pulumi_docker:

pulumi==3.243.0
pulumi_random==4.21.0
pulumi_docker==5.0.0

Install the updated dependencies:

source venv/bin/activate
pip install -r requirements.txt

Explanation

The Docker provider translates Pulumi resource declarations into Docker API calls — it can pull images, create containers, build images, and manage networks and volumes, all as Pulumi-managed resources.

Like any Pulumi provider, it is a standard Python package. Add it to requirements.txt, pin the version, and pip install — the same workflow you used for pulumi_random.

Docker must be running

The Docker provider communicates with the local Docker daemon. Make sure Docker Desktop (or the Docker Engine) is running before running pulumi up.


Step 2 — Pull an nginx image

Replace __main__.py with the following:

import pulumi
import pulumi_docker as docker


def main():
    image = docker.RemoteImage(
        "nginx-image",
        name="nginxdemos/hello:0.4-plain-text"
    )
    pulumi.export("image_id", image.id)


main()

Run a preview to see the plan:

pulumi preview

Explanation

Every Pulumi resource call shares the same three-part signature:

ResourceType("logical-name", arg1=value1, arg2=value2, ...)
PartExamplePurpose
Typedocker.RemoteImageThe resource class — determines which API the provider calls
Logical name"nginx-image"Pulumi’s stable identifier for this resource in state
Argsname="nginxdemos/hello:0.4-plain-text"Provider-specific configuration for the resource

docker.RemoteImage instructs Docker to pull nginxdemos/hello:0.4-plain-text and records the image reference in Pulumi state.


Step 3 — Add a Container with a port mapping

Extend __main__.py to declare a container that uses the image:

import pulumi
import pulumi_docker as docker


def main():
    image = docker.RemoteImage(
        "nginx-image",
        name="nginxdemos/hello:0.4-plain-text",
        keep_locally=True
    )

    container = docker.Container(
        "nginx-container",
        image=image.image_id,
        ports=[docker.ContainerPortArgs(internal=80, external=8080)]
    )

    pulumi.export("container_name", container.name)


main()

Apply the changes:

pulumi up

Once the stack is up, verify the container is serving:

curl http://localhost:8080

You should see a plain-text response with the server address and hostname.

Explanation

The container declaration introduces two key concepts.

Chaining resourcesimage=image.image_id passes the RemoteImage output directly into the container declaration. Pulumi sees this reference and ensures the image is pulled before the container is created. No explicit ordering is needed when you pass an output directly.

Port mappingContainerPortArgs(internal=80, external=8080) maps port 80 inside the container to port 8080 on your machine — the Docker equivalent of -p 8080:80.

keep_locally=True tells the provider not to delete the pulled image when the stack is destroyed — useful during development where images are large and slow to re-pull.


Step 4 — Understand resource naming

After pulumi up completes, list the running containers:

docker ps --format 'table {{.Names}}\t{{.Image}}'

Compare the container name in the output with the logical name "nginx-container" you declared in code.

Explanation

Every resource has two distinct names:

NameExampleSet by
Logical namenginx-containerYou — the first argument to the resource constructor
Physical namenginx-container-a1b2c3Provider — derived from the logical name plus a random suffix

Pulumi uses the logical name to track the resource across pulumi up runs. The physical name is what actually appears in Docker (or Azure, or AWS) — the random suffix is added by the provider to avoid collisions.

Why the random suffix?

When Pulumi replaces a resource (delete-and-recreate), the new physical resource must exist briefly alongside the old one. A unique suffix prevents name collisions during that overlap window.

If your resource can’t tolerate two instances existing at the same time, you can tell Pulumi to delete the old one first with the delete_before_replace resource option — covered in the next step.

You can pin the physical name by setting the provider’s name argument — for a container: name="my-nginx". Do this only when the name must be stable and predictable; you lose Pulumi’s collision-avoidance guarantee.


Step 5 — Protect a resource from deletion

Add protect=True to the container:

container = docker.Container(
    "nginx-container",
    image=image.image_id,
    ports=[docker.ContainerPortArgs(internal=80, external=8080)],
    opts=pulumi.ResourceOptions(protect=True)
)

Apply the change:

pulumi up

Now try to tear down the stack:

pulumi destroy

Observe the error. Then remove protect=True from the code and run pulumi up once more to lift the protection — pulumi destroy will now succeed.

Explanation

Resource options are passed via opts=pulumi.ResourceOptions(...) and control lifecycle, dependencies, and protection — independent of any specific provider.

OptionEffect
protect=TrueBlocks pulumi destroy on this resource — useful for databases and storage accounts
ignore_changes=["field"]Skips updates when the named fields drift from the declared values
depends_on=[res1, res2]Forces Pulumi to wait for the listed resources even without a direct output reference
parent=other_resourceDeclares logical ownership; child appears nested under parent in the preview tree

protect=True causes pulumi destroy to fail with an error like:

error: resource 'urn:…:nginx-container' is protected and cannot be deleted

The only way to remove a protected resource is to set protect=False (or remove the option entirely) and run pulumi up first — making deletion a deliberate two-step action. In the Azure labs you will add this to the PostgreSQL server and ACR registry.

ignore_changes is useful when a field is managed outside Pulumi — for example, a tag that operations staff update directly in the Azure portal. Listing it prevents Pulumi from reverting those manual changes on the next pulumi up.

depends_on vs output chaining

Prefer passing outputs directly between resources over depends_on. Direct references give Pulumi the actual value (not just an ordering hint) and make the dependency obvious to a reader.

Use depends_on only when there is no value to pass — for example, ensuring a namespace exists before applying a Kubernetes manifest that references it by string.