Pulumi is an infrastructure as code tool that lets you describe infrastructure in general-purpose languages such as Python, TypeScript, Go or C#, instead of a dedicated configuration language. You get loops, functions, packages and your usual editor and test tools, while Pulumi's engine still computes a plan (a preview), applies only the differences and tracks what it manages in state. In this tutorial you will install Pulumi on Ubuntu 24.04, write a Python program that runs Nginx containers through the Docker provider, use stacks for separate environments, store configuration and encrypted secrets, and compare the result with Terraform. Everything runs on one server, with no cloud account.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
  • A non-root user with sudo privileges.
  • Docker Engine installed from Docker's official repository, with your user in the docker group (log out and back in after adding it).
  • Basic knowledge of Python.

Confirm that Docker works without sudo:

docker run --rm hello-world

The output should include Hello from Docker!.

Step 1 - Installing Pulumi and Python tooling

Pulumi's Python programs run in a virtual environment, so install the venv module along with pip:

sudo apt update
sudo apt install python3-venv python3-pip curl

Pulumi distributes the CLI through an install script that places the binaries in ~/.pulumi/bin and adds that directory to your PATH. Download the script first and read it before running it:

curl -fsSL https://get.pulumi.com -o install-pulumi.sh
less install-pulumi.sh
sh install-pulumi.sh

Reload your shell configuration so that the new PATH takes effect, then check the version:

source ~/.bashrc
pulumi version

The command prints the installed version, for example v3.197.0. Any recent 3.x release works. To upgrade later, run the same install script again.

Step 2 - Choosing where Pulumi stores state

Like Terraform, Pulumi keeps state that maps your program's resources to real objects. By default it stores state in Pulumi Cloud, a hosted service that requires an account. It can also use a self-managed backend: a local directory, or an object storage bucket such as S3.

For this tutorial, use the local backend, which stores state under ~/.pulumi:

pulumi login --local
Logged in to your-hostname as your_user (file://~)

With a self-managed backend, Pulumi encrypts secrets with a passphrase per stack. Export it so the commands below do not prompt for it every time, replacing your_passphrase with a strong value you store in a password manager:

export PULUMI_CONFIG_PASSPHRASE='your_passphrase'

If you lose the passphrase, the secrets stored in the stack cannot be decrypted. For teams, a shared bucket backend (pulumi login s3://your_bucket) or Pulumi Cloud replaces the local directory, and the rest of this tutorial works the same way.

Step 3 - Creating a Python project

Create a directory and generate a project from the built-in python template:

mkdir ~/web-stack
cd ~/web-stack
pulumi new python --name web-stack --stack dev --yes

The command creates a stack called dev, a virtual environment in venv/ and these files:

FilePurpose
Pulumi.yamlProject name, runtime (python) and virtual environment settings
__main__.pyThe program that declares your infrastructure
requirements.txtPython dependencies, starting with the pulumi SDK
.gitignoreExcludes venv/ and other generated files from Git

Add the Docker provider package to the dependencies and install it into the project's virtual environment:

echo "pulumi-docker>=4.0.0,<5.0.0" >> requirements.txt
pulumi install

Verify that the package is available:

venv/bin/pip show pulumi-docker

The output starts with Name: pulumi_docker followed by an installed 4.x version.

Step 4 - Writing the infrastructure program

The program below reads a map of sites from configuration and runs one Nginx container per site. The loop is plain Python, which is where Pulumi differs most from a declarative language: you can use any control flow, function or library to build the resource graph.

Open the program:

nano __main__.py

Replace its contents with:

import pulumi
import pulumi_docker as docker

config = pulumi.Config()
sites = config.require_object("sites")        # for example {"blog": 8081, "shop": 8082}
api_token = config.require_secret("apiToken")  # stays encrypted in config and state
stack = pulumi.get_stack()

image = docker.RemoteImage(
    "nginx",
    name="nginx:stable-alpine",
    keep_locally=True,
)

urls = {}
for site, port in sites.items():
    docker.Container(
        f"web-{site}",
        name=f"{stack}-{site}",
        image=image.image_id,
        restart="unless-stopped",
        envs=[
            f"SITE_NAME={site}",
            pulumi.Output.concat("API_TOKEN=", api_token),
        ],
        ports=[docker.ContainerPortArgs(internal=80, external=int(port))],
    )
    urls[site] = f"http://localhost:{port}"

pulumi.export("urls", urls)

A few points about this code:

  • The first argument of each resource ("nginx", "web-blog") is its logical name. Pulumi uses it to track the resource in state, so it must be unique within the stack and stable across runs.
  • image.image_id is an output: its value is only known after the image is pulled. Pulumi records the dependency automatically and pulls the image before creating the containers.
  • pulumi.Output.concat builds a string from an output. Because api_token is a secret, the result is also treated as a secret and masked in the CLI.
  • Container names include the stack name, so several stacks can run on the same host without clashing.

Step 5 - Setting configuration and secrets

Configuration is stored per stack in Pulumi.<stack>.yaml. Set the sites map with --path, which writes nested keys:

pulumi config set --path 'sites.blog' 8081
pulumi config set --path 'sites.shop' 8082

Set the token with --secret, so that it is encrypted before it is written to disk. Replace your_api_token with any test value:

pulumi config set --secret apiToken your_api_token

Look at the resulting file:

cat Pulumi.dev.yaml
encryptionsalt: v1:...
config:
  web-stack:apiToken:
    secure: v1:3Tq1...
  web-stack:sites:
    blog: 8081
    shop: 8082

Keys are namespaced with the project name, and the secret is stored only as ciphertext, so this file is safe to commit to Git.

Step 6 - Previewing and deploying

pulumi preview runs your program and shows what would change, without changing anything:

pulumi preview

The preview lists four resources to create: the stack itself, the nginx image and the web-blog and web-shop containers, and ends with a summary:

Resources:
    + 4 to create

The stack counts as a resource of its own. Deploy with pulumi up, which shows the same preview and asks for confirmation:

pulumi up

Select yes. When it finishes, it prints the outputs:

Outputs:
    urls: {
        blog: "http://localhost:8081"
        shop: "http://localhost:8082"
    }

Resources:
    + 4 created

Verify the containers and the secret inside one of them:

docker ps --format '{{.Names}}\t{{.Ports}}'
curl -sI http://localhost:8081 | head -n 1
docker exec dev-blog printenv API_TOKEN
dev-shop	0.0.0.0:8082->80/tcp
dev-blog	0.0.0.0:8081->80/tcp
HTTP/1.1 200 OK
your_api_token

Read stack outputs at any time, for example from a script:

pulumi stack output urls --json

Step 7 - Changing infrastructure

Change the port of the shop site and preview the change with a detailed diff:

pulumi config set --path 'sites.shop' 8083
pulumi preview --diff

The Docker provider cannot change the published port of a running container, so the preview shows a replacement (+-) of web-shop and leaves web-blog untouched. Apply it with pulumi up, then confirm that curl -sI http://localhost:8083 answers.

Adding a site is just another pulumi config set --path 'sites.docs' 8084 followed by pulumi up: the program does not change.

Step 8 - Using stacks for separate environments

A stack is an independent instance of the same program, with its own configuration and state. Create a prod stack alongside dev:

pulumi stack init prod
pulumi config set --path 'sites.blog' 9081
pulumi config set --secret apiToken your_prod_api_token
pulumi up

pulumi stack init selects the new stack, so the config set commands write to Pulumi.prod.yaml. List the stacks and switch between them with select:

pulumi stack ls
pulumi stack select dev
NAME   LAST UPDATE     RESOURCE COUNT
dev*   5 minutes ago   4
prod   1 minute ago    3

The asterisk marks the current stack. docker ps now shows dev-blog, dev-shop and prod-blog running side by side.

Step 9 - Cleaning up

pulumi destroy deletes every resource in the current stack. Destroy both stacks, then remove the prod stack record and its configuration:

pulumi destroy --stack dev --yes
pulumi destroy --stack prod --yes
pulumi stack rm prod --yes

The Nginx image stays on disk because of keep_locally=True. Remove it with docker image rm nginx:stable-alpine if you no longer need it.

Pulumi compared with Terraform

Both tools follow the same model: declare the desired state, preview a diff, apply it and record the result in state. The differences are in how you write and operate the code:

PulumiTerraform
LanguagePython, TypeScript, Go, C#, Java or YAMLHCL, a declarative configuration language
Loops and logicNative language features, functions, classes and packagesfor_each, count, dynamic blocks and built-in functions
ReuseComponent resources distributed as language packagesModules from Git or the Terraform Registry
EnvironmentsStacks, each with its own config fileWorkspaces or separate root modules
Secrets in stateEncrypted per stackStored in plain text; protect the backend
State storagePulumi Cloud or self-managed (local, S3, Azure Blob, Google Cloud Storage)Local file or backends such as S3, PostgreSQL, HCP Terraform
TestingUnit tests with the language's framework (pytest, Jest, Go test)terraform test with .tftest.hcl files
LicenseApache 2.0Business Source License (OpenTofu is the open source fork)

Choose Pulumi when your team already writes Python or TypeScript and wants to use the same language, abstractions and tests for infrastructure, or when the infrastructure needs real logic. Choose Terraform when you want the largest ecosystem of modules and examples, a language that operations and development teams can both read without programming experience, or when your organization already standardizes on it.

Troubleshooting

pulumi: command not found after installing: the installer added ~/.pulumi/bin to ~/.bashrc, which only applies to new shells. Run source ~/.bashrc or log in again.

passphrase must be set with PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE: the local backend needs the passphrase for every command that reads secrets. Export the variable in the current shell, or point PULUMI_CONFIG_PASSPHRASE_FILE at a file readable only by your user.

ModuleNotFoundError: No module named 'pulumi_docker': the package is not in the project's virtual environment. Check that it is listed in requirements.txt and run pulumi install again.

Missing required configuration variable 'web-stack:sites': the current stack has no sites value. Check which stack is selected with pulumi stack ls and set its configuration.

Conclusion

You installed Pulumi, wrote a Python program that deploys containers from a configuration map, stored an encrypted secret, and ran two stacks of the same program side by side. The same workflow of config, preview and up applies unchanged when you replace the Docker provider with a cloud provider package.

As next steps, move state to a shared bucket backend with pulumi login s3://your_bucket so your team can collaborate, wrap the container logic in a pulumi.ComponentResource class to reuse it across projects, and add unit tests with Pulumi's mocks and pytest.