6.8 — Infrastructure as Code: ARM/Bicep vs. Pulumi/Terraform
For .NET engineers who know: ARM templates, Azure Bicep, and the Azure Resource Manager deployment model You’ll learn: Where Terraform and Pulumi fit in the broader IaC ecosystem, what the Render Blueprint spec (
render.yaml) gives you for free, and when IaC is actually worth the investment at our scale Time: 10-15 min read
The .NET Way (What You Already Know)
ARM templates are JSON documents that describe Azure resources declaratively. The Azure Resource Manager evaluates the desired state, diffs it against the current state, and applies the delta. Bicep is a domain-specific language that compiles to ARM templates — it eliminates the JSON verbosity while keeping the same deployment model:
// Bicep — deploy an Azure App Service
param location string = 'eastus'
param appName string
resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = {
name: '${appName}-plan'
location: location
sku: {
name: 'B1'
tier: 'Basic'
}
}
resource webApp 'Microsoft.Web/sites@2022-03-01' = {
name: appName
location: location
properties: {
serverFarmId: appServicePlan.id
httpsOnly: true
siteConfig: {
nodeVersion: '20-lts'
}
}
}
The Azure DevOps pipeline runs az deployment group create --template-file main.bicep, ARM diffs the desired vs. current state, and provisions or updates the resource group. The deployment is idempotent — running it twice produces the same result.
ARM/Bicep works well within the Azure ecosystem but has no concept of resources outside it. Deploying to Render, AWS, or any non-Azure service means switching tools entirely.
The Broader IaC Ecosystem
Terraform
Terraform (by HashiCorp, now open-source via OpenTofu after the license change) is the dominant IaC tool across cloud providers. It uses its own configuration language (HCL — HashiCorp Configuration Language) and supports every major cloud through a provider plugin system. The same Terraform project can provision an AWS RDS database, a Render web service, and a Cloudflare DNS record.
The mental model maps directly from Bicep:
| Bicep Concept | Terraform Equivalent |
|---|---|
Template file (.bicep) | Configuration files (.tf) |
| Resource definition | resource block |
| Parameter | variable block |
| Output | output block |
| Azure Resource Manager | Terraform state + provider |
az deployment group create | terraform apply |
| What-if deployment | terraform plan |
| Resource group scope | Workspace / state file |
A minimal Terraform example for a Render web service:
# main.tf — Terraform for Render
terraform {
required_providers {
render = {
source = "render-oss/render"
version = "~> 1.0"
}
}
}
provider "render" {
api_key = var.render_api_key
owner_id = var.render_owner_id
}
variable "render_api_key" {
type = string
sensitive = true
}
variable "render_owner_id" {
type = string
}
# PostgreSQL database
resource "render_postgres" "db" {
name = "myapp-db"
plan = "starter"
region = "oregon"
version = "15"
}
# Web service
resource "render_web_service" "api" {
name = "myapp-api"
plan = "starter"
region = "oregon"
runtime_source = {
native_runtime = {
auto_deploy = true
branch = "main"
build_command = "pnpm install && pnpm build"
build_filter = { paths = ["apps/api/**", "packages/**"] }
repo_url = "https://github.com/your-org/your-repo"
runtime = "node"
start_command = "node dist/main.js"
}
}
env_vars = {
NODE_ENV = { value = "production" }
DATABASE_URL = {
value = render_postgres.db.connection_string
}
}
}
output "api_url" {
value = render_web_service.api.service_details.url
}
# Terraform workflow
terraform init # download providers
terraform plan # show what will change
terraform apply # apply changes
terraform destroy # tear everything down
The state file (terraform.tfstate) records which real resources Terraform manages. Store it remotely (Terraform Cloud, S3 with DynamoDB locking) for team environments — never commit it to git, as it contains sensitive values.
Pulumi
Pulumi is infrastructure as code written in actual programming languages — TypeScript, Python, Go, C#. For a team coming from .NET, Pulumi’s C# support is a genuine draw. For our team specifically, the TypeScript support is the relevant one:
// index.ts — Pulumi TypeScript for Render + AWS
import * as render from '@pulumi/render';
import * as aws from '@pulumi/aws';
// PostgreSQL on Render
const db = new render.PostgresDatabase('myapp-db', {
name: 'myapp-db',
plan: 'starter',
region: 'oregon',
databaseVersion: 'POSTGRES_15',
});
// S3 bucket for file uploads (AWS)
const bucket = new aws.s3.Bucket('uploads', {
acl: 'private',
versioning: { enabled: false },
});
// Render web service
const api = new render.WebService('myapp-api', {
name: 'myapp-api',
plan: 'starter',
region: 'oregon',
runtimeSource: {
nativeRuntime: {
repoUrl: 'https://github.com/your-org/your-repo',
branch: 'main',
buildCommand: 'pnpm install && pnpm build',
startCommand: 'node dist/main.js',
runtime: 'node',
},
},
envVars: [
{ key: 'DATABASE_URL', value: db.connectionString },
{ key: 'AWS_BUCKET', value: bucket.bucket },
{ key: 'NODE_ENV', value: 'production' },
],
});
export const apiUrl = api.serviceDetails.url;
export const bucketName = bucket.bucket;
# Pulumi workflow
pulumi login # authenticate (Pulumi Cloud or self-hosted)
pulumi stack init dev # create a stack (equivalent to environment)
pulumi up # preview and apply changes
pulumi destroy # tear down the stack
Pulumi advantages over Terraform for our team:
- TypeScript is the native language — no HCL to learn
- Full language features: loops, conditionals, abstractions, imports
- Can import existing Pulumi component libraries shared across projects
- Pulumi Cloud handles state, secrets, and audit history
Pulumi trade-offs:
- Smaller community and provider ecosystem than Terraform
- Pulumi Cloud has a free tier but costs money at scale
- TypeScript compilation adds a step before deployments execute
Render Blueprint Spec (render.yaml)
For most teams at our scale, render.yaml is sufficient and Terraform/Pulumi is overkill. The Render Blueprint spec is a YAML file committed to your repository that defines all services, databases, environment variables, and cron jobs. Render reads it and provisions (or updates) resources to match.
# render.yaml — complete application definition
services:
- type: web
name: api
runtime: node
plan: starter
region: oregon
buildCommand: pnpm install && pnpm build
startCommand: node dist/main.js
healthCheckPath: /api/health
autoDeploy: true
branch: main
scaling:
minInstances: 1
maxInstances: 3
targetMemoryPercent: 70
envVars:
- key: NODE_ENV
value: production
- key: DATABASE_URL
fromDatabase:
name: postgres-db
property: connectionString
- key: REDIS_URL
fromService:
name: redis
type: redis
property: connectionString
- key: STRIPE_SECRET_KEY
sync: false # must be set manually in dashboard
- type: web
name: frontend
runtime: node
plan: starter
region: oregon
buildCommand: pnpm install && pnpm build
staticPublishPath: .next
envVars:
- key: NEXT_PUBLIC_API_URL
fromService:
name: api
type: web
property: host
- type: cron
name: cleanup-job
runtime: node
schedule: "0 2 * * *" # 2am UTC daily
buildCommand: pnpm install && pnpm build
startCommand: node dist/jobs/cleanup.js
- type: redis
name: redis
plan: starter
region: oregon
databases:
- name: postgres-db
databaseName: myapp
user: myapp
plan: starter
region: oregon
previewPlan: starter
Blueprint commands:
# Validate render.yaml before pushing
render blueprint validate
# Deploy the blueprint (useful in CI)
render blueprint launch --yes
The key difference from Terraform: the Blueprint spec lives in your repository, is read by Render on every push, and Render manages the state. You do not manage a state file. You do not run plan/apply locally. The trade-off is that Render’s Blueprint has fewer capabilities than Terraform — it can only manage Render resources, and it cannot express complex conditionals or reuse abstractions across projects.
When You Need IaC vs. When the Dashboard Is Sufficient
At our current scale, this decision framework applies:
| Situation | Recommendation |
|---|---|
| Single team, one Render project, one region | render.yaml Blueprint is sufficient |
| Need to provision AWS resources (S3, SES, CloudFront) alongside Render | Add Terraform for the AWS pieces; keep render.yaml for Render |
| Multiple environments (dev, staging, prod) with environment-specific config | render.yaml with environment variable overrides per environment |
| Multi-region, multi-cloud, complex networking | Terraform or Pulumi — you have outgrown the dashboard |
| You want to version-control all infrastructure changes with PR reviews | render.yaml for Render, Terraform for everything else |
| Onboarding new engineers — they need a reproducible environment | render.yaml with seed scripts is sufficient |
| Disaster recovery — rebuild the entire stack from scratch in 30 minutes | Terraform or Pulumi — the Blueprint can rebuild Render, but not DNS, CDN, S3 |
The honest answer for our team today: render.yaml covers 90% of what we need. Terraform is worth introducing when we add AWS services (SES for email, CloudFront for CDN, or RDS for a production database tier). Pulumi is worth evaluating if we find ourselves writing complex Terraform with a lot of conditional logic that would benefit from actual TypeScript.
Gotchas for .NET Engineers
1. Terraform state is not in the configuration files — it is a separate artifact you must manage.
In ARM/Bicep, the ARM Resource Manager tracks state in Azure’s own database. In Terraform, state lives in a terraform.tfstate file. By default this file is local, which means it is lost if your machine is lost, and two engineers running terraform apply simultaneously will corrupt it. Always configure remote state from the start:
# backend.tf — remote state in S3 (or use Terraform Cloud)
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "myapp/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-lock" # prevents concurrent applies
encrypt = true
}
}
Never commit terraform.tfstate to git. Add it to .gitignore immediately.
2. pulumi up is not idempotent in the same way terraform apply is — it diffs against Pulumi’s state, not live infrastructure.
If someone creates a resource manually in the Render dashboard outside of Pulumi, Pulumi does not know about it. The next pulumi up will not see the manual resource. This is the same as Terraform’s state drift problem, but TypeScript engineers used to having full control over their code are sometimes surprised that the program is not re-evaluated from scratch each time.
3. Render Blueprint fromService references assume the service exists — circular dependencies will fail.
If frontend references api’s host URL via fromService, and both are in the same Blueprint, Render must create api before frontend. Render handles this ordering automatically for simple cases, but circular references (service A needs service B’s URL, service B needs service A’s URL) will cause the Blueprint deployment to fail. Restructure to eliminate the circular dependency, typically by extracting the shared value into a separate environment variable set manually.
4. HCL (Terraform’s language) has a learning curve that looks easier than it is.
HCL appears simple but has subtle rules around type coercion, the for_each meta-argument, and module variable scoping that are not obvious from reading examples. Engineers who assume it is “just JSON with loops” hit walls quickly when they try to build conditional logic or dynamic resource counts. Budget time to read the Terraform documentation properly rather than copying examples and adjusting values.
5. Destroying and recreating is not the same as updating.
Terraform and Pulumi aim to update resources in-place when possible, but some resource properties are immutable — changing them forces destroy-and-recreate. For databases, this means data loss. Always run terraform plan and look for forces replacement annotations before applying any changes that touch database resources. The Render postgres resource’s plan and region fields are immutable — changing them destroys the database.
Hands-On Exercise
Create a render.yaml Blueprint spec for a realistic application and validate it locally.
Step 1: Create a render.yaml at the root of a repository. Define:
- A NestJS API web service with
healthCheckPath: /api/health - A Next.js frontend web service
- A PostgreSQL database
- A cron job that runs at midnight UTC
- An environment variable on the API that reads the database connection string from the database definition
Step 2: Validate the Blueprint spec:
npm install -g @render-oss/render-cli
render login
render blueprint validate
Fix any validation errors the CLI reports.
Step 3: Add previewsEnabled: true and previewsExpireAfterDays: 3 to the API service. Add a previewPlan: starter to the database.
Step 4 (optional — awareness level): Install Terraform and create a main.tf that provisions one S3 bucket. Run terraform init, terraform plan, and terraform apply. Observe the state file created locally. Add terraform.tfstate and terraform.tfstate.backup to .gitignore.
Step 5 (optional — awareness level): Create a Pulumi TypeScript project (pulumi new typescript). Replace the default index.ts with code that creates one AWS S3 bucket. Run pulumi up and observe the diff. Run pulumi destroy.
Quick Reference
# Render Blueprint
render blueprint validate # validate render.yaml
render blueprint launch --yes # deploy blueprint
render services list # list all services
# Terraform
terraform init # initialize (download providers)
terraform plan # show planned changes
terraform apply # apply changes
terraform apply -target=render_web_service.api # apply one resource
terraform destroy # destroy all resources
terraform state list # list tracked resources
terraform state show render_web_service.api # inspect one resource
# Pulumi
pulumi login # authenticate
pulumi stack init dev # create environment stack
pulumi up # preview and apply
pulumi preview # dry-run only
pulumi destroy # destroy stack
pulumi stack ls # list stacks
pulumi config set MY_VAR value # set stack config variable
pulumi config set --secret MY_SECRET value # set encrypted secret
# render.yaml — annotated minimal example
services:
- type: web
name: api
runtime: node
plan: starter # starter | standard | pro
region: oregon # oregon | frankfurt | singapore | ohio
branch: main
buildCommand: pnpm install && pnpm build
startCommand: node dist/main.js
healthCheckPath: /api/health
autoDeploy: true
previewsEnabled: true
previewsExpireAfterDays: 7
envVars:
- key: DATABASE_URL
fromDatabase:
name: db
property: connectionString # connectionString | host | port | database | user | password
databases:
- name: db
plan: starter
region: oregon
previewPlan: starter
Further Reading
- Render Blueprint spec reference
- Terraform documentation
- Terraform Render provider
- Pulumi documentation
- Pulumi vs Terraform — official comparison
- OpenTofu — the open-source Terraform fork (relevant after HashiCorp’s 2023 license change)
- Bicep documentation — for reference when you need to compare