EP ElasticPurple
← Back to Projects

Cloud Portfolio Infrastructure

Building a serverless 3-tier web application on AWS, then migrating to Terraform.

Overview

This project demonstrates a production-grade serverless architecture on AWS. The site you're reading this on is the result—a static portfolio with a DynamoDB-backed visitor counter, fully managed through Infrastructure as Code.

I built this in two phases: first manually via AWS Console/CLI to understand each service, then migrated everything to Terraform for reproducibility and version control.

Architecture

The stack consists of three tiers:

Frontend (Presentation Tier)

  • S3 — Static website hosting for HTML, CSS, JavaScript
  • CloudFront — CDN for global delivery with HTTPS
  • ACM — SSL/TLS certificate management
  • Route53 — DNS for custom domain

Backend (Logic Tier)

  • Lambda — Serverless function for visitor counter
  • Lambda Function URL — Direct HTTPS endpoint (no API Gateway needed)

Data Tier

  • DynamoDB — NoSQL table storing visitor count

Infrastructure & Deployment

  • Terraform — All resources defined as code
  • S3 Backend — Remote state with native locking
  • GitHub Actions — CI/CD for content deployment
  • WAF — Web application firewall with managed rule groups

Terraform Structure

The infrastructure is organized into two modules for separation of concerns:

terraform/
├── main.tf                    # Module composition
├── variables.tf               # Input variables
├── outputs.tf                 # Output values
├── terraform.tf               # Provider & backend config
├── modules/
│   ├── visitor-counter/       # Lambda, DynamoDB, IAM
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── website-frontend/      # S3, CloudFront, Route53, ACM, WAF
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── lambda_function.zip        # Python handler

Key Configuration Decisions

Multi-region providers: CloudFront requires ACM certificates in us-east-1, while the rest of my infrastructure runs in eu-central-1.

provider "aws" {
  region = "eu-central-1"
}

provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

S3 native state locking: Using the new S3-native locking instead of the deprecated DynamoDB approach.

backend "s3" {
  bucket       = "crc-terraform-state-11012026"
  key          = "crc/terraform.tfstate"
  region       = "eu-central-1"
  encrypt      = true
  use_lockfile = true  # S3 native locking
}

Lambda Function URL: Chose this over API Gateway for simplicity—it's a single endpoint that doesn't need the complexity of API Gateway's features.

Challenges & Solutions

Challenge 1: MIME Type Issues

Initial deployment loaded HTML but CSS/JS files failed. S3 wasn't setting correct Content-Type headers.

Solution: Explicit content-type mapping in GitHub Actions:

- name: Deploy to S3
  run: |
    aws s3 sync . s3://$BUCKET --delete \
      --exclude "*.html" --exclude "*.css" --exclude "*.js"
    aws s3 sync . s3://$BUCKET --delete \
      --include "*.html" --content-type "text/html"
    aws s3 sync . s3://$BUCKET --delete \
      --include "*.css" --content-type "text/css"
    aws s3 sync . s3://$BUCKET --delete \
      --include "*.js" --content-type "application/javascript"

Challenge 2: Terraform Import Drift

When importing existing manually-created resources, Terraform wanted to recreate them due to configuration mismatches.

Solution: Carefully matched Terraform config to existing resources, paying attention to:

  • IAM role paths (/service-role/ vs root path)
  • Exact CloudFront cache policy IDs
  • Resource naming conventions

Challenge 3: CloudFront Cache Invalidation

Content updates weren't visible immediately due to CDN caching.

Solution: Added invalidation step to CI/CD pipeline:

- name: Invalidate CloudFront
  run: |
    aws cloudfront create-invalidation \
      --distribution-id ${{ secrets.CLOUDFRONT_DIST_ID }} \
      --paths "/*"

Security Implementation

  • WAF — Three managed rule groups: IP Reputation, Common Rules, Known Bad Inputs
  • IAM Least Privilege — Lambda role only has DynamoDB access to specific table
  • S3 Block Public Access — All public access blocked; CloudFront uses OAC
  • HTTPS Only — CloudFront redirects HTTP to HTTPS
  • Secrets Management — AWS credentials stored in GitHub Secrets, never in code

Lessons Learned

  1. Manual first, then automate. Building manually taught me what each service actually does before abstracting it away with Terraform.
  2. IAM paths matter. The /service-role/ path isn't just cosmetic—it affects how resources reference each other.
  3. Modular Terraform pays off. Even for a small project, separating frontend and backend modules made debugging much easier.
  4. S3 native locking is the future. No more DynamoDB table for state locking.
  5. Lambda Function URLs are underrated. For simple use cases, they eliminate API Gateway complexity entirely.

Source Code

Both repositories are public: