Skip to main content
DevOps, Deployment & Operations
🚀 DevOps & OpsLesson 6 of 13

Infrastructure as Code

Your servers as version-controlled files — reproducible, reviewable infrastructure.

Infrastructure as Code

Stage 3 · DevOps, Deployment & Operations · B.U.I.L.D. letter: D

You already know how to ship a feature with a prompt. Now let's make the server that runs it just as easy to reproduce, review, and version-control as the code itself.


⚠️ The vibe trap

You spun up a database in the cloud console, clicked "add environment variable," manually enabled a CDN toggle, and pushed your app to prod. Everything works — today. But when a teammate tries to reproduce your setup, or you need a staging environment, or something blows up and you need to rebuild from scratch at 2 AM, you're staring at 47 settings screens you only half-remember. Nobody reviewed those clicks. Nothing is in git. The infra exists only inside a cloud dashboard and inside your head, and neither one is reliable.


🗺️ What Infrastructure as Code Actually Is

Infrastructure as Code (IaC) means you write your infra — servers, databases, DNS records, storage buckets, environment configs — in plain text files, check those files into git, and use a tool to apply them to reality. When reality drifts from the file, you change the file and re-apply. The cloud dashboard becomes read-only.

Mental model: think of it as a package.json for your servers. You declare what you want; the tool figures out how to get there.

Why it matters:

  • Reproducible — spin up an identical staging environment in minutes
  • Reviewable — every infra change goes through a pull request, not a 3-click Monday-morning accident
  • Auditable — git history tells you who changed the database tier and when
  • Recoverable — rebuild from scratch with one command instead of one nightmare

Common mistake: treating IaC as "just documentation." The file is the source of truth. If you tweak something in the console and don't update the file, you've created drift — and the next apply will revert your manual change.


📋 Declarative vs. Imperative

Most modern IaC tools are declarative: you describe the desired end state, not a sequence of steps.

Imperative (scripting):
  Step 1: Create a bucket named "my-uploads"
  Step 2: If bucket exists, skip
  Step 3: Set bucket region to us-east-1
  Step 4: Enable versioning on bucket
  Step 5: Attach IAM policy...

Declarative (IaC):
  resource "aws_s3_bucket" "uploads" {
    bucket = "my-uploads"
    region = "us-east-1"
  }
  # Tool figures out all the steps automatically.

Mental model: declarative IaC is like ordering at a restaurant ("I want the pasta, medium spice") rather than walking into the kitchen and narrating every step.

Why it matters: the tool handles idempotency for you. Run the same config twice — or a hundred times — and the result is identical. Nothing gets duplicated, nothing blows up on re-run.

Common mistake: writing a bash script full of aws cli calls and calling it IaC. That is imperative automation — fine for one-offs, but it breaks on re-runs and is hard to diff.


🏗️ Start Where You Are: Platform Config Files

You do not need Terraform on day one. Every major hosting platform has its own IaC-lite format. This is the right place to start.

Vercel

{
  "version": 2,
  "name": "my-app",
  "regions": ["iad1"],
  "env": {
    "NODE_ENV": "production"
  },
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "DENY" }
      ]
    }
  ],
  "redirects": [
    {
      "source": "/old-path",
      "destination": "/new-path",
      "permanent": true
    }
  ]
}

Render

services:
  - type: web
    name: my-app
    env: node
    region: oregon
    plan: starter
    buildCommand: npm install && npm run build
    startCommand: npm start
    envVars:
      - key: NODE_ENV
        value: production
      - key: DATABASE_URL
        fromDatabase:
          name: my-db
          property: connectionString

databases:
  - name: my-db
    plan: starter
    region: oregon

Mental model: these files are your deployment config. Check them in. When you change the region or add a header, open a PR, get a review, merge, and the platform picks it up automatically.

Why it matters: a vercel.json or render.yaml in your repo means any collaborator — or any future-you — can look at one file and understand the full deployment shape.

Common mistake: keeping these files out of git because they "feel like config." They are code. They belong in version control.


🌍 Terraform: The Full Picture

When you outgrow platform files, Terraform (and OpenTofu, its open-source fork) lets you describe any cloud resource in a provider-agnostic language called HCL.

terraform {
  required_providers {
    vercel = {
      source  = "vercel/vercel"
      version = "~> 1.0"
    }
  }
}

provider "vercel" {
  # reads VERCEL_API_TOKEN from env — never hardcode secrets
}

resource "vercel_project" "my_app" {
  name      = "my-app"
  framework = "nextjs"

  git_repository = {
    type = "github"
    repo = "acme/my-app"
  }
}

resource "vercel_project_environment_variable" "node_env" {
  project_id = vercel_project.my_app.id
  key        = "NODE_ENV"
  value      = "production"
  target     = ["production"]
}

Mental model: each resource block is a "thing that should exist." Terraform reads the current state of the world, compares it to your files, and produces a diff — called a plan — before touching anything.

Why it matters: Terraform manages state — a record of what it created and how those resources map to your config. This is what makes idempotency reliable at scale.

Common mistake: editing resources directly in the cloud console after Terraform created them. Terraform's state no longer matches reality. The next plan will show a confusing diff and may revert your change.


🔄 The Plan → Apply Loop

This is the core IaC workflow. Never apply blind.

# 1. Initialize (download providers, set up state backend — first time only)
terraform init

# 2. Format your files (keeps diffs clean)
terraform fmt

# 3. Validate syntax before touching infra
terraform validate

# 4. Preview what will change — read this carefully before continuing
terraform plan

# 5. Apply the changes (prompts for confirmation unless you add -auto-approve)
terraform apply

A real plan output looks like this:

Terraform will perform the following actions:

  # vercel_project.my_app will be created
  + resource "vercel_project" "my_app" {
      + id        = (known after apply)
      + name      = "my-app"
      + framework = "nextjs"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions? (yes/no)

Mental model: terraform plan is a dry run that shows you exactly what will change in production before you commit. Green + means create, yellow ~ means modify, red - means destroy. Read the red lines very carefully.

Why it matters: IaC without plan review is just as dangerous as clicking around in the console — it just fails reproducibly. The plan step is what turns IaC from "powerful" into "safe and powerful."

Common mistake: running terraform apply -auto-approve in a script without first reviewing the plan. Always pipe plan output to a review step in CI. Never destroy resources you did not intend to destroy.


🗂️ State, Drift, and Keeping Things Clean

Terraform stores a state file (terraform.tfstate) that maps your HCL resources to real cloud objects. Treat this file with respect:

  • Never commit raw state to git — it may contain secrets and it changes on every apply
  • Use a remote backend (Terraform Cloud, S3 + DynamoDB, etc.) for team projects so everyone shares the same state
  • Never edit state by hand — use terraform state commands if you need to manipulate it
# List all resources Terraform currently knows about
terraform state list

# Remove a resource from state without destroying it in the cloud
# (useful when importing manually-created resources)
terraform state rm vercel_project.old_app

# Import an existing cloud resource into Terraform management
terraform import vercel_project.my_app prj_abc123

Mental model: state is Terraform's memory of what it built. If state and reality diverge, you have drift — and Terraform will try to fix it on the next apply, which may surprise you.

Common mistake: deleting the state file to "start fresh." This makes Terraform think nothing exists, so it tries to create everything again — often resulting in duplicate resources or conflicts.


🛠️ Your Mission

Pick one piece of your app's infrastructure that currently only exists in a cloud dashboard or in your head. Capture it as a version-controlled file.

Good starting points:

  • A vercel.json with your headers, redirects, and region
  • A render.yaml describing your web service and database
  • A fly.toml for a Fly.io app
  • A terraform/main.tf for a single Vercel project resource

The goal is not to capture everything. The goal is to capture something, put it in git, and prove to yourself that a teammate could read that file and understand your deployment without asking you.


✅ You're done when…

  • Your infra file passes the Production-Readiness Checklist: no hardcoded secrets (only env var references), committed to git, and readable by a teammate cold
  • You can delete the file, restore it from git, and re-apply it to get an identical result — idempotency confirmed
  • A terraform plan (or equivalent preview) shows zero unexpected changes after a clean apply
  • Every resource in the file has a comment explaining why it exists, not just what it is

➡️ Next: Observability: Logging. Build It Right, Or Don't Build It At All. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.