Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 ConceptTerraform Equivalent
Template file (.bicep)Configuration files (.tf)
Resource definitionresource block
Parametervariable block
Outputoutput block
Azure Resource ManagerTerraform state + provider
az deployment group createterraform apply
What-if deploymentterraform plan
Resource group scopeWorkspace / 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:

SituationRecommendation
Single team, one Render project, one regionrender.yaml Blueprint is sufficient
Need to provision AWS resources (S3, SES, CloudFront) alongside RenderAdd Terraform for the AWS pieces; keep render.yaml for Render
Multiple environments (dev, staging, prod) with environment-specific configrender.yaml with environment variable overrides per environment
Multi-region, multi-cloud, complex networkingTerraform or Pulumi — you have outgrown the dashboard
You want to version-control all infrastructure changes with PR reviewsrender.yaml for Render, Terraform for everything else
Onboarding new engineers — they need a reproducible environmentrender.yaml with seed scripts is sufficient
Disaster recovery — rebuild the entire stack from scratch in 30 minutesTerraform 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