DEV Community

Harshdeep Singh
Harshdeep Singh

Posted on • Originally published at theharshdeepsingh.com

Deploying a Next.js App to AWS with CI/CD Pipelines (Step-by-Step)

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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";
    }
}

Enter fullscreen mode Exit fullscreen mode
# 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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)"

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 startup to 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)