← All posts
·

Building This Blog: Next.js on AWS from Scratch

How I deployed a Next.js 16 static site to S3/CloudFront with a C# Lambda backend, all managed with Terraform and GitHub Actions.


Building This Blog: Next.js on AWS from Scratch

I've been a full-stack developer for a while, but my AWS experience has mostly been through managed services and existing infrastructure that someone else set up. So I gave myself a project with a concrete goal: build and ship a personal portfolio and blog with full ownership of every layer — the frontend, backend, infrastructure, and CI/CD pipeline.

This post is a tour of what I built, the decisions I made, and the things that bit me.

The architecture

The site is a monorepo with three distinct concerns:

  • Frontend: Next.js 16 with static export (output: 'export'), deployed to S3 and served through CloudFront
  • Backend: A C# ASP.NET Core minimal API running on AWS Lambda, fronted by API Gateway v2 (HTTP API)
  • Infrastructure: All AWS resources defined in Terraform, with separate dev and prod environments

For CI/CD I used GitHub Actions with three workflows: one for the frontend, one for Lambda deployments, and one for Terraform.

Why static export + S3?

A portfolio site has no dynamic server-side rendering needs. Every page can be pre-built at deploy time. Static export gives you:

  • No cold starts — CloudFront serves pre-built HTML directly from S3
  • Near-zero hosting cost — S3 and CloudFront free tiers cover a personal site comfortably
  • Global CDN — CloudFront edge caches automatically

The tradeoff is that any truly dynamic page (user-specific data, server-side sessions) isn't possible without the Lambda backend. For a contact form that's fine — the form submits to the API, the page itself is static.

Why C# on Lambda?

Honestly? Because I wanted to. TypeScript API routes would have been simpler, but I use C# day-to-day and wanted to see how well the .NET Lambda runtime holds up for this kind of workload.

The answer: pretty well. The serverless.AspNetCoreMinimalAPI template means I can write the API the same way I'd write a normal ASP.NET Core app, and LambdaEventSource.HttpApi handles the API Gateway v2 integration. The main constraint is the cold start — dotnet8 Lambda cold starts are ~1-3 seconds. For a contact form on a portfolio site that's acceptable.

The one gotcha: Lambda only supports net8.0. If you scaffold the project with the default net10.0 target (which dotnet new will pick if you have .NET 10 installed), you'll need to change it.

Terraform: the interesting bits

S3-native state locking

Terraform 1.10 introduced S3-native state locking via use_lockfile = true in the backend block, replacing the older dynamodb_table approach. If you're starting fresh, use it:

backend "s3" {
  bucket       = "my-terraform-state"
  key          = "prod/terraform.tfstate"
  region       = "us-east-1"
  use_lockfile = true
}

The DynamoDB table approach still works but is deprecated and requires provisioning an extra resource.

Multi-region ACM certificates

CloudFront requires ACM certificates to be provisioned in us-east-1 regardless of where your other resources live. This means your frontend Terraform module needs a second provider alias:

terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      version               = "~> 5.0"
      configuration_aliases = [aws.us_east_1]
    }
  }
}

Without configuration_aliases, Terraform will refuse to init the module.

CloudFront OAC

I used CloudFront Origin Access Control (OAC) rather than the older Origin Access Identity (OAI) to give CloudFront private access to the S3 bucket. OAC is the current recommended approach and supports all S3 signing protocols. With OAC, the S3 bucket stays completely private — no public access — and CloudFront is granted access via a bucket policy condition:

condition {
  test     = "StringEquals"
  variable = "AWS:SourceArn"
  values   = [aws_cloudfront_distribution.site.arn]
}

CI/CD: the things that tripped me up

CRLF line endings break GitHub Actions silently

On Windows with git config core.autocrlf = true, YAML files get CRLF line endings when committed. GitHub Actions silently ignores workflow files with CRLF — no error, the file just doesn't appear in the Actions tab. The fix is a .gitattributes file:

* text=auto eol=lf
*.yml eol=lf

This was the most frustrating thing to debug because GitHub gives you no indication of what's wrong.

GitHub OIDC: personal accounts work the same as orgs

The IAM role creation wizard asks for a "GitHub organization". If your repo is under a personal account, just enter your GitHub username. It works identically.

Re-running a job doesn't pick up workflow file changes

When you fix a bug in a workflow file and click "Re-run job", GitHub replays the original commit's workflow — not the updated one. You need to trigger a fresh run. workflow_dispatch (the "Run workflow" button) is the easiest way when the path filter won't fire naturally.

filebase64sha256 fails in CI if the zip doesn't exist

I'm using source_code_hash = filebase64sha256(var.lambda_zip_path) in the Lambda resource so Terraform detects code changes. This hash is computed at terraform plan time — which means the zip must exist before terraform plan runs. In CI, the Lambda package has to be built before terraform init:

- uses: actions/setup-dotnet@v4
  with:
    dotnet-version: '8.0.x'

- name: Build Lambda package
  working-directory: backend/Portfolio.Api
  run: dotnet lambda package --configuration Release --output-package bin/Release/net8.0/Portfolio.Api.zip

What I'd do differently

TypeScript API routes if I were optimizing for speed-to-ship. The C# Lambda is more fun but added significant setup overhead compared to a Next.js API route.

Fewer Terraform modules for a v1. I split the infrastructure into frontend, backend, and data modules. For a project this size, a flat main.tf would have been simpler until the need for reuse actually appeared.

What I'd do the same

Terraform for everything. Having the full infrastructure in code means I can tear it down, clone it to a new AWS account, or recreate it from scratch in under an hour. That's worth the initial investment.

GitHub OIDC instead of long-lived keys. A 30-minute setup that means no IAM access keys sitting in GitHub Secrets. Should be the default for any new project.

The full source is on GitHub.