Deployment
Deployment & Production
The last mile. We connect to the live MySQL database, configure the server environment, build the Next.js app, put Nginx in front of it, wire up SSL, and keep it alive with PM2. When this chapter is done, osztromok.com runs on the new stack.
Final milestone: https://osztromok.com serves the
rebuilt Next.js app. Every public page loads from the ISR cache. The admin panel
is behind HTTPS and session auth. A single git pull + npm run build + pm2 reload
deploys future changes in under two minutes.
What the production stack looks like
Incoming request path
- Browser → Nginx (port 80/443, SSL termination)
- Nginx → Next.js (port 3000, localhost only)
- Next.js → MySQL (localhost socket or 127.0.0.1:3306)
- Static assets served by Nginx directly from
.next/static
Process management
- PM2 keeps Next.js running, auto-restarts on crash
- PM2 starts on system boot — survives server reboots
pm2 reloadfor zero-downtime deploys- PM2 log rotation so disk doesn't fill up
Pre-deployment checklist — local machine
Run npm run build locally. Fix any TypeScript or lint errors before they fail on the server. Check the build output table — every content route should show ISR, not Dynamic.
Copy .env.local variable names (not values!) to a checklist. You'll need to create these on the server: DATABASE_URL, AUTH_SECRET, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, REVALIDATE_TOKEN, NEXTAUTH_URL, NEXT_PUBLIC_SITE_URL.
Run npx prisma migrate status. If you used prisma db pull (Chapter 5) rather than Prisma-managed migrations, the status will show "baseline" — that's fine. No pending migrations = safe to deploy.
Confirm output is NOT set to 'export' (that disables Server Components and API routes). The default (no output key) is correct for a Node.js server deployment.
Create .nvmrc with the Node version you used locally (e.g. 20.11.0). This prevents silent version mismatches between dev and prod.
next.config.js — production settings
/** @type {import('next').NextConfig} */
const nextConfig = {
// ── Images ─────────────────────────────────────────────────────
// Allow Next.js Image component to serve images from your domain
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'osztromok.com' },
],
},
// ── Logging ────────────────────────────────────────────────────
// In production, suppress the verbose fetch cache logs
logging: {
fetches: { fullUrl: false },
},
// ── Headers ────────────────────────────────────────────────────
// Security headers — Nginx will add the SSL ones, but these help
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'same-origin' },
],
},
]
},
}
module.exports = nextConfig
Step-by-step deployment on the production server
Install Node.js on the server (if not already present)
Use NVM so you can switch versions without sudo:
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash $ source ~/.bashrc $ nvm install 20 # install Node 20 LTS $ nvm use 20 $ node -v # confirm: v20.x.x v20.11.0
Install PM2 globally
$ npm install -g pm2 $ pm2 -v 5.3.x
Clone the repo and install dependencies
$ cd /var/www $ git clone https://github.com/yourname/osztromok-next.git $ cd osztromok-next $ npm ci # clean install — uses package-lock.json exactly
npm ci on servers, not npm install.
ci never updates package-lock.json and fails fast if the
lockfile is out of date — preventing surprise version upgrades in production.
Create the production .env file
Never commit .env.local to git. Create it directly on the server:
$ nano .env.local
# Database — direct local connection (no SSH tunnel in production)
DATABASE_URL="mysql://dbuser:dbpassword@127.0.0.1:3306/osztromok"
# NextAuth
AUTH_SECRET="your-64-char-secret-from-npx-auth-secret"
NEXTAUTH_URL="https://osztromok.com"
# Admin credentials
ADMIN_USERNAME="admin"
ADMIN_PASSWORD_HASH="$2a$12$your-bcrypt-hash"
# ISR on-demand revalidation
REVALIDATE_TOKEN="your-random-hex-token"
# Used in kanji page for fetch() calls to our own API
NEXT_PUBLIC_SITE_URL="https://osztromok.com"
# Disable Prisma query logging in production
NODE_ENV="production"
Generate Prisma client and run the build
$ npx prisma generate # regenerate client for production node_modules $ npm run build Creating an optimized production build ... ✓ Compiled successfully ✓ Linting and checking validity of types Route (app) Size First Load JS ┌ ○ / 4.2 kB 88.4 kB ├ ○ /login 2.1 kB 76.3 kB ├ ● /[subject] 1.8 kB 74.1 kB ├ ● /[subject]/[subtopic] 2.2 kB 74.5 kB ├ ● /[subject]/[subtopic]/[page] 3.1 kB 75.4 kB ├ ● /japan/kanji/[...char] 1.9 kB 74.2 kB └ ○ /admin 5.8 kB 92.3 kB ● (ISR) prerendered as static HTML (uses revalidate) ○ (Static) prerendered as static HTML ✓ Build complete.
○ = fully static (never changes).
● = ISR (rebuilt on demand after the revalidate window).
λ = server-rendered on every request (you shouldn't see this for content pages).
If a content route shows λ, check for an accidental
dynamic = 'force-dynamic' or uncached cookies() / headers() call.
Start the app with PM2
module.exports = {
apps: [{
name: 'osztromok-next',
script: 'node_modules/.bin/next',
args: 'start',
cwd: '/var/www/osztromok-next',
instances: 1, // 1 instance — scale up if needed
exec_mode: 'fork',
env: {
PORT: 3000,
NODE_ENV: 'production',
},
// Log files
out_file: './logs/out.log',
error_file: './logs/error.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
max_memory_restart: '512M', // restart if memory exceeds 512 MB
}],
}
$ mkdir -p logs $ pm2 start ecosystem.config.js [PM2] Starting /var/www/osztromok-next/node_modules/.bin/next in fork_mode (1 instance) [PM2] Done. $ pm2 status ┌────┬────────────────────┬─────────┬───────┬──────┬──────────┐ │ id │ name │ status │ cpu │ mem │ uptime │ ├────┼────────────────────┼─────────┼───────┼──────┼──────────┤ │ 0 │ osztromok-next │ online │ 0.1% │ 87mb │ 0s │ └────┴────────────────────┴─────────┴───────┴──────┴──────────┘ $ pm2 save # save process list to disk $ pm2 startup # generate systemd service (auto-start on reboot) [PM2] To setup the Startup Script, copy/paste the following command: sudo env PATH=... pm2 startup systemd -u ubuntu --hp /home/ubuntu # Run the command PM2 prints — it registers the process with systemd
Configure Nginx as a reverse proxy
Replace (or update) the existing Nginx site config. The key change is pointing
proxy_pass to port 3000 instead of the old FastAPI port (8000).
# Redirect HTTP → HTTPS
server {
listen 80;
server_name osztromok.com www.osztromok.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name osztromok.com www.osztromok.com;
# SSL — managed by Certbot (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/osztromok.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/osztromok.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# ── Serve Next.js static assets directly from disk ──────────────
# Bypasses Node.js entirely for CSS/JS/images — much faster
location /_next/static/ {
alias /var/www/osztromok-next/.next/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# ── Serve files from /public directly ────────────────────────
location /resources/ {
alias /var/www/osztromok-next/public/resources/;
expires 7d;
}
# ── Everything else → Next.js on port 3000 ─────────────────
location / {
proxy_pass http://127.0.0.1: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;
# Give Next.js up to 60s to respond (ISR rebuild can take a moment)
proxy_read_timeout 60s;
}
}
$ sudo nginx -t # test config — must say "ok" nginx: configuration file /etc/nginx/nginx.conf test is successful $ sudo systemctl reload nginx
Smoke test every route type
$ curl -I https://osztromok.com/ HTTP/2 200 ✓ home page $ curl -I https://osztromok.com/japan HTTP/2 200 ✓ subject page (ISR) $ curl -I https://osztromok.com/japan/kanji/水 HTTP/2 200 ✓ kanji catch-all (on-demand cached) $ curl -I https://osztromok.com/japan/made-up-slug HTTP/2 404 ✓ correct 404 for unknown slugs $ curl -I https://osztromok.com/admin HTTP/2 307 ✓ redirects to /login (unauthenticated) $ curl https://osztromok.com/api/subjects [{"id":1,"name":"Japanese","slug":"japan",...}] ✓ JSON API
Deploying future changes — the update workflow
After the first deploy, every subsequent update is the same four commands.
PM2's reload (not restart) performs a zero-downtime
swap — the old process handles existing connections while the new one starts.
$ cd /var/www/osztromok-next $ git pull origin main $ npm ci $ npx prisma generate $ npm run build $ pm2 reload osztromok-next # zero-downtime reload ✓ Reloaded successfully
#!/bin/bash
set -e # stop on any error
cd /var/www/osztromok-next
echo "→ Pulling latest code…"
git pull origin main
echo "→ Installing dependencies…"
npm ci
echo "→ Generating Prisma client…"
npx prisma generate
echo "→ Building…"
npm run build
echo "→ Reloading PM2…"
pm2 reload osztromok-next
echo "✓ Deployed successfully at $(date)"
$ chmod +x deploy.sh $ ./deploy.sh
PM2 day-to-day commands
# ── Process status ────────────────────────────────────────────
pm2 status # list all processes with CPU/memory
pm2 show osztromok-next # detailed info for this process
# ── Logs ──────────────────────────────────────────────────────
pm2 logs osztromok-next # tail live logs (Ctrl+C to exit)
pm2 logs osztromok-next --lines 100 # last 100 lines
pm2 flush # clear all log files
# ── Lifecycle ─────────────────────────────────────────────────
pm2 reload osztromok-next # zero-downtime reload (use for deploys)
pm2 restart osztromok-next # full restart (use if reload doesn't help)
pm2 stop osztromok-next # stop without removing from process list
pm2 delete osztromok-next # stop and remove from list
# ── Monitoring ────────────────────────────────────────────────
pm2 monit # live CPU/memory dashboard in terminal
Common production issues and fixes
502 Bad Gateway
- Next.js isn't running →
pm2 status - Wrong port in Nginx → check
proxy_passis:3000 - Build failed →
pm2 logsto see the error - Missing
.env.local→ process crashes on startup
ISR pages not updating
- Call
POST /api/revalidatewith the correct token - Check
revalidateis exported from the page file - Confirm Nginx isn't caching the response itself (check
proxy_cache) pm2 restartclears the in-memory ISR cache as last resort
Database connection refused
- Check
DATABASE_URLin.env.local— correct user/pass/dbname? - MySQL user must have privileges:
GRANT ALL ON osztromok.* TO 'dbuser'@'localhost'; - Confirm MySQL is running:
systemctl status mysql - Test directly:
mysql -u dbuser -p osztromok
Static assets 404
- Run build first —
.next/static/only exists afternpm run build - Check Nginx
aliaspath ends with/ - Permissions: Nginx user (
www-data) needs read access to.next/static/