The first time I deployed a Next.js app to production, it took me three days. Not because the app was complicated — it was a straightforward portfolio site. It took three days because I had no idea what I was doing with AWS, I'd never written a GitHub Actions workflow, and every tutorial I found either skipped the hard parts or assumed I already knew them.
By the time I was done, I had a deployment pipeline I was genuinely proud of: push to main, GitHub Actions runs the build, tests pass, the app deploys to an EC2 instance behind CloudFront. Zero manual steps. Zero downtime deploys. Total cost: about $5/month.
This guide is the one I wish had existed. We're going to deploy a Next.js app to AWS from scratch — EC2 for compute, CloudFront for CDN, GitHub Actions for CI/CD — with every step explained so you understand what you're building, not just copying commands.
Why AWS Instead of Vercel?
This is a fair question. Vercel is genuinely excellent for Next.js, and for most projects it's the right call. You push, it deploys. Done.
AWS makes sense when:
- You need to control the infrastructure (compliance, data residency, custom VPC configuration)
- You're running other services (databases, queues, lambdas) in AWS and want everything in the same network
- You want to learn infrastructure skills that transfer to enterprise environments
- Your app has specific performance requirements that benefit from custom CloudFront configuration
- You're a freelancer or consultant who wants to bill separately for infrastructure
If none of those apply to you, use Vercel. This guide is for when they do.
The Architecture
Here's what we're building:
┌────────────────────────────────────────────────────────┐
│ GITHUB ACTIONS CI/CD │
│ │
│ Push to main → Build → Test → Deploy to EC2 │
└──────────────────────┬─────────────────────────────────┘
│ SSH deploy
▼
┌────────────────────────────────────────────────────────┐
│ AWS EC2 INSTANCE │
│ │
│ Ubuntu 22.04 LTS │
│ Node.js 20 + PM2 (process manager) │
│ Next.js app running on port 3000 │
│ Nginx reverse proxy (port 80/443 → 3000) │
└──────────────────────┬─────────────────────────────────┘
│ Origin
▼
┌────────────────────────────────────────────────────────┐
│ CLOUDFRONT CDN │
│ │
│ Static assets cached at edge (/_next/static/*) │
│ TTL: 1 year for static, 0 for HTML │
│ SSL termination via ACM certificate │
│ Custom domain: yourapp.com │
└────────────────────────────────────────────────────────┘
This isn't the only way to run Next.js on AWS. You could use Elastic Beanstalk, App Runner, ECS, or deploy static exports to S3 + CloudFront. The EC2 + CloudFront approach gives you the most control and transfers the most skills to enterprise environments.
Prerequisites
- An AWS account (free tier works for learning; a t3.micro is enough for small apps)
- A domain name (optional but recommended — we'll set up SSL)
- A GitHub repository with your Next.js app
- Basic familiarity with the AWS console
The total setup takes about 90 minutes the first time. After that, every deployment is automatic.
Step 1: Set Up the EC2 Instance
In the AWS Console, navigate to EC2 and launch a new instance. The settings that matter:
- AMI: Ubuntu Server 22.04 LTS (free tier eligible)
- Instance type: t3.micro (1 vCPU, 1GB RAM) for small apps; t3.small for medium traffic
- Key pair: Create a new one, download it — you'll need this for SSH and GitHub Actions
- Security group: Allow inbound traffic on ports 22 (SSH), 80 (HTTP), and 443 (HTTPS). Add your IP as the only source for port 22 (don't expose SSH to 0.0.0.0/0).
Once the instance is running, SSH in and set up the environment:
# Connect to your instance
ssh -i your-key.pem ubuntu@YOUR_EC2_PUBLIC_IP
# Update system packages
sudo apt-get update && sudo apt-get upgrade -y
# Install Node.js 20 via NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install PM2 globally (process manager for Node.js)
sudo npm install -g pm2
# Install Nginx
sudo apt-get install -y nginx
# Verify everything installed correctly
node --version # v20.x.x
npm --version # 10.x.x
pm2 --version # 5.x.x
nginx -v # nginx/1.24.x
Step 2: Configure Nginx as a Reverse Proxy
Nginx will listen on port 80 and forward requests to your Next.js app on port 3000. This is the standard setup for Node.js apps on Linux servers.
sudo nano /etc/nginx/sites-available/nextjs-app
Paste this configuration:
server {
listen 80;
server_name YOUR_DOMAIN.com www.YOUR_DOMAIN.com;
# Proxy requests to Next.js
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Cache Next.js static assets at Nginx level too
location /_next/static/ {
proxy_pass http://localhost:3000;
proxy_cache_valid 200 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
# Enable the site and test the config
sudo ln -s /etc/nginx/sites-available/nextjs-app /etc/nginx/sites-enabled/
sudo nginx -t # should say "test is successful"
sudo systemctl restart nginx
Step 3: The GitHub Actions Workflow
This is where the CI/CD magic happens. The workflow does four things: checks out code, runs your build, SSHs into the server, and restarts the app. Create this file in your repository:
mkdir -p .github/workflows
Create .github/workflows/deploy.yml:
name: Deploy to AWS EC2
on:
push:
branches: [main]
workflow_dispatch: # also allow manual triggers
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Build Next.js app
run: npm run build
env:
# Pass any build-time env vars here
NEXT_PUBLIC_GA_ID: ${{ secrets.NEXT_PUBLIC_GA_ID }}
- name: Deploy to EC2
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_PRIVATE_KEY }}
script: |
cd /var/www/nextjs-app
# Pull latest code
git pull origin main
# Install dependencies (production only)
npm ci --omit=dev
# Build the app on the server
npm run build
# Restart with PM2 (zero-downtime reload)
pm2 reload nextjs-app --update-env
echo "Deploy complete at $(date)"
Step 4: Set Up GitHub Secrets
In your GitHub repository, go to Settings → Secrets and variables → Actions, and add these three secrets:
Secret Name
Value
EC2_HOST
Your EC2 instance's public IP address or domain
EC2_PRIVATE_KEY
The full contents of your .pem file (including the BEGIN/END lines)
NEXT_PUBLIC_GA_ID
Your Google Analytics measurement ID (or any other public env vars)
For the private key, open the .pem file in a text editor, copy everything including -----BEGIN RSA PRIVATE KEY----- and -----END RSA PRIVATE KEY-----, and paste it as the secret value.
Step 5: First Deploy and PM2 Setup
Before the GitHub Action can work, you need to get the app running on the server for the first time:
# Clone your repo to the server
sudo mkdir -p /var/www/nextjs-app
sudo chown ubuntu:ubuntu /var/www/nextjs-app
cd /var/www/nextjs-app
git clone https://github.com/YOUR_USERNAME/YOUR_REPO.git .
# Create .env file on the server
nano .env
# Add your production environment variables here
# Install dependencies and build
npm ci
npm run build
# Start with PM2
pm2 start npm --name "nextjs-app" -- start
pm2 save # persist across server restarts
pm2 startup # generate startup script
# PM2 will output a command to run — run it
# Verify the app is running
pm2 status
curl http://localhost:3000 # should return HTML
Step 6: CloudFront CDN (Optional but Recommended)
CloudFront puts your app behind a global CDN, which means static assets load from an edge location near your users instead of your EC2 server. For most apps, this makes a meaningful difference in load times outside your server's region.
In the AWS Console, go to CloudFront and create a new distribution:
- Origin domain: Your EC2 public IP or domain (not localhost)
- Origin protocol policy: HTTP only (Nginx handles the connection to EC2)
- Viewer protocol policy: Redirect HTTP to HTTPS
-
Cache policy for
/_next/static/*:CachingOptimized— these files are content-addressed, so they can be cached for years -
Cache policy for
/*(HTML pages):CachingDisabled— Next.js handles its own cache headers; CloudFront should pass them through
If you have a domain, attach it to the CloudFront distribution and request an ACM (AWS Certificate Manager) certificate for free SSL. DNS validation takes about 15 minutes.
Watch: Next.js CI/CD to AWS EC2 with GitHub Actions
Common Pitfalls
1. Building on the server vs. building in CI
The workflow above builds in the GitHub Action AND on the server. That's redundant — you only need to do it in one place. For small apps, building on the server is fine (simpler). For larger teams, build in CI, upload the artifact, and skip the build step on the server. The tradeoff: artifacts can be large (100MB+), so you need S3 or similar to store them.
2. Forgetting to set NODE_ENV=production
When you run npm start (which runs next start), Next.js automatically sets NODE_ENV=production. But PM2 doesn't always inherit this. Be explicit in your PM2 config or startup command:
pm2 start npm --name "nextjs-app" -- start -- --NODE_ENV=production
3. Not configuring PM2 to restart on crash
By default PM2 restarts crashed processes, but you want to limit restarts to prevent crash loops. Add --max-restarts 10 and --min-uptime 5000 to your pm2 start command. Five seconds of uptime before a restart counts is usually enough to catch truly broken deployments.
4. SSH key permissions
The most common SSH error you'll hit is UNPROTECTED PRIVATE KEY FILE. GitHub Actions handles this correctly when you use appleboy/ssh-action, but if you're doing raw SSH commands, your .pem file needs chmod 400 your-key.pem — readable only by the owner, nothing else.
EC2 vs. Vercel vs. AWS Amplify — Which Should You Choose?
Factor
EC2 + CloudFront
Vercel
AWS Amplify
Setup time
90 min (first time)
5 min
20 min
Next.js feature support
Full (you control the runtime)
Full (built for Next.js)
Most features, some lag
Cost at low traffic
~$5/month (t3.micro)
Free tier, then $20+/month
Pay per build + hosting
Cost at high traffic
Predictable (fixed instance)
Can get expensive fast
Moderate
Infrastructure control
Full — you own everything
None — Vercel manages it
Partial
Learning value
High — enterprise-transferable
Low (it just works)
Medium
Best for
Learning, compliance, cost control
Speed, simplicity, teams
Existing AWS customers
TL;DR
- The stack: EC2 (compute) + Nginx (reverse proxy) + PM2 (process manager) + CloudFront (CDN) + GitHub Actions (CI/CD). Each layer has one job.
-
GitHub Actions workflow: trigger on push to main → install → lint → build → SSH into EC2 →
git pull→ rebuild →pm2 reload. About 25 lines of YAML. - Store secrets properly: EC2 host, private key, and env vars go in GitHub repository secrets — never hardcoded in workflow files.
-
PM2 is essential for production Node.js — it keeps the process alive, restarts on crash, and enables zero-downtime reloads. Run
pm2 startupto make it persist across server reboots. - CloudFront is optional but worth it — static assets cached at the edge make a real difference for users outside your server's region, and the free ACM SSL certificate saves you the hassle of Certbot configuration.
Top comments (0)