Introduction
If you have worked with Terraform or OpenTofu before, you know the routine — you write HCL configuration files, and the tool turns them into real infrastructure. It works well, but it has a ceiling. You cannot use a real programming language. No loops that behave like actual loops, no functions that return values, no types that catch mistakes before you deploy. Everything is a workaround.
Pulumi takes a different approach. You write real code — TypeScript, Python, Go, or other languages — and that code provisions your infrastructure. The same language you use to build your application is the same language you use to manage the servers it runs on. For developers, this changes everything.
This tutorial walks through setting up Pulumi with TypeScript to provision your first Hetzner Cloud server. By the end, you will have a working server running on Hetzner, provisioned entirely through code you can read, version, and extend like any other TypeScript project.
Why Pulumi over Terraform / OpenTofu
This question comes up a lot, so it is worth addressing directly. Terraform and OpenTofu are excellent tools, and there is nothing wrong with using them. But there are real differences:
| Terraform / OpenTofu | Pulumi | |
|---|---|---|
| Language | HCL (custom syntax) | TypeScript, Python, Go, C# |
| Loops | for_each, count (limited) |
Real for loops |
| Conditionals | count = condition ? 1 : 0 |
Real if statements |
| Functions | Limited built-ins | Any function you can write |
| Type safety | No | Yes (with TypeScript) |
| Testing | Limited | Full unit testing support |
| IDE support | Basic | Full autocomplete, errors |
If your infrastructure is mostly static — a few servers, a load balancer, some DNS — Terraform is perfectly fine. If you are building complex infrastructure that changes, scales, or has conditional logic, Pulumi is worth the switch. The real programming language is not a gimmick; it genuinely makes complex infrastructure easier to manage.
Prerequisites
- Hetzner Cloud account with an API token
- Node.js 20 or later
- Pulumi CLI installed
Check Pulumi is installed:
pulumi version
Example values used in this tutorial
| Item | Value |
|---|---|
| Server name | my-first-server |
| Location | nbg1 (Nuremberg) |
| Server type | cx23 |
| OS image | ubuntu-24.04 |
Step 1 - Install the Pulumi CLI
If you have not installed Pulumi yet, the quickest way on macOS or Linux is:
curl -fsSL https://get.pulumi.com | shOn Windows, use the installer from pulumi.com/docs/install.
After installation, verify it works:
pulumi version
# v3.x.xPulumi stores stack state — the record of what infrastructure exists — either in Pulumi Cloud (free for individuals) or locally. This tutorial uses local state so you do not need to create an account:
pulumi login --localStep 2 - Create a new Pulumi project
Create a directory for your project and initialize it:
mkdir hetzner-pulumi-intro && cd hetzner-pulumi-intro
pulumi new typescript -yThe -y flag accepts all defaults. Pulumi creates the following structure:
hetzner-pulumi-intro/
├── index.ts ← your infrastructure code goes here
├── package.json
├── tsconfig.json
└── Pulumi.yaml ← project name and runtimeInstall the Hetzner Cloud provider:
npm install @pulumi/hcloudStep 3 - Configure your API token
Pulumi manages configuration and secrets per stack. Store your Hetzner API token as an encrypted secret:
export HCLOUD_TOKEN=your-api-token-here
pulumi config set --secret hcloudToken $HCLOUD_TOKENIf Pulumi prompts you for a secrets passphrase when using the local backend, choose one and keep it available for later Pulumi commands.
The --secret flag encrypts the value in the stack configuration file. It will never appear in plain text in your code or version control.
Step 4 - Write your first infrastructure code
Open index.ts and replace its contents with the following:
import * as pulumi from "@pulumi/pulumi";
import * as hcloud from "@pulumi/hcloud";
// Read the API token from stack config (encrypted)
const config = new pulumi.Config();
const hcloudToken = config.requireSecret("hcloudToken");
// Create the Hetzner Cloud provider
const provider = new hcloud.Provider("hcloud", {
token: hcloudToken,
});
// Provision a server
const server = new hcloud.Server("my-first-server", {
name: "my-first-server",
serverType: "cx23",
image: "ubuntu-24.04",
location: "nbg1",
labels: {
managedBy: "pulumi",
env: "tutorial",
},
}, { provider });
// Export the server's public IP so you can connect to it
export const serverIp = server.ipv4Address;
export const serverName = server.name;This is worth reading line by line because it is simpler than it looks:
pulumi.Config()reads values from your stack configuration — the token you set in Step 3hcloud.Providertells Pulumi to use your Hetzner API token for all resourceshcloud.Servercreates a server — the arguments map directly to Hetzner's APIexport constmakes the server IP available as a stack output after deployment
The key thing to notice: this is real TypeScript. You can put this in a function, loop over it to create multiple servers, import it from another file, or write a unit test for it. Everything you know about programming applies here.
Step 5 - Preview and deploy
Before creating anything, preview what Pulumi will do:
pulumi previewThe output shows exactly what will be created:
Previewing update (dev):
Type Name Plan
+ pulumi:pulumi:Stack hetzner-pulumi-intro-dev create
+ ├─ pulumi:providers:hcloud hcloud create
+ └─ hcloud:index:Server my-first-server create
Resources:
+ 3 to createNothing has been created yet. The preview is free and safe to run as many times as you want. Once you are happy with the plan:
pulumi upPulumi shows the same preview and asks for confirmation. Select yes and press Enter:
Updating (dev):
Type Name Status
+ pulumi:pulumi:Stack hetzner-pulumi-intro-dev created
+ ├─ pulumi:providers:hcloud hcloud created
+ └─ hcloud:index:Server my-first-server created
Outputs:
serverIp : "203.0.113.10"
serverName: "my-first-server"
Resources:
+ 3 created
Duration: 12sYour server is running. The public IP appears in the outputs.
Connecting to your server
If you did not add an SSH key, Hetzner sends the root password to your account email automatically. Connect using:
ssh root@$(pulumi stack output serverIp)
# enter the password from your emailFor production servers, always use SSH keys instead of passwords. The next tutorials in this series show how to add an SSH key to your Pulumi configuration.
Step 6 - Inspect the stack
At any point you can check what Pulumi knows about your infrastructure:
# See all outputs
pulumi stack output
# See a specific output
pulumi stack output serverIpYou can also open the Hetzner Console and see the server there — Pulumi and the Console show the same thing because they both talk to the same Hetzner API.
Step 7 - Modify the infrastructure
This is where Pulumi starts to show its value. Change the label in your index.ts:
labels: {
managedBy: "pulumi",
env: "tutorial",
updated: "yes", // add this line
},Run pulumi preview again:
~ update hcloud:index:Server my-first-server [diff: ~labels]Pulumi detected exactly what changed — only the labels, nothing else. Run pulumi up to apply it. The server is updated in place, no recreation needed.
This is the update cycle in practice: change the code, preview, apply. The state file tracks what exists so Pulumi only touches what changed.
Step 8 - Destroy the infrastructure
When you are done:
pulumi destroyPulumi shows what will be deleted and asks for confirmation. After confirming, the server is removed from Hetzner and you stop being billed for it.
pulumi stack rm devThis removes the local stack state file.
Conclusion
You provisioned a Hetzner Cloud server using real TypeScript code. The workflow — write, preview, apply — is the same pattern you will use for everything from a single server to a full Kubernetes cluster.
A few things worth remembering from this tutorial:
pulumi previewis always safe — it shows the plan without making changes- Secrets are encrypted in stack config — never put tokens in your code
- Exports make outputs accessible via
pulumi stack output - The same language skills you already have apply directly to infrastructure
Full source code for this series: Getting Started with Pulumi and TypeScript on Hetzner Cloud