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.
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.
| Port | Source | Purpose |
|---|---|---|
| 22 | Your IP only | SSH |
| 80 | 0.0.0.0/0 | HTTP (redirects to HTTPS) |
| 443 | 0.0.0.0/0 | HTTPS |
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 pm2Verify versions:
node -v
npm -v
pm2 -vStep 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-appInstall dependencies and build
npm ci
npm run buildStart the app with PM2
pm2 start npm --name "nextjs-app" -- start
pm2 save
pm2 startuppm2 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:3000Step 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 nginxStep 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.comCertbot 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-runTIP
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 ast3, 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.smallfor 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-apppm2 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
.envfile 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