Skip to main content
Back to Blog
Infrastructure as CodeAWS CDKTerraformDevOpsJavaScriptCloud

Infrastructure as Code with AWS CDK or Terraform for JS Developers

A practical, code-first guide for JavaScript and TypeScript developers to provision EC2, S3, Lambda, and databases declaratively using AWS CDK and Terraform.

August 21, 202613 min readNiraj Kumar

Introduction

If you've ever provisioned an EC2 instance by clicking through the AWS Console, only to forget exactly which settings you chose when you need to recreate it six months later, you already understand the core problem Infrastructure as Code (IaC) solves.

As a JavaScript developer, you're used to describing behavior declaratively — React components describe UI state, not step-by-step DOM manipulation. IaC applies the same philosophy to cloud infrastructure: instead of manually configuring servers, storage buckets, and databases through a web console, you describe the desired end state in code, and a tool figures out how to make the cloud match that description.

In 2026, two tools dominate this space for JavaScript and TypeScript developers:

  • AWS Cloud Development Kit (CDK) — write infrastructure in TypeScript, JavaScript, Python, or Go, which compiles down to CloudFormation templates.
  • Terraform — HashiCorp's cloud-agnostic tool using HCL (HashiCorp Configuration Language), with a growing CDK for Terraform (CDKTF) option that also supports TypeScript.

This guide walks through both approaches, using real, runnable examples to provision the four building blocks nearly every application needs: EC2 instances, S3 buckets, Lambda functions, and databases. By the end, you'll understand not just the syntax, but the mental model, trade-offs, and pitfalls that separate a hobby project from production-grade infrastructure.

What Is Infrastructure as Code, Really?

Infrastructure as Code means managing and provisioning infrastructure through machine-readable definition files rather than manual configuration or interactive tools. There are two dominant paradigms:

  • Declarative IaC: You describe the desired state (e.g., "I want an S3 bucket named my-app-assets with versioning enabled"). The tool calculates the difference between current and desired state and applies only the necessary changes. Terraform and CloudFormation (which CDK compiles to) both work this way.
  • Imperative IaC: You write step-by-step instructions for how to create resources (e.g., a bash script calling the AWS CLI). This is fragile, hard to make idempotent, and rarely used for serious infrastructure today.

AWS CDK is interesting because it's imperative in syntax but declarative in output — you write TypeScript classes and functions, but the CDK synthesizes that code into a declarative CloudFormation template before anything is deployed.

Why This Matters for JS Developers Specifically

Before CDK and CDKTF existed, JavaScript developers had to learn YAML-heavy CloudFormation or HCL just to deploy their own applications. That context-switch was a real barrier. Now you can:

  • Use the same language (TypeScript) for your application code and your infrastructure code
  • Get autocomplete, type checking, and refactoring tools from your IDE
  • Write unit tests for your infrastructure using familiar frameworks like Jest
  • Share logic through npm packages, loops, conditionals, and functions — not copy-pasted YAML blocks

AWS CDK vs. Terraform: Choosing Your Tool

Before diving into code, it's worth understanding when each tool shines.

FactorAWS CDKTerraform
LanguageTypeScript, JS, Python, Java, Go, C#HCL (or TypeScript via CDKTF)
Cloud supportAWS only (native)Multi-cloud (AWS, GCP, Azure, etc.)
State managementDelegated to CloudFormationSelf-managed .tfstate file (local or remote)
Abstraction levelHigh-level constructs (L1/L2/L3)Lower-level resource blocks, plus modules
Learning curveEasier for JS/TS developersSteeper if new to HCL, easier if multi-cloud is a goal
Community modulesGrowing construct libraryMassive, mature module registry

Rule of thumb: if you're AWS-only and your team is JavaScript-heavy, CDK reduces friction significantly. If you need multi-cloud support, work in a polyglot infrastructure team, or want the most battle-tested ecosystem of reusable modules, Terraform (or CDKTF if you want TypeScript) is the safer long-term bet.

Setting Up Your Environment

AWS CDK Setup

npm install -g aws-cdk
mkdir my-infra && cd my-infra
cdk init app --language typescript
npm install aws-cdk-lib constructs

This scaffolds a project with a lib/my-infra-stack.ts file where your infrastructure lives, and a bin/my-infra.ts entry point.

Terraform Setup

brew install terraform   # or download from terraform.io
mkdir my-infra && cd my-infra
touch main.tf variables.tf outputs.tf

If you prefer staying in TypeScript, install CDK for Terraform instead:

npm install -g cdktf-cli
cdktf init --template=typescript --local

Provisioning an S3 Bucket

S3 is usually the first resource developers provision — it's simple, cheap, and foundational for static assets, backups, and data lakes.

With AWS CDK (TypeScript)

import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';

export class StorageStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new s3.Bucket(this, 'AppAssetsBucket', {
      bucketName: 'my-app-assets-prod',
      versioned: true,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
      lifecycleRules: [
        {
          transitions: [
            {
              storageClass: s3.StorageClass.INFREQUENT_ACCESS,
              transitionAfter: cdk.Duration.days(30),
            },
          ],
        },
      ],
    });
  }
}

Notice how versioned: true and blockPublicAccess are just object properties — no separate resource blocks required, unlike raw CloudFormation.

With Terraform (HCL)

resource "aws_s3_bucket" "app_assets" {
  bucket = "my-app-assets-prod"
}

resource "aws_s3_bucket_versioning" "app_assets_versioning" {
  bucket = aws_s3_bucket.app_assets.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "app_assets_block" {
  bucket                  = aws_s3_bucket.app_assets.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Terraform splits configuration into separate resource blocks per concern (bucket, versioning, access block), which is more verbose but makes each concern independently auditable.

Provisioning a Lambda Function

Serverless functions are where JS developers often feel most at home, since the runtime code and the infrastructure code can live in the same repository — sometimes even the same language.

With AWS CDK

import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';

const helloFn = new lambda.Function(this, 'HelloHandler', {
  runtime: lambda.Runtime.NODEJS_20_X,
  handler: 'index.handler',
  code: lambda.Code.fromAsset('lambda/hello'),
  memorySize: 256,
  timeout: cdk.Duration.seconds(10),
  environment: {
    STAGE: 'production',
  },
});

const api = new apigateway.LambdaRestApi(this, 'HelloApi', {
  handler: helloFn,
  proxy: false,
});

const helloResource = api.root.addResource('hello');
helloResource.addMethod('GET');

CDK's LambdaRestApi construct wires up API Gateway integration, IAM permissions, and the Lambda function in a handful of lines — a huge amount of undifferentiated heavy lifting is handled for you by the L2/L3 constructs.

With Terraform

resource "aws_lambda_function" "hello_handler" {
  function_name = "hello-handler"
  runtime       = "nodejs20.x"
  handler       = "index.handler"
  filename      = "lambda/hello.zip"
  memory_size   = 256
  timeout       = 10
  role          = aws_iam_role.lambda_exec.arn

  environment {
    variables = {
      STAGE = "production"
    }
  }
}

resource "aws_iam_role" "lambda_exec" {
  name = "hello-lambda-exec-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

Terraform requires you to explicitly define the IAM role and trust policy — nothing is implicit. This verbosity is a trade-off: more control, more boilerplate.

Provisioning an EC2 Instance

Despite the rise of serverless, EC2 remains essential for long-running processes, custom runtimes, or workloads that don't fit the Lambda execution model.

With AWS CDK

import * as ec2 from 'aws-cdk-lib/aws-ec2';

const vpc = new ec2.Vpc(this, 'AppVpc', { maxAzs: 2, natGateways: 1 });

const securityGroup = new ec2.SecurityGroup(this, 'WebSg', {
  vpc,
  description: 'Allow HTTP/HTTPS traffic',
  allowAllOutbound: true,
});
securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS');
securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP');

const instance = new ec2.Instance(this, 'WebServer', {
  vpc,
  instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
  machineImage: ec2.MachineImage.latestAmazonLinux2023(),
  securityGroup,
  keyName: 'my-ssh-keypair',
});

Note that a full VPC — with subnets, route tables, and NAT gateways — is provisioned with a single new ec2.Vpc() call. This is CDK's biggest strength: sensible, secure defaults baked into high-level constructs.

With Terraform

resource "aws_instance" "web_server" {
  ami                    = "ami-0c101f26f147fa7fd"
  instance_type          = "t3.micro"
  key_name               = "my-ssh-keypair"
  vpc_security_group_ids = [aws_security_group.web_sg.id]
  subnet_id              = aws_subnet.public_subnet.id

  tags = {
    Name = "web-server"
  }
}

resource "aws_security_group" "web_sg" {
  name   = "web-sg"
  vpc_id = aws_vpc.app_vpc.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

With Terraform, you must explicitly source the AMI ID (often via a data block querying the latest Amazon Linux AMI) and hand-wire the VPC, subnet, and security group relationships.

Provisioning a Database

Most applications need persistent storage beyond object storage. We'll look at both a relational option (RDS) and a serverless NoSQL option (DynamoDB).

RDS with AWS CDK

import * as rds from 'aws-cdk-lib/aws-rds';

const dbInstance = new rds.DatabaseInstance(this, 'AppDatabase', {
  engine: rds.DatabaseInstanceEngine.postgres({
    version: rds.PostgresEngineVersion.VER_16,
  }),
  vpc,
  instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
  allocatedStorage: 20,
  credentials: rds.Credentials.fromGeneratedSecret('dbadmin'),
  removalPolicy: cdk.RemovalPolicy.SNAPSHOT,
  multiAz: false,
  publiclyAccessible: false,
});

Credentials.fromGeneratedSecret automatically creates and stores a random password in AWS Secrets Manager — you never hardcode a database password in your repository.

DynamoDB with CDK (Serverless Alternative)

import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';

const table = new dynamodb.Table(this, 'UsersTable', {
  partitionKey: { name: 'userId', type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  pointInTimeRecovery: true,
  removalPolicy: cdk.RemovalPolicy.RETAIN,
});

RDS with Terraform

resource "aws_db_instance" "app_database" {
  identifier             = "app-database"
  engine                 = "postgres"
  engine_version         = "16.3"
  instance_class         = "db.t3.micro"
  allocated_storage      = 20
  username               = "dbadmin"
  manage_master_user_password = true
  vpc_security_group_ids = [aws_security_group.db_sg.id]
  db_subnet_group_name   = aws_db_subnet_group.app_subnet_group.name
  publicly_accessible    = false
  skip_final_snapshot    = false
}

Terraform's manage_master_user_password = true similarly delegates password generation to AWS Secrets Manager, avoiding plaintext credentials in your .tf files.

Real-World Example: A Serverless API with Storage

Let's tie these pieces together into a realistic architecture: an API Gateway endpoint backed by a Lambda function, which reads/writes to DynamoDB and stores uploaded files in S3.

export class ServerlessApiStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const uploadsBucket = new s3.Bucket(this, 'UploadsBucket', {
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    });

    const usersTable = new dynamodb.Table(this, 'UsersTable', {
      partitionKey: { name: 'userId', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    });

    const apiHandler = new lambda.Function(this, 'ApiHandler', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda/api'),
      environment: {
        TABLE_NAME: usersTable.tableName,
        BUCKET_NAME: uploadsBucket.bucketName,
      },
    });

    usersTable.grantReadWriteData(apiHandler);
    uploadsBucket.grantReadWrite(apiHandler);

    new apigateway.LambdaRestApi(this, 'Api', { handler: apiHandler });
  }
}

The grantReadWriteData and grantReadWrite helper methods are a standout CDK feature — they automatically generate the least-privilege IAM policy needed, scoped to that specific bucket and table, without you writing a single line of IAM JSON.

Best Practices

  • Use remote state for Terraform. Store .tfstate in an S3 backend with DynamoDB locking to prevent concurrent modification conflicts across a team.
  • Separate environments with distinct stacks or workspaces. Keep dev, staging, and prod isolated — either as separate CDK stacks/apps or Terraform workspaces — so a mistake in one doesn't cascade.
  • Never hardcode secrets. Use AWS Secrets Manager or SSM Parameter Store, and reference them dynamically rather than embedding plaintext credentials.
  • Apply least-privilege IAM everywhere. Use CDK's grant* methods or scope Terraform IAM policies tightly to specific ARNs and actions.
  • Tag every resource. Consistent tagging (Environment, Project, Owner) makes cost allocation and cleanup dramatically easier at scale.
  • Run cdk diff or terraform plan before every apply. Never deploy blind — always review exactly what will change.
  • Modularize reusable patterns. Build custom CDK constructs or Terraform modules for patterns you repeat across projects (e.g., "standard VPC," "standard Lambda API").
  • Enable versioning and point-in-time recovery on stateful resources. S3 versioning and DynamoDB PITR are cheap insurance against accidental deletion.
  • Automate deployments through CI/CD. Run cdk deploy or terraform apply from a pipeline with proper approvals, not from a developer's laptop.

Common Mistakes

  • Manually editing resources in the AWS Console after provisioning with IaC. This creates configuration drift — your code no longer matches reality, and the next deploy may silently revert your manual fix or fail outright.
  • Committing .tfstate files or CDK cdk.context.json cache files with sensitive data to public repositories. State files can contain secrets in plaintext; always .gitignore them and use remote backends.
  • Using overly broad IAM policies like "Action": "*" or "Resource": "*" out of convenience during development, then forgetting to tighten them before production.
  • Deleting and recreating resources unnecessarily by changing immutable properties (like an S3 bucket name or RDS engine version in some cases), causing unwanted data loss. Always check cdk diff / terraform plan output for "replace" operations.
  • Not pinning provider or CDK versions, leading to "works on my machine" failures when a teammate runs a newer CLI version with breaking changes.
  • Skipping removalPolicy / prevent_destroy on critical resources, resulting in accidental deletion of production databases or buckets during a stack teardown.
  • Treating IaC code with less rigor than application code — no code review, no tests, no linting. Infrastructure bugs can be far more costly than application bugs.

🚀 Pro Tips

  • Use CDK Aspects to enforce organization-wide policies (e.g., "all S3 buckets must have encryption enabled") across every stack automatically.
  • In Terraform, use for_each instead of count when creating multiple similar resources — it produces more stable, addressable resource identifiers during updates.
  • Write unit tests for your CDK stacks using the aws-cdk-lib/assertions module to assert that specific resources and properties exist before you ever deploy.
  • Use terraform fmt and cdk lint (or ESLint with a CDK plugin) in a pre-commit hook to keep infrastructure code style consistent across your team.
  • Leverage CDK Pipelines or a Terraform Cloud/Atlantis setup to get automatic plan previews on every pull request — reviewers see infrastructure changes before merge.
  • For multi-account setups, use CDK's environment-agnostic stacks with explicit env: { account, region } props, or Terraform's provider aliases, to avoid deploying to the wrong AWS account.
  • Store Lambda function code in a separate directory with its own package.json, and bundle it with esbuild via NodejsFunction in CDK for smaller, faster-cold-start deployments.

📌 Key Takeaways

  • Infrastructure as Code replaces manual, error-prone console clicks with reviewable, version-controlled, repeatable definitions of your cloud resources.
  • AWS CDK lets JavaScript and TypeScript developers write infrastructure using the same language and tooling as their application code, with high-level constructs that bake in secure defaults.
  • Terraform trades some of that language familiarity for cloud-agnostic flexibility and one of the most mature module ecosystems available.
  • EC2, S3, Lambda, and databases can all be defined, versioned, and deployed declaratively — turning "how did I configure this?" into a solved problem.
  • Best practices like remote state, least-privilege IAM, tagging, and CI/CD-driven deployments are what separate toy projects from production-grade infrastructure.

Conclusion

Infrastructure as Code isn't just a DevOps buzzword — for JavaScript developers, it's an invitation to bring the same engineering discipline you already apply to application code (version control, code review, testing, modularity) to the cloud resources that code runs on.

Whether you choose AWS CDK for its TypeScript-native experience and AWS-optimized abstractions, or Terraform for its cloud-agnostic reach and enormous module ecosystem, the underlying shift is the same: infrastructure becomes something you read, review, and reproduce, not something you remember. Start small — provision a single S3 bucket or Lambda function with the tool of your choice — and build up from there. Once your infrastructure lives in code, you'll wonder how you ever managed it any other way.

References

All Articles
Infrastructure as CodeAWS CDKTerraformDevOpsJavaScriptCloud

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.