Vercel is the easiest way to deploy Next.js, but not every project can use it — budget constraints, compliance requirements, need for custom server configuration, or just wanting full control. AWS EC2 gives you a real Linux server you can configure exactly how you need.
This guide takes you from a fresh EC2 instance to a production-ready Next.js deployment with HTTPS, process management, and automatic restarts.
In the AWS Console:
.pem fileAfter launch, get your instance's public IP from the console.
# Connect via SSH
chmod 400 your-key.pem
ssh -i your-key.pem ubuntu@YOUR_EC2_IP
# Update system
sudo apt update && sudo apt upgrade -y
# Install Node.js 20 via NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Install PM2 globally
sudo npm install -g pm2
# Install Nginx
sudo apt install -y nginx
# Verify
node --version # v20.x.x
pm2 --version
nginx -v# Create app directory
sudo mkdir -p /var/www/myapp
sudo chown ubuntu:ubuntu /var/www/myapp
# Clone your repository
cd /var/www/myapp
git clone https://github.com/yourusername/your-nextjs-app.git .
# Install dependencies
npm ci --production=false
# Build the Next.js app
npm run buildCreate your environment file:
# /var/www/myapp/.env.production
nano .env.productionNODE_ENV=production
DATABASE_URL=your_database_url
NEXTAUTH_SECRET=your_secret
NEXTAUTH_URL=https://yourdomain.comPM2 keeps your Node.js process alive, restarts on crash, and starts on server reboot:
// /var/www/myapp/ecosystem.config.js
module.exports = {
apps: [
{
name: 'nextjs-app',
script: 'node_modules/.bin/next',
args: 'start',
cwd: '/var/www/myapp',
instances: 'max', // Use all CPU cores
exec_mode: 'cluster', // Cluster mode for multi-core
watch: false,
max_memory_restart: '1G',
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
error_file: '/var/log/pm2/nextjs-error.log',
out_file: '/var/log/pm2/nextjs-out.log',
merge_logs: true,
time: true,
},
],
}# Create log directory
sudo mkdir -p /var/log/pm2
sudo chown ubuntu:ubuntu /var/log/pm2
# Start the app
pm2 start ecosystem.config.js --env production
# Save PM2 config so it restarts after server reboot
pm2 save
# Set up PM2 to start on boot
pm2 startup
# Run the command it outputs (usually: sudo env PATH=... pm2 startup ...)Nginx sits in front of your Node.js app — handling SSL, gzip compression, and static assets:
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
# Redirect HTTP to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
# SSL — will be filled in by Certbot
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
# Next.js static assets — cache aggressively
location /_next/static/ {
alias /var/www/myapp/.next/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Public folder
location /public/ {
alias /var/www/myapp/public/;
expires 1d;
}
# Proxy everything else 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;
proxy_read_timeout 60s;
}
}# Enable the site
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
# Test config
sudo nginx -t
# Reload
sudo systemctl reload nginx# Install Certbot
sudo apt install -y certbot python3-certbot-nginx
# Get certificate (temporarily opens port 80)
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Certbot automatically modifies your Nginx config and sets up auto-renewal
# Verify renewal will work
sudo certbot renew --dry-runYour site is now live at https://yourdomain.com.
Create a deployment script:
# /var/www/myapp/deploy.sh
#!/bin/bash
set -e
echo "Starting deployment..."
cd /var/www/myapp
# Pull latest code
git pull origin main
# Install new dependencies
npm ci --production=false
# Build
npm run build
# Reload PM2 with zero downtime
pm2 reload ecosystem.config.js --env production
echo "Deployment complete!"chmod +x deploy.shFor GitHub Actions automation, add a deploy job that SSH's in and runs this script — exactly like the CI/CD pipeline covered in the GitHub Actions article on this blog.
# Real-time logs
pm2 logs nextjs-app --lines 100
# Process status
pm2 status
# CPU/memory usage
pm2 monit
# Nginx access logs
sudo tail -f /var/log/nginx/access.log
# Nginx error logs
sudo tail -f /var/log/nginx/error.log1. Not setting up a firewall. Enable UFW:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable2. Running Node as root. Use the ubuntu user, not root.
3. Forgetting NEXTAUTH_URL in production. NextAuth needs to know the production URL — without it, OAuth callbacks fail.
4. Not configuring swap. Small instances can run out of RAM during npm run build. Add 2GB swap:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab5. Not caching static assets in Nginx. Next.js generates hashed filenames for JS/CSS — they can be cached permanently. The Nginx config above handles this.