Infrastructure as Code mit Pulumi: Jenseits von Terraform
Praktischer Vergleich von Pulumi und Terraform: wie echte Programmiersprachen Abstraktionen, Tests und Komposition ermöglichen, die HCL nicht kann.

Das Argument für echte Programmiersprachen in IaC
Terraform hat die Infrastrukturverwaltung revolutioniert. Davor bedeutete die Bereitstellung von Cloud-Ressourcen, sich durch Konsolen zu klicken oder brüchige Shell-Skripte zu schreiben. HCL brachte uns deklarative, reproduzierbare Infrastrukturdefinitionen, die versioniert und überprüft werden konnten.
Aber HCL ist eine Konfigurationssprache, keine Programmiersprache. Es bietet Variablen, Bedingungen und Schleifen – aber keine Funktionen mit Rückgabewerten, keine echten Abstraktionen, keine Typsysteme und keine Paketmanager. Je komplexer die Infrastruktur wird, desto stärker summieren sich diese Einschränkungen.
Pulumi verfolgt einen anderen Ansatz: Infrastruktur wird in TypeScript, Python, Go oder C# geschrieben. Dabei kommen dieselben Sprachfunktionen zum Einsatz wie im Anwendungscode – Interfaces, Klassen, Generics, Unit-Tests. Dieser Leitfaden untersucht, was dadurch möglich wird und in welchen Bereichen Terraform weiterhin im Vorteil ist.
Grundlegende Ressourcendefinition: Seite an Seite
Beginnen wir mit einem einfachen Vergleich: ein S3-Bucket mit Versionierung und Verschlüsselung in beiden Tools.
# Terraform HCL
resource "aws_s3_bucket" "data_bucket" {
bucket = "my-app-data-${var.environment}"
}
resource "aws_s3_bucket_versioning" "data_bucket" {
bucket = aws_s3_bucket.data_bucket.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data_bucket" {
bucket = aws_s3_bucket.data_bucket.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}// Pulumi TypeScript
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
const config = new pulumi.Config();
const environment = config.require("environment");
const dataBucket = new aws.s3.Bucket("data-bucket", {
bucket: `my-app-data-${environment}`,
versioning: { enabled: true },
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "AES256",
},
},
},
});Bei einfachen Ressourcen ist der Unterschied marginal. Pulumi ist etwas kompakter, weil zusammengehörige Konfigurationen unter der übergeordneten Ressource verschachtelt werden. Terraform teilt sie in separate Ressourcen auf. Die eigentlichen Vorteile zeigen sich jedoch erst, wenn man beginnt, Abstraktionen zu bauen.
Komponentenressourcen: wiederverwendbare Infrastrukturmuster
Terraform hat Module. Sie eignen sich zum Bündeln wiederverwendbarer Infrastruktur, sind aber durch das Typsystem und das Kompositionsmodell von HCL eingeschränkt. Pulumis Komponentenressourcen sind echte Klassen mit Interfaces, Validierung und Komposition.
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
interface SecureBucketArgs {
namePrefix: string;
environment: string;
retentionDays?: number;
enableAccessLogs?: boolean;
allowedPrincipals?: string[];
}
class SecureBucket extends pulumi.ComponentResource {
public readonly bucket: aws.s3.Bucket;
public readonly bucketArn: pulumi.Output<string>;
constructor(
name: string,
args: SecureBucketArgs,
opts?: pulumi.ComponentResourceOptions
) {
super("custom:storage:SecureBucket", name, {}, opts);
const retention = args.retentionDays ?? 90;
this.bucket = new aws.s3.Bucket(
`${name}-bucket`,
{
bucket: `${args.namePrefix}-${args.environment}`,
versioning: { enabled: true },
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "AES256",
},
},
},
lifecycleRules: [
{
enabled: true,
noncurrentVersionExpiration: { days: retention },
},
],
},
{ parent: this }
);
new aws.s3.BucketPublicAccessBlock(
`${name}-public-access-block`,
{
bucket: this.bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
},
{ parent: this }
);
if (args.allowedPrincipals && args.allowedPrincipals.length > 0) {
this.createBucketPolicy(name, args.allowedPrincipals);
}
if (args.enableAccessLogs) {
this.createAccessLogs(name, args.environment);
}
this.bucketArn = this.bucket.arn;
this.registerOutputs({ bucketArn: this.bucketArn });
}
private createBucketPolicy(name: string, principals: string[]): void {
new aws.s3.BucketPolicy(
`${name}-policy`,
{
bucket: this.bucket.id,
policy: this.bucket.arn.apply((arn) =>
JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { AWS: principals },
Action: ["s3:GetObject", "s3:PutObject"],
Resource: `${arn}/*`,
},
],
})
),
},
{ parent: this }
);
}
private createAccessLogs(name: string, environment: string): void {
new aws.s3.Bucket(
`${name}-access-logs`,
{
bucket: `${name}-access-logs-${environment}`,
acl: "log-delivery-write",
lifecycleRules: [
{
enabled: true,
expiration: { days: 30 },
},
],
},
{ parent: this }
);
}
}
// Usage is clean and type-safe
const appData = new SecureBucket("app-data", {
namePrefix: "myapp-data",
environment: "production",
retentionDays: 365,
enableAccessLogs: true,
allowedPrincipals: ["arn:aws:iam::123456789:role/app-role"],
});Das Interface SecureBucketArgs erzwingt den Vertrag zur Kompilierzeit. Vertippt man sich beim Namen einer Property, erkennt der TypeScript-Compiler das schon vor dem Deployment. Terraform validiert erst zur Plan-Zeit, was ein langsameres Feedback bedeutet.
Infrastrukturcode testen
Genau hier zahlt sich Pulumis sprachzentrierter Ansatz am meisten aus. Infrastrukturcode lässt sich mit denselben Werkzeugen per Unit-Test prüfen wie Anwendungscode.
import * as pulumi from "@pulumi/pulumi";
import { describe, it, expect, beforeAll } from "vitest";
// Mock Pulumi runtime for unit tests
pulumi.runtime.setMocks({
newResource: (args: pulumi.runtime.MockResourceArgs) => {
return { id: `${args.name}-id`, state: args.inputs };
},
call: (args: pulumi.runtime.MockCallArgs) => {
return args.inputs;
},
});
describe("SecureBucket", () => {
let bucket: SecureBucket;
beforeAll(() => {
bucket = new SecureBucket("test-bucket", {
namePrefix: "test",
environment: "testing",
retentionDays: 30,
});
});
it("should enable versioning", async () => {
const versioning = await new Promise<{ enabled: boolean }>((resolve) =>
bucket.bucket.versioning.apply((v) => resolve(v!))
);
expect(versioning.enabled).toBe(true);
});
it("should enable encryption", async () => {
const encryption = await new Promise((resolve) =>
bucket.bucket.serverSideEncryptionConfiguration.apply((c) =>
resolve(c)
)
);
expect(encryption).toBeDefined();
});
it("should set lifecycle rules based on retention", async () => {
const rules = await new Promise<aws.types.input.s3.BucketLifecycleRule[]>(
(resolve) =>
bucket.bucket.lifecycleRules.apply((r) => resolve(r || []))
);
expect(rules.length).toBeGreaterThan(0);
expect(rules[0].noncurrentVersionExpiration?.days).toBe(30);
});
});Das Testen von Terraform-Modulen erfordert Integrationstests mit echter Infrastruktur oder komplexe Mocking-Frameworks. Pulumis Mock-System ermöglicht es, die Ressourcenkonfiguration zu prüfen, ohne überhaupt etwas bereitzustellen. Dadurch verschieben sich Infrastrukturfehler nach vorn – sie werden in der CI entdeckt, nicht erst in der Produktion.
Dynamische Infrastruktur mit Schleifen und Bedingungen
HCLs for_each und count decken einfache Iterationen ab, aber komplexe bedingte Logik wird damit schnell unlesbar. Pulumi nutzt dagegen Standardkonstrukte der Sprache.
// ❌ Terraform: Complex conditional resource creation
// resource "aws_route53_record" "cert_validation" {
// for_each = {
// for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
// name = dvo.resource_record_name
// record = dvo.resource_record_value
// type = dvo.resource_record_type
// }
// }
// ...
// }// ✅ Pulumi: Standard TypeScript patterns
interface ServiceConfig {
name: string;
port: number;
replicas: number;
publicFacing: boolean;
healthCheckPath?: string;
}
const services: ServiceConfig[] = [
{ name: "api", port: 3000, replicas: 3, publicFacing: true, healthCheckPath: "/health" },
{ name: "worker", port: 4000, replicas: 2, publicFacing: false },
{ name: "gateway", port: 8080, replicas: 2, publicFacing: true, healthCheckPath: "/ping" },
];
// Create ECS services with conditional load balancer attachment
for (const service of services) {
const taskDef = new aws.ecs.TaskDefinition(`${service.name}-task`, {
family: service.name,
containerDefinitions: JSON.stringify([
{
name: service.name,
image: `${ecrRepo.repositoryUrl}:${service.name}-latest`,
portMappings: [{ containerPort: service.port }],
healthCheck: service.healthCheckPath
? {
command: [
"CMD-SHELL",
`curl -f http://localhost:${service.port}${service.healthCheckPath} || exit 1`,
],
interval: 30,
timeout: 5,
retries: 3,
}
: undefined,
},
]),
});
const ecsService = new aws.ecs.Service(`${service.name}-service`, {
cluster: cluster.arn,
taskDefinition: taskDef.arn,
desiredCount: service.replicas,
loadBalancers: service.publicFacing
? [
{
targetGroupArn: createTargetGroup(service).arn,
containerName: service.name,
containerPort: service.port,
},
]
: undefined,
});
}Der bedingte Health-Check und die optionale Anbindung des Load Balancers sind in TypeScript natürliche Muster. In HCL erfordert das verschachtelte dynamic-Blöcke und ternäre Ausdrücke, die die eigentliche Absicht verschleiern.
Stack-Referenzen: stackübergreifende Komposition
Große Infrastrukturen werden auf mehrere Stacks (oder Terraform-State-Dateien) aufgeteilt. Pulumis Stack-Referenzen ermöglichen den typisierten Datenaustausch zwischen Stacks.
// Network stack exports
export const vpcId = vpc.id;
export const privateSubnetIds = privateSubnets.map((s) => s.id);
export const publicSubnetIds = publicSubnets.map((s) => s.id);
// Application stack imports from network stack
const networkStack = new pulumi.StackReference("org/network/production");
const vpcId = networkStack.getOutput("vpcId");
const privateSubnetIds = networkStack.getOutput("privateSubnetIds");
const appCluster = new aws.ecs.Cluster("app-cluster", {});
const appService = new aws.ecs.Service("app-service", {
cluster: appCluster.arn,
networkConfiguration: {
subnets: privateSubnetIds as pulumi.Output<string[]>,
securityGroups: [appSecurityGroup.id],
},
});Terraform erreicht eine ähnliche Komposition mit terraform_remote_state-Datenquellen, verliert dabei aber die Typinformationen. Pulumis Stack-Referenzen behalten die Ausgabetypen des Quell-Stacks bei.
Wann Terraform weiterhin die Nase vorn hat
Pulumi ist nicht in jeder Hinsicht besser. Terraform hat in bestimmten Szenarien echte Vorteile.
Die Plan-Ausgabe von Terraform lässt sich bei der Infrastrukturprüfung besser lesen. Der Diff zeigt genau, was erstellt, geändert oder gelöscht wird, und zwar in einem Format, das Operations-Teams verstehen. Pulumis Preview-Funktion existiert, ist aber weniger ausgereift.
Das Ökosystem von Terraform ist größer: mehr Provider, mehr Module, mehr Beispiele aus der Community. Wer einen selten genutzten Cloud-Dienst einsetzt, findet mit höherer Wahrscheinlichkeit einen gut gepflegten Terraform-Provider dafür.
// Pulumi can consume Terraform providers via the bridge
// But native Pulumi providers are better when available
import * as cloudflare from "@pulumi/cloudflare";
const record = new cloudflare.Record("api-record", {
zoneId: zone.id,
name: "api",
type: "A",
value: loadBalancer.dnsName,
proxied: true,
});Für Teams mit bestehenden Terraform-Codebasen ist die Migration nicht trivial. Pulumi kann Terraform-State importieren und über seine Bridge sogar Terraform-Provider nutzen, aber eine vollständige Migration erfordert gezielten Aufwand.
Die wichtigsten Erkenntnisse
Infrastruktur als Code entwickelt sich über deklarative Konfigurationssprachen hinaus. Pulumis Ansatz – der Einsatz echter Programmiersprachen – ermöglicht Komponentenabstraktionen, Typsicherheit, Unit-Tests und Kompositionsmuster, die sich in HCL nicht ausdrücken lassen.
Die Wahl zwischen Pulumi und Terraform ist keine Entweder-oder-Entscheidung. Pulumi spielt seine Stärken aus, wenn die Infrastruktur komplex ist, wiederverwendbare Komponenten mit Validierung gebraucht werden und das Team bereits in TypeScript oder Python denkt. Terraform punktet, wenn ein größeres Ökosystem, eine einfachere Plan-Prüfung und eine breitere organisatorische Vertrautheit im Vordergrund stehen.
Entscheidend ist nicht, welches Tool man wählt, sondern dass die eigene Infrastruktur als Code definiert, versioniert, von Kolleginnen und Kollegen überprüft und vor dem Deployment getestet wird. Beide Tools bieten diese grundlegende Fähigkeit. Alles darüber hinaus ist eine Frage der Entwicklererfahrung und der organisatorischen Passung.


