I built three IaC providers for Zerops. Here's which one I'd use.
I wrote the Terraform, Pulumi and Alchemy providers for Zerops because I want to describe a whole project in code, deploy it, and see exactly what will change before I touch it again.
Terraform gave me that in HCL. The Pulumi bridge let me reuse the provider in programming languages. With Alchemy, I could put the application source and its environment directly on the service. Each gives me a different way to work with the same platform.
It started with agents. An agent working on a Zerops project needs infrastructure it can describe, diff and show to a human before anything changes, and the human approving it needs the same thing. Terraform was the obvious first format. Pulumi and Alchemy came next because different crowds have different favourite tools, and Zerops is about developer experience: you should be able to use the one you're comfortable with.
I'll use one document API to show where those choices become visible: Node.js, PostgreSQL, Valkey and a private object-storage bucket. Same services, same application, same route. I care more about how I will maintain this project six months later than how few lines it takes to create it.
One project, three ways to describe it
The project is called documents, in LIGHT mode. The API runs Node.js 22 with one container, PostgreSQL 16 and Valkey 7.2 are single-node, and the private bucket has a 1 GB quota. HTTP goes from documents.example.com to api:3000; the database and cache have no public route.
Use your Node application in app/, with a committed package-lock.json and npm start listening on 0.0.0.0:3000. All three providers use this app/zerops.yml:
zerops:
- setup: api
build:
base: nodejs@22
buildCommands:
- npm ci
deployFiles: ./
run:
base: nodejs@22
ports:
- port: 3000
protocol: tcp
httpSupport: true
start: npm start
The app reads DATABASE_URL, CACHE_URL and the four S3 variables below. Replace the example domain and configure DNS separately.
Keep the infrastructure files and state outside app/. Terraform and Pulumi directory uploads do not honor .gitignore; Alchemy supports .deployignore. Review what you upload, especially credentials and dependencies.
I deployed all three versions with a probe application and a generated Zerops subdomain. Each returned HTTP 200 after a PostgreSQL query, a Valkey ping and an S3 write, read and delete using all six environment variables. OpenTofu and Pulumi reported no changes afterward; Alchemy's plan and drift check did too.
Terraform: the resource graph is right there
I started with the native Terraform provider. Version 0.1.2 is in both the Terraform and OpenTofu registries, with the same zeropsio/zerops source.
I like being able to follow the project from top to bottom. Create the services, wire the environment, deploy the app, publish the route. HCL makes each of those a resource:
terraform {
required_providers {
zerops = {
source = "zeropsio/zerops"
version = "0.1.2"
}
}
}
provider "zerops" {}
resource "zerops_project" "documents" {
name = "documents"
mode = "LIGHT"
}
resource "zerops_service" "api" {
project_id = zerops_project.documents.id
hostname = "api"
type = "nodejs@22"
horizontal_autoscaling = {
min_containers = 1
max_containers = 1
}
}
resource "zerops_service" "db" {
project_id = zerops_project.documents.id
hostname = "db"
type = "postgresql:single@16"
vertical_autoscaling = { min_disk_gb = 2 }
}
resource "zerops_service" "cache" {
project_id = zerops_project.documents.id
hostname = "cache"
type = "valkey:single@7.2"
}
resource "zerops_object_storage" "assets" {
project_id = zerops_project.documents.id
hostname = "assets"
quota_gb = 1
policy = "private"
}
locals {
app_env = {
DATABASE_URL = "$${db_connectionString}"
CACHE_URL = "$${cache_connectionString}"
S3_ENDPOINT = "$${assets_apiUrl}"
S3_BUCKET = "$${assets_bucketName}"
S3_ACCESS_KEY_ID = "$${assets_accessKeyId}"
S3_SECRET_ACCESS_KEY = "$${assets_secretAccessKey}"
}
}
resource "zerops_service_env" "app" {
for_each = local.app_env
service_id = zerops_service.api.id
key = each.key
content = each.value
depends_on = [zerops_service.db, zerops_service.cache, zerops_object_storage.assets]
}
resource "zerops_service_deploy" "api" {
service_id = zerops_service.api.id
source_dir = "${path.module}/app"
zerops_yaml = file("${path.module}/app/zerops.yml")
setup = "api"
depends_on = [zerops_service_env.app]
}
resource "zerops_public_http_routing" "api" {
project_id = zerops_project.documents.id
domains = ["documents.example.com"]
ssl_enabled = true
location = [{
path = "/"
port = 3000
service_id = zerops_service.api.id
}]
depends_on = [zerops_service_deploy.api]
}
$${db_connectionString} passes a literal ${db_connectionString} to Zerops, which resolves it for the application. HCL cannot infer the database dependency from that string, so depends_on supplies it. The same applies to cache and storage.
The :single in postgresql:single@16 matters too. Zerops interprets an unqualified postgresql@16 as HA. I made an omitted mode a planning error in provider 0.1.2 because choosing the database topology should be explicit. Changing the mode replaces the service.
Supply ZEROPS_TOKEN through your shell or CI secrets, then use terraform init and terraform plan, or the equivalent tofu commands. Commit the dependency lockfile. For an approved CI change, save the plan and apply that artifact with terraform apply reviewed.tfplan.
Pulumi: keep the provider, change the language
For Pulumi, I exposed the Terraform implementation through a bridge. Pulumi 0.1.1 embeds Terraform provider 0.1.2, including the explicit-mode check and resource floors. You don't need the Terraform CLI.
That reuse matters to me. A PostgreSQL sizing fix should reach both providers without two implementations slowly disagreeing about the same API.
Install @zerops/pulumi@0.1.1 and @pulumi/pulumi, with TypeScript and Node types for development. In a Pulumi project with runtime: nodejs, the same topology looks like this:
import * as fs from "node:fs";
import * as zerops from "@zerops/pulumi";
const project = new zerops.Project("documents", {
name: "documents", mode: "LIGHT",
});
const api = new zerops.Service("api", {
projectId: project.id, hostname: "api", type: "nodejs@22",
horizontalAutoscaling: { minContainers: 1, maxContainers: 1 },
});
const db = new zerops.Service("db", {
projectId: project.id, hostname: "db", type: "postgresql:single@16",
verticalAutoscaling: { minDiskGb: 2 },
});
const cache = new zerops.Service("cache", {
projectId: project.id, hostname: "cache", type: "valkey:single@7.2",
});
const assets = new zerops.ObjectStorage("assets", {
projectId: project.id, hostname: "assets", quotaGb: 1, policy: "private",
});
const variables = Object.entries({
DATABASE_URL: "${db_connectionString}",
CACHE_URL: "${cache_connectionString}",
S3_ENDPOINT: "${assets_apiUrl}",
S3_BUCKET: "${assets_bucketName}",
S3_ACCESS_KEY_ID: "${assets_accessKeyId}",
S3_SECRET_ACCESS_KEY: "${assets_secretAccessKey}",
}).map(([key, content]) => new zerops.ServiceEnv(key, {
serviceId: api.id, key, content,
}, { dependsOn: [db, cache, assets] }));
const deploy = new zerops.ServiceDeploy("deploy", {
serviceId: api.id, sourceDir: "./app", setup: "api",
zeropsYaml: fs.readFileSync("./app/zerops.yml", "utf8"),
}, { dependsOn: variables });
new zerops.PublicHttpRouting("route", {
projectId: project.id, domains: ["documents.example.com"], sslEnabled: true,
locations: [{ path: "/", port: 3000, serviceId: api.id }],
}, { dependsOn: [deploy] });
The resource graph is familiar, but now I can build it with ordinary functions and loops. Quoted strings preserve the Zerops references; JavaScript template literals would interpolate them.
Use pulumi login for Pulumi Cloud or pulumi login --local, initialize a stack, set pulumi config set --secret zerops:token, then run pulumi preview and pulumi up. The state backend and the Zerops API have separate authentication.
For a smaller app, the WebApp component groups a project, application, deployment, public subdomain and optional PostgreSQL. I used primitives here because this project also has Valkey, storage and a custom route.
Alchemy: the application belongs on the service
With Alchemy, I put source and environment into Service. That is the version I enjoy reading when I am working on the application: the runtime, the code to upload and its connections are together.
Install @zerops/alchemy@0.2.0, alchemy@2.0.0-beta.84, effect@4.1.2 and @effect/platform-bun@4.1.2. Keep that exact Alchemy version pinned; the Bun CLI also needs its platform peer. Save this as alchemy.run.ts and supply ZEROPS_TOKEN:
import * as Alchemy from "alchemy";
import * as Effect from "effect/Effect";
import * as Zerops from "@zerops/alchemy";
export default Alchemy.Stack("Documents", {
providers: Zerops.providers(),
state: Alchemy.localState(), // Plaintext JSON; demonstration only.
}, Effect.gen(function* () {
const project = yield* Zerops.Project("Project", {
name: "documents", mode: "LIGHT",
});
const db = yield* Zerops.Postgres("Database", {
project, hostname: "db", version: "16", mode: "NON_HA",
});
const cache = yield* Zerops.Valkey("Cache", {
project, hostname: "cache", version: "7.2", mode: "NON_HA",
});
const assets = yield* Zerops.ObjectStorage("Assets", {
project, hostname: "assets", quotaGb: 1, policy: "private",
});
const api = yield* Zerops.Service("Api", {
project, hostname: "api", type: "nodejs@22",
source: { dir: "./app" }, setup: "api",
autoscaling: { horizontal: { minContainers: 1, maxContainers: 1 } },
env: {
DATABASE_URL: Zerops.ref(db, "connectionString"),
CACHE_URL: Zerops.ref(cache, "connectionString"),
S3_ENDPOINT: Zerops.ref(assets, "apiUrl"),
S3_BUCKET: Zerops.ref(assets, "bucketName"),
S3_ACCESS_KEY_ID: Zerops.ref(assets, "accessKeyId"),
S3_SECRET_ACCESS_KEY: Zerops.ref(assets, "secretAccessKey"),
},
});
yield* Zerops.HttpRoute("Route", {
project, domains: ["documents.example.com"], ssl: true,
locations: [{ path: "/", service: api, port: 3000 }],
});
}));
Postgres supplies the 2 GB disk floor. NON_HA selects single-node mode. The family helpers give me typed reference keys, and Zerops.ref builds the server-side reference without fetching the generated credentials. Storage keys use an untyped path.
Use bunx alchemy plan, bunx alchemy deploy and bunx alchemy drift --no-input. For an approval-pinned release, use an immutable archive or full Git SHA: Alchemy snapshots source again during reconciliation, so a directory can change after planning.
What I'd put in CI
Keep review and apply as separate steps, pin the source revision, and let only one pipeline write to a project at a time. That includes someone clicking around in the GUI while CI applies routing changes.
State needs a protected home. Terraform's sensitive values remain in state, Pulumi encrypts secrets in state, and the Alchemy local-state example writes plaintext JSON. Server-side environment references are useful, but Terraform and Pulumi storage resources can still put returned credentials in state. Protect saved plans too.
For existing infrastructure, Terraform has resource-specific import IDs and Pulumi primitives follow the bridged ID semantics. Alchemy has ownership markers and deploy --adopt for unowned named objects. Adoption includes responsibility for deleting them later.
There is one lifecycle detail I would make explicit in the team's runbook: deleting a Terraform or Pulumi deployment invocation leaves the running application in place. Deleting its service or project destroys the environment. Alchemy destroy removes managed data resources too. Application rollback and infrastructure destruction need separate procedures.
Which one I'd pick
Terraform or OpenTofu for a team that already uses it. I would keep the existing state backend and plan-review process. The explicit graph is easy to inspect, and the provider is in both registries.
Pulumi when I want infrastructure in a programming language with the Terraform provider's platform coverage. It keeps the shared implementation while letting me organize the project as code. I'd also use WebApp when its smaller topology fits.
Alchemy for a TypeScript/Effect project where I want source, environment and runtime together. That is my preference for an application-focused stack like this one. If I need dedicated IPv4, VPN, general lookups or certificate-issuance waiting, I'd pick Terraform or Pulumi; Alchemy does not cover those yet.
Start with the provider examples and put one of your projects under the workflow you want to keep using.
Try Zerops in < 5 minutes without installing or forking anything — deploy one of our curated recipes, which are examples covering the full development lifecycle from local dev through staging to production, with built-in seeding, migrations, backups and scaling.
Your favorite language / framework not covered by our recipes? Deploy the Showcase recipe, or join Zerops Discord and we'll help you out.
Production-ready image processing pipeline showcasing distributed architecture on Zerops — a Bun + React frontend with real-time WebSocket updates and live architecture visualization, a Python worker for async image processing via NATS, backed by PostgreSQL, Valkey, and S3-compatible object storage.