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.
Internet
↓
Nginx (80 / 443) ← handles SSL, serves as reverse proxy
↓
Docker container (Next.js)
↓
App listens on localhost onlyThe key idea: Docker runs the app, Nginx is the only thing exposed to the internet, and AWS Security Groups act as the first firewall.
- An AWS account
- A domain name (pointed to your EC2 IP)
- Basic terminal knowledge
- A Next.js app ready to deploy
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 |This is important — lock it down from the start:ssh ubuntu@<YOUR_EC2_PUBLIC_IP>You're in. Let's set things up.
First, update everything:
sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-releaseWe'll use Node 20 LTS:
Verify it worked:
node -v
# Should output v20.x.xI use pnpm for faster installs and better monorepo support. Corepack makes this easy:
corepack enable
corepack prepare pnpm@latest --activateVerify:
pnpm -vWhy pnpm? It's faster than npm, uses less disk space, and handles workspaces better. If you prefer npm or yarn, adjust the Dockerfile accordingly.
We'll use Ubuntu's official packages — simple and stable:
sudo apt install -y docker.io docker-composeEnable Docker to start on boot:
sudo systemctl enable docker
sudo systemctl start dockerLet your user run Docker without sudo:
sudo usermod -aG docker $USER
newgrp dockerVerify:
docker --version
docker-compose --versionYou'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 storeClone your repo:
git clone https://github.com/your-username/your-repo.gitWhen 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.
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.
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 --buildYour app now runs on `http://localhost:3000` (not accessible from outside yet).
Nginx will handle SSL and proxy requests to your Docker container.
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginxBefore 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.confInside 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 nginxVisit `http://yourdomain.com` — you should see your app!
Free SSL in one command. Install Certbot:
sudo apt install -y certbot python3-certbot-nginxGet your certificate:
sudo certbot --nginxFollow 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-runYour site is now on HTTPS. 🔒
When you push changes:
cd ~/your-repo
git pull
docker-compose up -d --buildThat's it. Zero-downtime deployments are possible with more setup, but this works for most cases.
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 protectionGood 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"
Your Docker container probably isn't running:
docker-compose ps
docker-compose logsYou forgot to add yourself to the docker group:
sudo usermod -aG docker $USER
newgrp dockerCheck the cron job:
sudo systemctl status certbot.timerCheck if the container is bound to localhost:
docker-compose ps
# Should show 127.0.0.1:3000->3000/tcpThis 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.
0
0
0