Kenny Tran

Dec 13, 2025 • 6 min read

How I Deploy Next.js Apps to AWS EC2 (The Simple Way)

A no-nonsense guide for developers who want full control without the complexity.

I've tried Vercel. I've tried Railway. They're great — until you need something they don't offer, or the bill surprises you.

So I went back to basics: an EC2 instance, Docker, and Nginx. It's not fancy, but it works, it's cheap, and I actually understand what's happening.

This guide documents my exact setup. No theoretical fluff — just what I actually run in production.


What We're Building

Internet
 ↓
Nginx (80 / 443) ← handles SSL, serves as reverse proxy
 ↓
Docker container (Next.js)
 ↓
App listens on localhost only

The key idea: Docker runs the app, Nginx is the only thing exposed to the internet, and AWS Security Groups act as the first firewall.


Prerequisites

- An AWS account
- A domain name (pointed to your EC2 IP)
- Basic terminal knowledge
- A Next.js app ready to deploy


Step 1: Create an EC2 Instance

Head to the AWS Console and launch a new instance:

| Setting | Value |
| ----------------- | ------------------------------- |
| OS. | Ubuntu 22.04 LTS |
| Instance type | `t3.micro` (free tier eligible) |
| Storage | 8-20GB depending on your app |

Security Group Rules

This is important — lock it down from the start:

Step 2: Connect to Your Server

ssh ubuntu@<YOUR_EC2_PUBLIC_IP>

You're in. Let's set things up.


Step 3: Install System Dependencies

First, update everything:

sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-release

Step 4: Install Node.js

We'll use Node 20 LTS:

Verify it worked:

node -v
# Should output v20.x.x

Step 5: Install pnpm

I use pnpm for faster installs and better monorepo support. Corepack makes this easy:

corepack enable
corepack prepare pnpm@latest --activate

Verify:

pnpm -v

Why pnpm? It's faster than npm, uses less disk space, and handles workspaces better. If you prefer npm or yarn, adjust the Dockerfile accordingly.

Step 6: Install Docker & Docker Compose

We'll use Ubuntu's official packages — simple and stable:

sudo apt install -y docker.io docker-compose

Enable Docker to start on boot:

sudo systemctl enable docker
sudo systemctl start docker

Let your user run Docker without sudo:

sudo usermod -aG docker $USER
newgrp docker

Verify:

docker --version
docker-compose --version

Step 7: Set Up Git Access

You'll need to pull your code from GitHub. I use HTTPS with a Personal Access Token (simpler than SSH keys on servers).

Store credentials so you don't re-authenticate every time:

git config --global credential.helper store

Clone your repo:

git clone https://github.com/your-username/your-repo.git

When prompted:

- Username: Your GitHub username
- Password: Your Personal Access Token (not your actual password)

Create a PAT: GitHub → Settings → Developer Settings → Personal Access Tokens → Generate new token (classic). Give it `repo` scope.


Step 8: Dockerize Your Next.js App

Create a `Dockerfile` in your project root:

FROM node:20-alpine

WORKDIR /app

# Copy package files first (better caching)
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

# Copy the rest
COPY . .
RUN pnpm build

EXPOSE 3000

CMD ["pnpm", "start"]

Why Alpine? Smaller image size (~150MB vs ~1GB for full Node image).

Why copy package files first? Docker caches layers. If your dependencies haven't changed, it won't reinstall them on every build.


Step 9: Create docker-compose.yml

This makes running your container reproducible:

version: "3.8"

services:
 web:
 build: .
 container_name: nextjs-app
 restart: always
 ports:
 - "127.0.0.1:3000:3000" # Only localhost, not public!

⚠️ **Critical:** Notice `127.0.0.1:3000:3000` — this binds the container to localhost only. If you use just `3000:3000`, Docker exposes it publicly, bypassing Nginx entirely.

Build and run:

docker-compose up -d --build

Your app now runs on `http://localhost:3000` (not accessible from outside yet).

Step 10: Install and Configure Nginx

Nginx will handle SSL and proxy requests to your Docker container.

sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx

Configure Request Header Buffers (Important!)

Before creating your site config, update the main Nginx config to handle large request headers. This is **critical if you use Supabase, NextAuth, or any auth system** that stores JWTs in cookies.

sudo nano /etc/nginx/nginx.conf

Inside the `http {}` block (not inside a `server {}` block), add:

http {
 # Handle large cookies (Supabase JWT + refresh token)
 client_header_buffer_size 16k;
 large_client_header_buffers 8 16k;

 # ... keep existing config below ...
}

Why? Supabase auth uses access tokens (JWT) and refresh tokens stored in cookies. These can easily exceed Nginx's default 8KB buffer, causing 400 or 431 errors.

Create your site config:

sudo nano /etc/nginx/sites-available/app

Paste this:

server {
 listen 80;
 server_name yourdomain.com;

 location / {
 proxy_pass http://localhost:3000;
 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;

 # Handle large Set-Cookie headers from Next.js (Supabase auth)
 proxy_buffer_size 32k;
 proxy_buffers 8 32k;
 proxy_busy_buffers_size 64k;
 }
}

Why the proxy buffers? When Next.js sets large cookies (like Supabase tokens), Nginx needs bigger buffers to handle the response headers. Without this, you'll get 502 "upstream sent too big header" errors.

Enable it:

sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default # Remove default site
sudo nginx -t # Test config
sudo systemctl reload nginx

Visit `http://yourdomain.com` — you should see your app!


Step 11: Add HTTPS with Let's Encrypt

Free SSL in one command. Install Certbot:

sudo apt install -y certbot python3-certbot-nginx

Get your certificate:

sudo certbot --nginx

Follow the prompts. Certbot will:

1. Verify you own the domain
2. Get a certificate
3. Automatically update your Nginx config
4. Set up auto-renewal

Verify auto-renewal is working:

sudo certbot renew --dry-run

Your site is now on HTTPS. 🔒


Updating Your App

When you push changes:

cd ~/your-repo
git pull
docker-compose up -d --build

That's it. Zero-downtime deployments are possible with more setup, but this works for most cases.


Security Checklist

Before you call it done:

- [ ] SSH restricted to your IP only
- [ ] Docker ports bound to `127.0.0.1`, not `0.0.0.0`
- [ ] Nginx is the only public-facing service
- [ ] HTTPS enabled with auto-renewal
- [ ] System packages updated (`sudo apt update && sudo apt upgrade`)
- [ ] Header buffers configured (if using Supabase/auth)
- [ ] Consider setting up fail2ban for SSH protection

When This Setup Makes Sense

Good for:

- MVPs and side projects
- Small production apps (<10k daily users)
- Apps using Supabase, NextAuth, or similar auth
- Learning infrastructure
- When you want full control
- Tight budgets (~$5-15/month)

Not ideal for:

- High-availability requirements
- Auto-scaling needs
- Teams without DevOps experience
- "I never want to think about servers"


Troubleshooting

"502 Bad Gateway" from Nginx

Your Docker container probably isn't running:

docker-compose ps
docker-compose logs

"Permission denied" when running Docker

You forgot to add yourself to the docker group:

sudo usermod -aG docker $USER
newgrp docker

SSL certificate not renewing

Check the cron job:

sudo systemctl status certbot.timer

App works locally but not through Nginx

Check if the container is bound to localhost:

docker-compose ps
# Should show 127.0.0.1:3000->3000/tcp

Wrapping Up

This isn't the "right" way to deploy — there's no such thing. But it's a **working** way that I use for real projects.

You get:

- Full control over your infrastructure
- Predictable costs
- No vendor lock-in
- Actually understanding what's running

The tradeoff? You're responsible for updates, security, and troubleshooting. For me, that's worth it.

Join Kenny on Peerlist!

Join amazing folks like Kenny and thousands of other builders on Peerlist.

peerlist.io/

It’s available... this username is available! 😃

Claim your username before it's too late!

This username is already taken, you’re a little late.😐

0

0

0