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:
- S3 Bucket: Hosts static files (HTML, CSS, JS, images)
- CloudFront Distribution: CDN layer for global content delivery
- Route 53 (optional): DNS management for custom domains
- ACM (optional): SSL certificate management
Static Export Configuration
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.
Build Static Site
This generates an out/ directory with all static assets. Test Locally
Verify the site works correctly at http://localhost:3000
S3 Bucket Setup
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.
Configure Bucket for Static Hosting
aws s3 website s3://your-portfolio-bucket \
--index-document index.html \
--error-document 404.html
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
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
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
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
}
]
}
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
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
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
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"
}
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:
- Lambda@Edge: Add image transformation at CloudFront edge
- CloudFront Functions: Lightweight image resizing
- Third-party CDN: Cloudinary, Imgix for advanced optimization
- 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
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/"
}
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
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