Skip to main content

Overview

Deploy your Next.js portfolio as a static site to AWS S3 with CloudFront CDN distribution. This setup provides:
  • Global CDN: CloudFront edge locations for low-latency content delivery
  • Cost-effective: Pay only for storage and data transfer
  • Scalability: Automatic scaling to handle traffic spikes
  • SSL/TLS: Free SSL certificates via AWS Certificate Manager
This approach requires Next.js static export. For Server-Side Rendering (SSR), consider AWS Amplify or containerized deployments on ECS/Fargate.

Prerequisites

  • AWS Account with IAM permissions for S3, CloudFront, and ACM
  • AWS CLI installed and configured
  • Domain name (optional, for custom domains)
  • Next.js project with static export capability

Architecture

The deployment architecture uses:
  1. S3 Bucket: Hosts static files (HTML, CSS, JS, images)
  2. CloudFront Distribution: CDN layer for global content delivery
  3. Route 53 (optional): DNS management for custom domains
  4. ACM (optional): SSL certificate management

Static Export Configuration

1

Modify Next.js Config

Update next.config.mjs to enable static export:
const nextConfig = {
  output: "export",
  images: {
    unoptimized: true, // Required for static export
    remotePatterns: [
      {
        protocol: "https",
        hostname: "d2th3dc33uqqn2.cloudfront.net",
      },
      {
        protocol: "https",
        hostname: "cdn.jsdelivr.net",
      },
    ],
  },
};
Change output: "standalone" to output: "export" for static builds. This disables server-side features like API routes and ISR.
2

Build Static Site

npm run build
This generates an out/ directory with all static assets.
3

Test Locally

npx serve out
Verify the site works correctly at http://localhost:3000

S3 Bucket Setup

1

Create S3 Bucket

aws s3 mb s3://your-portfolio-bucket --region us-east-1
Use us-east-1 region for CloudFront integration to avoid additional data transfer costs.
2

Configure Bucket for Static Hosting

aws s3 website s3://your-portfolio-bucket \
  --index-document index.html \
  --error-document 404.html
3

Set Bucket Policy

Create a bucket policy file bucket-policy.json:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-portfolio-bucket/*"
    }
  ]
}
Apply the policy:
aws s3api put-bucket-policy \
  --bucket your-portfolio-bucket \
  --policy file://bucket-policy.json
4

Upload Static Files

aws s3 sync out/ s3://your-portfolio-bucket \
  --delete \
  --cache-control "public,max-age=31536000,immutable" \
  --exclude "*.html" \
  --exclude "*.json"

# Upload HTML with shorter cache
aws s3 sync out/ s3://your-portfolio-bucket \
  --delete \
  --cache-control "public,max-age=0,must-revalidate" \
  --exclude "*" \
  --include "*.html" \
  --include "*.json"
This sets long cache times for static assets (JS, CSS, images) and short cache for HTML to enable quick updates.

CloudFront Distribution Setup

1

Create Distribution

Create a CloudFront distribution configuration file cf-config.json:
{
  "CallerReference": "portfolio-distribution-1",
  "Comment": "Portfolio CDN Distribution",
  "DefaultRootObject": "index.html",
  "Origins": {
    "Quantity": 1,
    "Items": [
      {
        "Id": "S3-your-portfolio-bucket",
        "DomainName": "your-portfolio-bucket.s3.amazonaws.com",
        "S3OriginConfig": {
          "OriginAccessIdentity": ""
        }
      }
    ]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "S3-your-portfolio-bucket",
    "ViewerProtocolPolicy": "redirect-to-https",
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"]
    },
    "Compress": true,
    "MinTTL": 0
  },
  "Enabled": true
}
Create the distribution:
aws cloudfront create-distribution --distribution-config file://cf-config.json
2

Configure Custom Error Pages

Add custom error response for SPA routing:
aws cloudfront update-distribution --id YOUR_DISTRIBUTION_ID --if-match ETAG \
  --distribution-config file://cf-config-updated.json
Add to CustomErrorResponses:
"CustomErrorResponses": {
  "Quantity": 1,
  "Items": [
    {
      "ErrorCode": 404,
      "ResponsePagePath": "/404.html",
      "ResponseCode": "404",
      "ErrorCachingMinTTL": 300
    }
  ]
}
3

Enable Compression

CloudFront automatically compresses eligible files (CSS, JS) when Compress: true is set.
Enable Brotli compression in CloudFront settings for 15-20% better compression than gzip.

Custom Domain with SSL

1

Request SSL Certificate

Request certificate in ACM (must be in us-east-1 for CloudFront):
aws acm request-certificate \
  --domain-name luannguyen.net \
  --subject-alternative-names www.luannguyen.net \
  --validation-method DNS \
  --region us-east-1
2

Validate Certificate

Add the DNS validation CNAME records provided by ACM to your domain’s DNS settings.Check validation status:
aws acm describe-certificate --certificate-arn YOUR_CERT_ARN --region us-east-1
3

Add Custom Domain to CloudFront

Update CloudFront distribution with custom domain:
"Aliases": {
  "Quantity": 2,
  "Items": ["luannguyen.net", "www.luannguyen.net"]
},
"ViewerCertificate": {
  "ACMCertificateArn": "arn:aws:acm:us-east-1:ACCOUNT:certificate/CERT_ID",
  "SSLSupportMethod": "sni-only",
  "MinimumProtocolVersion": "TLSv1.2_2021"
}
4

Configure DNS

Create Route 53 records (or use your DNS provider):
# A record alias to CloudFront
aws route53 change-resource-record-sets \
  --hosted-zone-id YOUR_ZONE_ID \
  --change-batch file://dns-change.json
dns-change.json:
{
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "luannguyen.net",
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "d2th3dc33uqqn2.cloudfront.net",
          "EvaluateTargetHealth": false
        }
      }
    }
  ]
}
Z2FDTNDATAQYW2 is the hosted zone ID for all CloudFront distributions.

Automated Deployment Script

Create a deployment script deploy-aws.sh:
#!/bin/bash
set -e

BUCKET_NAME="your-portfolio-bucket"
DISTRIBUTION_ID="YOUR_CLOUDFRONT_ID"

echo "Building Next.js site..."
npm run build

echo "Syncing to S3..."
aws s3 sync out/ s3://$BUCKET_NAME \
  --delete \
  --cache-control "public,max-age=31536000,immutable" \
  --exclude "*.html" \
  --exclude "*.json"

aws s3 sync out/ s3://$BUCKET_NAME \
  --delete \
  --cache-control "public,max-age=0,must-revalidate" \
  --exclude "*" \
  --include "*.html" \
  --include "*.json"

echo "Invalidating CloudFront cache..."
aws cloudfront create-invalidation \
  --distribution-id $DISTRIBUTION_ID \
  --paths "/*"

echo "Deployment complete!"
Make executable and run:
chmod +x deploy-aws.sh
./deploy-aws.sh
CloudFront invalidations are free for the first 1,000 paths per month. Subsequent invalidations cost $0.005 per path.

Environment Variables

For build-time environment variables:
NEXT_PUBLIC_API_URL=https://api.example.com npm run build
Static exports bundle environment variables at build time. Runtime variables require a separate API or client-side configuration.

Image Optimization

The portfolio already uses CloudFront for image delivery:
images: {
  remotePatterns: [
    {
      protocol: "https",
      hostname: "d2th3dc33uqqn2.cloudfront.net",
    },
  ],
}
Options for Image Optimization:
  1. Lambda@Edge: Add image transformation at CloudFront edge
  2. CloudFront Functions: Lightweight image resizing
  3. Third-party CDN: Cloudinary, Imgix for advanced optimization
  4. Pre-build optimization: Optimize images during build with sharp
For static exports, set images.unoptimized: true in Next.js config, then use a third-party image CDN or pre-optimize images.

Cache Invalidation Strategy

After Each Deployment:
aws cloudfront create-invalidation \
  --distribution-id YOUR_DISTRIBUTION_ID \
  --paths "/*"
Selective Invalidation:
# Only invalidate HTML pages
aws cloudfront create-invalidation \
  --distribution-id YOUR_DISTRIBUTION_ID \
  --paths "/index.html" "/about.html" "/404.html"
No Invalidation (Recommended):
  • Use versioned asset URLs (Next.js does this automatically)
  • Set short cache on HTML, long cache on assets
  • Let natural TTL expire for changes

Monitoring & Analytics

1

Enable CloudFront Logging

aws cloudfront update-distribution \
  --id YOUR_DISTRIBUTION_ID \
  --distribution-config file://logging-config.json
Configure logging to S3:
"Logging": {
  "Enabled": true,
  "IncludeCookies": false,
  "Bucket": "your-logs-bucket.s3.amazonaws.com",
  "Prefix": "cloudfront/"
}
2

Set Up CloudWatch Alarms

Monitor key metrics:
  • 4xx/5xx error rates
  • Request count
  • Data transfer
aws cloudwatch put-metric-alarm \
  --alarm-name portfolio-high-errors \
  --alarm-description "Alert on high 5xx errors" \
  --metric-name 5xxErrorRate \
  --namespace AWS/CloudFront \
  --statistic Average \
  --period 300 \
  --threshold 5 \
  --comparison-operator GreaterThanThreshold
3

Integrate Analytics

Since Vercel Analytics won’t work on AWS, consider:
  • Google Analytics 4
  • AWS Pinpoint
  • Plausible Analytics (privacy-friendly)
  • Fathom Analytics

Cost Optimization

S3 Storage:
  • Enable S3 Intelligent-Tiering for automatic cost optimization
  • Use lifecycle policies to delete old deployment artifacts
CloudFront:
  • Use CloudFront price class to limit edge locations (Class 100 for US/Europe only)
  • Enable compression to reduce data transfer
  • Set appropriate cache TTLs to reduce origin requests
Estimated Monthly Costs:
  • S3 storage (1GB): ~$0.023
  • CloudFront (10GB transfer): ~$0.85
  • Route 53 (1 hosted zone): $0.50
  • Total: ~$1.50/month for low-traffic portfolio
Enable AWS Budgets to receive alerts when costs exceed thresholds.

CI/CD with GitHub Actions

Automate deployments with GitHub Actions:
name: Deploy to AWS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          NEXT_PUBLIC_SITE_URL: ${{ secrets.SITE_URL }}

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Sync to S3
        run: |
          aws s3 sync out/ s3://${{ secrets.S3_BUCKET }} \
            --delete \
            --cache-control "public,max-age=31536000,immutable" \
            --exclude "*.html" --exclude "*.json"
          aws s3 sync out/ s3://${{ secrets.S3_BUCKET }} \
            --delete \
            --cache-control "public,max-age=0,must-revalidate" \
            --exclude "*" --include "*.html" --include "*.json"

      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/*"
Store AWS credentials and IDs as GitHub repository secrets for security.

Limitations of Static Export

Static exports don’t support:
  • API Routes
  • Server-Side Rendering (SSR)
  • Incremental Static Regeneration (ISR)
  • Image Optimization (requires unoptimized: true)
  • Middleware
  • Internationalized Routing
Alternatives for Dynamic Features:
  • Use AWS Amplify for SSR support
  • Deploy Next.js to ECS/Fargate with Application Load Balancer
  • Use API Gateway + Lambda for serverless API routes

Build docs developers (and LLMs) love