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-assetswith 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.
| Factor | AWS CDK | Terraform |
|---|---|---|
| Language | TypeScript, JS, Python, Java, Go, C# | HCL (or TypeScript via CDKTF) |
| Cloud support | AWS only (native) | Multi-cloud (AWS, GCP, Azure, etc.) |
| State management | Delegated to CloudFormation | Self-managed .tfstate file (local or remote) |
| Abstraction level | High-level constructs (L1/L2/L3) | Lower-level resource blocks, plus modules |
| Learning curve | Easier for JS/TS developers | Steeper if new to HCL, easier if multi-cloud is a goal |
| Community modules | Growing construct library | Massive, 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
.tfstatein an S3 backend with DynamoDB locking to prevent concurrent modification conflicts across a team. - Separate environments with distinct stacks or workspaces. Keep
dev,staging, andprodisolated — 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 difforterraform planbefore 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 deployorterraform applyfrom 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
.tfstatefiles or CDKcdk.context.jsoncache files with sensitive data to public repositories. State files can contain secrets in plaintext; always.gitignorethem 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 planoutput 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_destroyon 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_eachinstead ofcountwhen 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/assertionsmodule to assert that specific resources and properties exist before you ever deploy. - Use
terraform fmtandcdk 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 viaNodejsFunctionin 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
- AWS CDK Developer Guide — https://docs.aws.amazon.com/cdk/v2/guide/home.html
- AWS CDK API Reference — https://docs.aws.amazon.com/cdk/api/v2/
- Terraform AWS Provider Documentation — https://registry.terraform.io/providers/hashicorp/aws/latest/docs
- Terraform Language Documentation — https://developer.hashicorp.com/terraform/language
- CDK for Terraform (CDKTF) — https://developer.hashicorp.com/terraform/cdktf
- AWS Well-Architected Framework — https://aws.amazon.com/architecture/well-architected/