DEPLOYMENTAWSEC2Next.jsNginx

Deploy your Next.js app to an EC2 web server, securely and cheaply

A step-by-step guide to running a production Next.js app on a single EC2 instance, locked down with Nginx, HTTPS, and a minimal security group — without paying for infrastructure you don't need.

·5 min read

Running Next.js on EC2 instead of a managed platform makes sense when you want full control over cost and configuration — a single t4g.small instance can comfortably serve a production app for a fraction of what managed compute charges, as long as it's set up correctly. This guide walks through provisioning, hardening, and deploying a Next.js app behind Nginx with HTTPS.

What you'll end up with

  • A single EC2 instance running your Next.js app with next start, managed by PM2 so it restarts on crash or reboot
  • Nginx as a reverse proxy in front of it, terminating TLS with a free Let's Encrypt certificate
  • A security group that only exposes ports 80/443 (and SSH from your IP), keeping the app port itself unreachable from the internet
  • A deploy flow you can run by hand or wire into CI later

NOTE

This guide assumes you already have an EC2 instance running Ubuntu 22.04 or later, and a domain name you can point an A record at.

Step 1 — Lock down the security group

Before installing anything, restrict inbound traffic. Your Next.js process should never be reachable directly — only Nginx should be exposed publicly.

PortSourcePurpose
22Your IP onlySSH
800.0.0.0/0HTTP (redirects to HTTPS)
4430.0.0.0/0HTTPS

Do not open port 3000 (or whatever port Next.js runs on) to the public internet — Nginx will proxy to it over localhost.

Step 2 — Install Node, PM2, and Nginx

SSH into the instance and install the runtime and process manager:

sudo apt update && sudo apt upgrade -y
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs nginx
sudo npm install -g pm2

Verify versions:

node -v
npm -v
pm2 -v

Step 3 — Pull and build your app

Clone the repository

cd /var/www
sudo git clone https://github.com/your-org/your-app.git
sudo chown -R $USER:$USER your-app
cd your-app

Install dependencies and build

npm ci
npm run build

Start the app with PM2

pm2 start npm --name "nextjs-app" -- start
pm2 save
pm2 startup

pm2 startup prints a command you need to run once with sudo — it registers PM2 as a systemd service so the app survives a reboot.

By default next start binds to port 3000. Confirm it's up:

curl -I http://localhost:3000

Step 4 — Reverse proxy with Nginx

Create a site config at /etc/nginx/sites-available/your-app:

server {
    listen 80;
    server_name your-domain.com;
 
    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_cache_bypass $http_upgrade;
        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;
    }
}

Enable it and reload Nginx:

sudo ln -s /etc/nginx/sites-available/your-app /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Step 5 — Add HTTPS with Let's Encrypt

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com -d www.your-domain.com

Certbot edits the Nginx config to add the TLS block and sets up auto-renewal via a systemd timer — no cron job needed. Confirm renewal works without actually renewing:

sudo certbot renew --dry-run

TIP

Certbot's auto-renewal runs twice daily by default and only renews certificates within 30 days of expiry, so this is safe to leave alone once the dry run passes.

Keeping cost down

A few choices matter more than instance size for cost:

  • Use a Graviton (t4g) instance — same burstable-CPU model as t3, roughly 20% cheaper for equivalent performance.
  • Attach an Elastic IP only while you need a stable address during setup, then leave it attached — an EIP is free while attached to a running instance, but billed hourly if left unattached.
  • Skip a load balancer until you actually need to scale past one instance — an ALB has its own hourly cost that outweighs a single t4g.small for low-traffic apps.
  • Set up a CloudWatch billing alarm at a threshold you'd notice, so an accidental resize or forgotten resource doesn't surprise you.

WARNING

If you later outgrow a single instance, don't manually load-balance across EC2 instances with Nginx — that's a good sign it's time for an Application Load Balancer with an Auto Scaling group instead.

Redeploying after changes

Once this is set up, shipping an update is:

cd /var/www/your-app
git pull
npm ci
npm run build
pm2 restart nextjs-app

pm2 restart does a near-instant restart, so the window where the app is unreachable is sub-second — acceptable for most low-traffic production apps, though a true zero-downtime setup would run two PM2 instances behind Nginx and swap traffic between them.

Next steps

This covers a single-instance deployment. From here, consider:

  • Moving environment secrets into AWS Systems Manager Parameter Store rather than a .env file on disk
  • Adding a GitHub Actions workflow that SSHes in and runs the redeploy steps above on every push to main
  • Setting up log rotation for PM2 logs (pm2-logrotate) so disk usage doesn't creep up over time