From Manual AWS Setup to Infrastructure as Code: My CDK Journey

Last week, I wanted to host a static website on AWS. I started with creating S3 buckets, CloudFront distributions, and Route53 manually.

It worked, but it soon began to feel tedious and not reproducible.

For every new domain I needed to host, I would need to:

But this reproducibility problem can be solved with IaC.

I considered a few options: Terraform, Pulumi, or AWS CDK. I decided to go with AWS CDK. I thought it would be a great learning experience.

What We're Building

Here's the architecture we'll create with CDK:

Architecture Diagram

Our serverless infrastructure includes:

The Flow:

  1. User visits resume.sayaji.dev

  2. Route53 routes to CloudFront distribution

  3. CloudFront serves static files from S3

  4. JavaScript calls /count endpoint

  5. CloudFront routes /count to API Gateway (with secret header)

  6. API Gateway authorizer validates the secret

  7. Counter Lambda updates DynamoDB and returns count

  8. Frontend displays the visitor count

Why AWS CDK?

I think CDK is new territory for me. I have used other IaC tools like Terraform and I liked them. But I wanted to try something new.

CDK uses three core concepts:

I like to think of constructs as Lego bricks single, reusable pieces that create one or more AWS resources.

For example: - s3.Bucket → creates an AWS S3 bucket - cloudfront.Distribution → creates a CloudFront distribution - route53.ARecord → creates a DNS record

As a developer turned DevOps engineer, I find it easier to write code than YAML.

Below is a small example of creating an S3 bucket using CDK.

new s3.Bucket(scope, 'WebsiteBucket', {
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  encryption: s3.BucketEncryption.S3_MANAGED,
  enforceSSL: true,
});

Key Constructs

Let me show you the most important constructs I built.

1. S3 Bucket (Secure by Default)

export class WebsiteBucket extends Construct {
  public readonly bucket: s3.Bucket;

  constructor(scope: Construct, id: string, props: MyBucketProps) {
    super(scope, id);

    this.bucket = new s3.Bucket(this, "Bucket", {
      bucketName: props.bucketName,
      websiteIndexDocument: "index.html",
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, // Security first!
      removalPolicy: RemovalPolicy.DESTROY,
    });
  }
}

2. CloudFront Distribution (Multi-Origin)

This was the challenging part - Static content from S3 (default behavior) - API requests to API Gateway (path /count)

export class WebsiteDistribution extends Construct {
  constructor(scope: Construct, id: string, props: CloudFrontProps) {
    super(scope, id);

    // Optional API origin for dynamic features
    const apiOrigin = props.apiUrl
      ? new origins.HttpOrigin(extractDomain(props.apiUrl), {
          protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
          customHeaders: {
            "x-cf-secret": props.cfSecret.secretValueFromJson('x-cf-secret').unsafeUnwrap()
          },
        })
      : undefined;

    this.distribution = new cloudfront.Distribution(this, "Distribution", {
      defaultBehavior: {
        origin: props.s3Origin,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      // Route /count to API Gateway
      additionalBehaviors: apiOrigin ? {
        [props.apiPath]: {
          origin: apiOrigin,
          cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
        },
      } : undefined,
      domainNames: [props.siteDomain],
      certificate: props.certificate,
    });
  }
}
How do I stop direct API access?

I added x-cf-secret header to API requests injected by CloudFront, which the Lambda authorizer validates. This prevents direct API access!

3. Lambda + DynamoDB (Visitor Counter)

The counter Lambda tracks unique visitors by IP:

def handler(event, context):
    src_ip = event.get("headers", {}).get("x-forwarded-for", "").split(",")[0].strip()

    if not src_ip:
        return {"statusCode": 400, "body": json.dumps({"message": "No IP found"})}

    # Try to insert IP, fail silently if it exists
    try:
        table.put_item(
            Item={"id": src_ip},
            ConditionExpression="attribute_not_exists(id)"  # Only insert if new
        )
    except ClientError as e:
        if e.response["Error"]["Code"] != "ConditionalCheckFailedException":
            raise

    # Count all unique IPs
    total = table.scan(Select="COUNT")["Count"]

    return {
        "statusCode": 200,
        "body": json.dumps({"ip": src_ip, "unique_visitors": total})
    }

I know, I know, storing IP addresses is not the best way to do it, but it works for now (maybe Flask + SQLite in WAL mode. In-memory IP address rate limiting), but this is for the future.

DynamoDB table:

new dynamodb.Table(this, "CounterTable", {
  partitionKey: { name: "id", type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, // Serverless pricing!
});

The Pattern: Stack Composition

Now instead of creating one giant stack with all the constructs, I split the stack into multiple logical stacks.

const app = new cdk.App();

// 1. Base infrastructure (deploy once)
const base = new BaseInfraStack(app, "BaseInfraStack", {
  env: { account: "123456789", region: "us-east-1" },
});

// 2. Backend services (dynamic features)
const backend = new BackendStack(app, "BackendStack", {
  env: { account: "123456789", region: "us-east-1" },
});

// 3. Website stacks (reusable pattern)
new ResumeStack(app, "ResumeStack", {
  hostedZoneId: base.hostedZone.hostedZoneId,
  certificateArn: base.certificate.certificateArn,
  apiUrl: backend.apiUrl,
  cfSecret: backend.cfSecret,
});

app.synth();

Why this pattern?

  1. Deploy common resources once: Route53 and ACM Certificates will be the same for subdomains, so it best fits for BaseInfraStack, whereas BackendStack will have API Gateway and Lambda functions and WebsiteStack will have S3 bucket and CloudFront distribution.

  2. Pass outputs between stacks: We can pass outputs of one stack to another stack. For example, we require the API GW url and secret from BackendStack to WebsiteStack.

  3. Reuse patterns: Now this pattern can be reused for any subdomain I will create.

Challenges I Faced

1. Cross-Stack References

Passing values between stacks required enabling crossRegionReferences:

new BaseInfraStack(app, "BaseInfraStack", {
  crossRegionReferences: true, // Required!
});

2. API Gateway Authorizer

Setting up the Lambda authorizer was tricky. The authorizer must return an IAM policy:

def generate_policy(principal_id, effect, resource):
    return {
        "principalId": principal_id,
        "policyDocument": {
            "Version": "2012-10-17",
            "Statement": [{
                "Action": "execute-api:Invoke",
                "Effect": effect,  # "Allow" or "Deny"
                "Resource": resource
            }]
        },
    }

3. GitHub OIDC

Setting up GitHub OIDC was a bit tricky. I had to enable the oidc provider in the github-actions.yml file:

permissions:
  id-token: write
  contents: read

Testing Strategy

Python Unit Tests (Lambda Functions)

class CounterLambdaTest(unittest.TestCase):
    @mock.patch("lambda_functions.counter.index.table")
    def test_lambda_success(self, mock_table):
        mock_table.put_item.return_value = {}
        mock_table.scan.return_value = {"Count": 123}

        event = {"headers": {"x-forwarded-for": "1.2.3.4"}}
        response = handler(event, None)

        self.assertEqual(response["statusCode"], 200)
        self.assertIn("unique_visitors", json.loads(response["body"]))

Run tests:

# Python tests
python -m unittest discover -s ./test -p "test_*.py" -v

CI/CD Pipeline

I use GitHub Actions with a six-stage pipeline:

  1. Setup → Install dependencies (cached)
  2. Tests → Run Python + CDK tests
  3. Plan → CDK diff (show changes)
  4. Approval → Manual approval for production
  5. Deploy → Deploy to AWS
  6. Notify → Send notifications

Key features: - OIDC Authentication - No AWS credentials stored in GitHub! - PR Comments - Automatically comments CDK diff on pull requests - Manual Approval - Production deployments require approval

Lessons Learned

After building this infrastructure, here are my key takeaways:

  1. Start Simple - Just start and start small and fast. Don't overcomplicate things.

  2. Use Constructs where possible - Reusable constructs have saved me hours from writing the same code again and again.

  3. Security Shift Left - I blocked public access to S3, used OIDC and implemented authorizers

  4. Stack Composition - Separating stacks makes it easier to maintain and also it is safer to update one stack without breaking the other.

What's Next?

Now that we have the foundation solid, here are some ideas I will be exploring:

1. Secret Rotation

The API Secret is not rotated. We can either use Secret Manager to rotate the secret or use a rotation lambda function.

2. CDK Tests

In this setup we have not covered CDK unit tests. We can add CDK unit tests to ensure the infrastructure is built correctly.

3. Monitoring & Alerts

We can use New Relic or Data Dog to monitor our simple infrastructure.

4. Optimizing Costs

It costs me less than $5 per month to run this infrastructure.

. WAF for Security

Protect against DDoS and common attacks with AWS WAF rate limiting. (but this is expensive!)

Conclusion

It was fun doing this, and I learned a lot about CDK and AWS. As a developer, I find CDK fluid and feels like I am slipping into a familiar pair of shoes. We get to use constructs, functions, loops and variables and suddenly infrastructure stops feeling like configuration files and starts feeling like code.