DevOps ·

Infrastructure as Code for Startups: Terraform, Pulumi, and SST Compared

A practical comparison of Terraform, Pulumi, and SST for startups. Covers state management, team workflow, CI/CD integration, and a decision framework for picking the right IaC tool based on your stack, team size, and cloud provider.

Infrastructure as Code for Startups: Terraform, Pulumi, and SST Compared

Most startups skip infrastructure as code until something breaks badly enough to force the conversation.

An engineer leaves, and nobody knows how the production database was provisioned. A staging environment is six months behind production because someone clicked through the AWS console and never wrote it down. A disaster recovery drill reveals that “restore from backup” is theoretical because nobody has tried it since the infrastructure changed.

These are not edge cases. They are the predictable outcome of running infrastructure without code.

IaC fixes all three by making infrastructure reproducible, reviewable, and recoverable. The harder question is which tool to use. Terraform, Pulumi, and SST each occupy different positions in the tradeoff space. This article compares them concretely, with the same infrastructure defined in all three, so you can make an informed choice rather than following whichever blog post you read last.

Why IaC compounds faster than you expect

The argument for IaC is usually framed around disaster recovery, which makes it feel optional until disaster arrives. The more immediate payoff is onboarding and iteration speed.

When infrastructure is code:

  • A new engineer can stand up a personal dev environment in one command
  • Every infrastructure change goes through the same PR review process as application code
  • You can diff what changed between deployments, not just guess
  • Drift detection tells you when someone made a manual change that breaks your assumptions

The cost of starting late is that you end up retrofitting IaC onto existing infrastructure, which requires importing resources and reconciling manual state. Starting early is much cheaper.

The infrastructure we will define

To make the comparison concrete, all three examples define the same stack: an HTTP API backed by a Lambda function, a PostgreSQL database via RDS, and an SQS queue for async processing. This covers the most common startup backend pattern.


Terraform

Terraform uses HashiCorp Configuration Language (HCL), a declarative DSL that describes the desired state of your infrastructure. The provider ecosystem is the largest in the IaC space, covering AWS, GCP, Azure, and hundreds of smaller providers.

Defining the stack

# main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket         = "my-startup-tfstate"
    key            = "api/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = var.aws_region
}

# SQS queue
resource "aws_sqs_queue" "jobs" {
  name                       = "${var.env}-jobs"
  visibility_timeout_seconds = 300

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.jobs_dlq.arn
    maxReceiveCount     = 5
  })
}

resource "aws_sqs_queue" "jobs_dlq" {
  name = "${var.env}-jobs-dlq"
}

# RDS PostgreSQL
resource "aws_db_instance" "main" {
  identifier        = "${var.env}-main"
  engine            = "postgres"
  engine_version    = "16.2"
  instance_class    = "db.t4g.micro"
  allocated_storage = 20
  db_name           = "app"
  username          = var.db_username
  password          = var.db_password

  vpc_security_group_ids = [aws_security_group.rds.id]
  db_subnet_group_name   = aws_db_subnet_group.main.name

  backup_retention_period = 7
  skip_final_snapshot     = false
}

# Lambda function
resource "aws_lambda_function" "api" {
  function_name = "${var.env}-api"
  role          = aws_iam_role.lambda_exec.arn
  runtime       = "nodejs20.x"
  handler       = "index.handler"
  filename      = data.archive_file.api.output_path

  environment {
    variables = {
      DATABASE_URL = "postgres://${var.db_username}:${var.db_password}@${aws_db_instance.main.endpoint}/app"
      QUEUE_URL    = aws_sqs_queue.jobs.url
    }
  }
}

# API Gateway
resource "aws_apigatewayv2_api" "main" {
  name          = "${var.env}-api"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_integration" "lambda" {
  api_id                 = aws_apigatewayv2_api.main.id
  integration_type       = "AWS_PROXY"
  integration_uri        = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}

resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.main.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

State management in Terraform

State is Terraform’s most important operational concern. The state file tracks every resource Terraform manages. If state and reality diverge, Terraform may try to recreate or destroy resources unexpectedly.

The S3 + DynamoDB backend shown above is the standard production setup. The DynamoDB table provides a distributed lock that prevents two terraform apply runs from clobbering each other. Without locking, concurrent applies on the same state can produce corrupted infrastructure.

Terraform Cloud and Spacelift are managed alternatives that handle locking, history, and policy enforcement, which reduces operational burden for teams that do not want to manage the backend themselves.

HCL tradeoffs

HCL is readable and has excellent tooling (fmt, validate, plan output). The downside is that it is a limited DSL. Anything requiring real logic (loops over heterogeneous data, conditional resource composition, dynamic module generation) quickly becomes awkward. The count and for_each meta-arguments solve some cases but hit walls when the logic grows.


Pulumi

Pulumi lets you define infrastructure in general-purpose languages: TypeScript, Python, Go, or C#. The resources map one-to-one with provider APIs, similar to Terraform providers, but the code is ordinary TypeScript.

Defining the stack

// index.ts
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";

const config = new pulumi.Config();
const env = pulumi.getStack();

// SQS queue with dead-letter queue
const jobsDlq = new aws.sqs.Queue(`${env}-jobs-dlq`);

const jobs = new aws.sqs.Queue(`${env}-jobs`, {
  visibilityTimeoutSeconds: 300,
  redrivePolicy: jobsDlq.arn.apply((arn) =>
    JSON.stringify({ deadLetterTargetArn: arn, maxReceiveCount: 5 })
  ),
});

// RDS PostgreSQL
const dbSubnetGroup = new aws.rds.SubnetGroup(`${env}-db-subnets`, {
  subnetIds: config.requireObject<string[]>("privateSubnetIds"),
});

const db = new aws.rds.Instance(`${env}-main`, {
  engine: "postgres",
  engineVersion: "16.2",
  instanceClass: aws.rds.InstanceType.T4G_Micro,
  allocatedStorage: 20,
  dbName: "app",
  username: config.require("dbUsername"),
  password: config.requireSecret("dbPassword"),
  dbSubnetGroupName: dbSubnetGroup.name,
  backupRetentionPeriod: 7,
  skipFinalSnapshot: false,
});

// Lambda function
const lambdaRole = new aws.iam.Role(`${env}-lambda-role`, {
  assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({
    Service: "lambda.amazonaws.com",
  }),
});

new aws.iam.RolePolicyAttachment(`${env}-lambda-basic`, {
  role: lambdaRole,
  policyArn: aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole,
});

const apiFunction = new aws.lambda.Function(`${env}-api`, {
  role: lambdaRole.arn,
  runtime: aws.lambda.Runtime.NodeJS20dX,
  handler: "index.handler",
  code: new pulumi.asset.AssetArchive({
    ".": new pulumi.asset.FileArchive("./dist"),
  }),
  environment: {
    variables: {
      DATABASE_URL: pulumi.interpolate`postgres://${config.require("dbUsername")}:${config.requireSecret("dbPassword")}@${db.endpoint}/app`,
      QUEUE_URL: jobs.url,
    },
  },
});

// API Gateway HTTP API
const api = new aws.apigatewayv2.Api(`${env}-api`, {
  protocolType: "HTTP",
});

const integration = new aws.apigatewayv2.Integration(`${env}-lambda`, {
  apiId: api.id,
  integrationType: "AWS_PROXY",
  integrationUri: apiFunction.arn,
  payloadFormatVersion: "2.0",
});

new aws.apigatewayv2.Route(`${env}-default`, {
  apiId: api.id,
  routeKey: "$default",
  target: pulumi.interpolate`integrations/${integration.id}`,
});

export const apiUrl = api.apiEndpoint;

State management in Pulumi

Pulumi state can live in Pulumi Cloud (the managed option), S3, Azure Blob, or local disk. The local backend is fine for solo projects but breaks down the moment two people need to apply changes. S3 with server-side locking is a reasonable self-hosted option, though Pulumi Cloud’s audit log and history features are harder to replicate yourself.

One important property: Pulumi tracks secrets natively. When you call config.requireSecret("dbPassword"), Pulumi encrypts the value in state using either a managed key (Pulumi Cloud) or a customer-managed key. Terraform requires a separate secrets management integration or manual handling to achieve equivalent protection.

TypeScript-specific benefits

Using TypeScript means you get the full language: real loops, conditionals, imported modules, type checking, and IDE support. This matters when you need to define 20 environments with slight variations, generate IAM policies programmatically, or reuse infrastructure logic across multiple stacks.

// Reusable component with typed inputs
interface WorkerConfig {
  name: string;
  sourceQueue: aws.sqs.Queue;
  concurrency: number;
  timeoutSeconds: number;
}

function createWorker(cfg: WorkerConfig): aws.lambda.EventSourceMapping {
  const fn = new aws.lambda.Function(`${cfg.name}-worker`, {
    runtime: aws.lambda.Runtime.NodeJS20dX,
    handler: "worker.handler",
    code: new pulumi.asset.FileArchive("./dist"),
    timeout: cfg.timeoutSeconds,
    role: workerRole.arn,
  });

  return new aws.lambda.EventSourceMapping(`${cfg.name}-mapping`, {
    functionName: fn.name,
    eventSourceArn: cfg.sourceQueue.arn,
    batchSize: cfg.concurrency,
  });
}

This level of abstraction is awkward in HCL and impossible without Terraform modules, which add their own complexity.


SST

SST (formerly Serverless Stack) is purpose-built for serverless architectures on AWS. It sits at a higher level of abstraction than Terraform or Pulumi: you work with constructs that represent real-world patterns rather than raw provider resources. Under the hood, SST uses Pulumi (v3+) for AWS resource provisioning and adds its own layer for developer experience.

Defining the stack

// sst.config.ts
/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: "my-startup",
      removal: input?.stage === "production" ? "retain" : "remove",
      home: "aws",
    };
  },

  async run() {
    // RDS PostgreSQL via Aurora Serverless v2
    const db = new sst.aws.Postgres("MainDb", {
      scaling: {
        min: "0.5 ACU",
        max: "4 ACU",
      },
    });

    // SQS queue
    const jobs = new sst.aws.Queue("Jobs");

    // Lambda API with live reload support
    const api = new sst.aws.Function("Api", {
      handler: "src/api.handler",
      link: [db, jobs],
      url: true,
    });

    // Queue consumer
    jobs.subscribe("src/worker.handler", {
      batch: {
        size: 10,
        partialResponses: true,
      },
    });

    return {
      apiUrl: api.url,
    };
  },
});
// src/api.ts
import { Resource } from "sst";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";

const sqs = new SQSClient({});

export const handler = async (event: any) => {
  // Resource.Jobs.url is type-safe and injected at runtime
  await sqs.send(
    new SendMessageCommand({
      QueueUrl: Resource.Jobs.url,
      MessageBody: JSON.stringify({ orderId: "123" }),
    })
  );

  return {
    statusCode: 200,
    body: JSON.stringify({ ok: true }),
  };
};

Live Lambda development

The standout SST feature for developer experience is sst dev. When you run it, SST proxies Lambda invocations through a local WebSocket connection to your machine. Your local code runs when the Lambda is triggered in AWS, with live reload on file changes. You get real AWS resources without the deploy cycle.

This is a meaningful shift for teams building Lambda-heavy applications. The feedback loop for iterating on a Lambda handler goes from “push, wait 60 to 90 seconds, test” to “save file, test immediately”.

SST tradeoffs

SST is deliberately opinionated. The constructs handle common AWS patterns well and the defaults are sensible, but when you need to drop below the abstraction, you reach for Pulumi’s lower-level primitives directly. That means you end up learning both SST constructs and Pulumi resources as the system grows.

SST is also AWS-only as of writing. If you need GCP, Azure, or Cloudflare Workers as your primary deployment target, SST is not the right fit.


Side-by-side tradeoffs

ConcernTerraformPulumiSST
LanguageHCL (DSL)TypeScript, Python, Go, C#TypeScript
Cloud supportAll major providersAll major providersAWS-focused
Abstraction levelLow (raw resources)Low to mediumMedium to high (constructs)
State managementS3 + DynamoDB or Terraform CloudPulumi Cloud, S3, or localPulumi Cloud (default)
Native secrets handlingRequires integrationBuilt-in encryptionBuilt-in via SST config
Local dev experienceNone specific to IaCNone specific to IaCsst dev (live Lambda proxy)
Ecosystem maturityHighestHighGrowing
Logic and reuseLimited (DSL constraints)Full language powerFull language power
Drift detectionterraform planpulumi previewsst diff
Learning curveLow to mediumMediumLow for AWS serverless
CI/CD integrationsExcellentGoodGood

State management: the hidden operational risk

All three tools maintain a state file that maps your code to real cloud resources. Mismanaging state is how you get duplicate resources, orphaned infrastructure, or a corrupted view that makes apply fail.

Common state failures:

  • Concurrent applies: two engineers run apply at the same time without locking. Both read the same base state, then both write back. One write is lost or conflicting. Always use remote state with locking.
  • Manual changes in the console: someone modifies a resource manually. State no longer reflects reality. Terraform will flag this on the next plan; the fix is either importing the change or reverting it.
  • Deleted resources not removed from code: if a resource is deleted from the cloud without removing it from IaC code, apply will try to recreate it. Usually harmless, but can be disruptive for stateful resources.

Drift detection is the practice of regularly running plan/preview to surface real-to-state divergence before it causes an incident. A reasonable CI job runs a plan on a schedule and alerts if there is unexpected drift.


Team workflow: PRs, drift, and CI/CD

The IaC workflow that scales to teams looks like this:

  1. Infrastructure changes live in the same repository as application code (or a dedicated infra repo)
  2. Every change goes through a PR with a plan output attached
  3. Automated CI runs plan on every PR and posts the output as a comment
  4. Apply runs only from the main branch after merge (not from local machines in production)
  5. A scheduled job runs plan daily and alerts on unexpected drift

For Terraform, Atlantis is the most common CI automation layer. It listens for PR events, runs terraform plan, and posts results. Spacelift and Terraform Cloud have the same capability with more built-in policy controls.

For Pulumi, the Pulumi GitHub Actions integration runs pulumi preview on PRs automatically. Pulumi Cloud adds change auditing, stack history, and policy-as-code.

For SST, the GitHub Actions integration follows a similar pattern: preview on PR, deploy on merge.

One non-obvious concern: never run apply manually against production from a developer machine. The state will diverge from what is in source control, secrets may end up in shell history, and there is no audit trail. Lock down production applies to CI.


Cost considerations

IaC tooling itself is free at the open-source layer. Costs come from:

  • Terraform Cloud: free tier is generous for small teams, paid tiers add SSO and policy features
  • Pulumi Cloud: free for individuals, team tier starts at a per-user cost; self-hosting S3 state is free
  • SST: free; Pulumi state is free for SST users through their managed offering

The more significant cost dimension is operational. A team spending 10 to 15 hours a month firefighting infrastructure drift, manually reproducing environments, or debugging console-only changes is spending more than any tooling subscription costs.


Decision framework

Choose Terraform if:

  • Your team uses multiple cloud providers
  • You have existing Terraform investment or HCL familiarity
  • You need the widest provider ecosystem (databases, SaaS integrations, networking)
  • Your team is larger and benefits from HCL’s readability over language flexibility

Choose Pulumi if:

  • Your team writes TypeScript (or Python/Go) and wants to stay in one language
  • Your infrastructure logic is complex enough that HCL’s DSL limitations are painful
  • You need native secret encryption in state
  • You want cross-cloud support with real language abstractions

Choose SST if:

  • You are building primarily on AWS with Lambda, SQS, RDS, and S3
  • Fast local development iteration on Lambda code is a priority
  • Your team wants sensible serverless defaults without low-level resource configuration
  • You are willing to be AWS-first in exchange for better developer experience

A reasonable default for most seed-to-Series-A AWS startups: SST for the application infrastructure (functions, queues, databases), with the option to drop to Pulumi primitives for anything the constructs do not cover. Avoid Terraform unless you have a specific reason to need the wider provider surface.


Closing

The goal of IaC is not to make infrastructure elegant. It is to make infrastructure forgettable in the right ways: reproducible on demand, reviewable in PRs, and recoverable when something breaks.

All three tools deliver that. The choice is about which tradeoffs fit your team’s language, cloud, and complexity profile. Pick the one that matches your actual constraints, apply it consistently, and treat infrastructure changes with the same review discipline as application code.

The cost of skipping IaC compounds quietly until it does not.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.