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