Infraestructura como código con Pulumi: más allá de Terraform
Comparación práctica entre Pulumi y Terraform: cómo los lenguajes de propósito general permiten abstracciones, pruebas y composición imposibles en HCL.

El argumento a favor de los lenguajes de programación reales en IaC
Terraform revolucionó la gestión de infraestructura. Antes de su llegada, aprovisionar recursos en la nube significaba navegar consolas a golpe de clic o escribir scripts de shell frágiles. HCL nos dio definiciones de infraestructura declarativas y reproducibles que podían versionarse y revisarse.
Pero HCL es un lenguaje de configuración, no un lenguaje de programación. Tiene variables, condicionales y bucles, pero carece de funciones que devuelvan valores, de abstracciones reales, de sistemas de tipos y de gestores de paquetes. A medida que la infraestructura se vuelve más compleja, estas limitaciones se acumulan.
Pulumi adopta un enfoque distinto: escribir la infraestructura en TypeScript, Python, Go o C#. Usar las mismas características del lenguaje que empleas en el código de aplicación: interfaces, clases, genéricos, pruebas unitarias. Esta guía explora qué posibilita ese enfoque y en qué casos Terraform todavía mantiene ventajas.
Definición básica de recursos: lado a lado
Empecemos con una comparación simple: un bucket de S3 con versionado y cifrado en ambas herramientas.
# 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",
},
},
},
});Para recursos simples, la diferencia es marginal. Pulumi resulta ligeramente más conciso porque las configuraciones relacionadas se anidan bajo el recurso padre. Terraform las separa en recursos independientes. Pero las verdaderas ventajas aparecen cuando empiezas a construir abstracciones.
Recursos de componente: patrones de infraestructura reutilizables
Terraform tiene módulos. Funcionan bien para empaquetar infraestructura reutilizable, pero están limitados por el sistema de tipos y el modelo de composición de HCL. Los recursos de componente de Pulumi son clases reales con interfaces, validación y composición.
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"],
});La interfaz SecureBucketArgs impone el contrato en tiempo de compilación. Si escribes mal el nombre de una propiedad, el compilador de TypeScript lo detecta antes del despliegue. Terraform valida en tiempo de plan, lo que ofrece una retroalimentación más lenta.
Cómo probar código de infraestructura
Aquí es donde el enfoque de Pulumi centrado en el lenguaje rinde sus mayores frutos. Puedes escribir pruebas unitarias para el código de infraestructura con las mismas herramientas que usas para el código de aplicación.
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);
});
});Probar módulos de Terraform requiere pruebas de integración con infraestructura real o frameworks de mocking complejos. El sistema de mocks de Pulumi permite verificar la configuración de los recursos sin desplegar nada. Esto desplaza los errores de infraestructura hacia etapas más tempranas, detectándolos en CI y no en producción.
Infraestructura dinámica con bucles y condicionales
Los constructos for_each y count de HCL manejan la iteración básica, pero la lógica condicional compleja se vuelve ilegible rápidamente. Pulumi utiliza construcciones estándar del lenguaje.
// ❌ 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,
});
}La verificación de salud condicional y la asociación opcional del balanceador de carga son patrones naturales en TypeScript. En HCL, esto requiere bloques dynamic anidados y expresiones ternarias que oscurecen la intención.
Referencias de stack: composición entre stacks
La infraestructura de gran tamaño se divide en múltiples stacks (o archivos de estado de Terraform). Las referencias de stack de Pulumi permiten compartir datos tipados entre 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 logra una composición similar con las fuentes de datos terraform_remote_state, pero pierde la información de tipos. Las referencias de stack de Pulumi conservan los tipos de salida del stack de origen.
Cuándo Terraform sigue ganando
Pulumi no es superior en todos los casos. Terraform tiene ventajas legítimas en escenarios específicos.
La salida del plan de Terraform es más legible para revisar la infraestructura. El diff muestra exactamente qué se creará, modificará o destruirá en un formato que los equipos de operaciones comprenden. La vista previa de Pulumi existe, pero es menos madura.
El ecosistema de Terraform es más grande: más proveedores, más módulos, más ejemplos de la comunidad. Si utilizas un servicio de nube poco común, es más probable que exista un proveedor de Terraform para él y que esté bien mantenido.
// 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,
});Para equipos con bases de código de Terraform ya existentes, la migración no es trivial. Pulumi puede importar el estado de Terraform e incluso consumir proveedores de Terraform mediante su puente, pero una migración completa requiere un esfuerzo dedicado.
Conclusiones clave
La infraestructura como código está evolucionando más allá de los lenguajes de configuración declarativos. El enfoque de Pulumi (usar lenguajes de programación reales) habilita abstracciones de componentes, seguridad de tipos, pruebas unitarias y patrones de composición que HCL no puede expresar.
La elección entre Pulumi y Terraform no es binaria. Pulumi destaca cuando la infraestructura es compleja, cuando necesitas componentes reutilizables con validación y cuando tu equipo ya piensa en TypeScript o Python. Terraform destaca cuando valoras un ecosistema más amplio, una revisión de planes más simple y una familiaridad organizacional más extendida.
Lo que más importa no es qué herramienta elijas, sino que tu infraestructura esté definida en código, versionada, revisada por colegas y probada antes del despliegue. Ambas herramientas ofrecen esa capacidad fundamental. Todo lo demás tiene que ver con la experiencia de desarrollo y el ajuste organizacional.


