Deployment

Chapter 10 — Deployment & Production | Next.js Course
Next.js Rebuild Course Chapter 10 of 10 — Final Chapter

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 reload for zero-downtime deploys
  • PM2 log rotation so disk doesn't fill up
Same server as before. The original FastAPI + Nginx setup already exists. We're replacing the FastAPI process with a Next.js process — Nginx config changes are minimal. MySQL stays exactly where it is.

Pre-deployment checklist — local machine

npm run build passes with zero errors

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.

All environment variables documented

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.

prisma migrate status is clean

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.

next.config.js output mode set

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.

Node.js version pinned

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

next.config.js JavaScript
/** @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

1

Install Node.js on the server (if not already present)

Use NVM so you can switch versions without sudo:

SSH — production server
$ 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
2

Install PM2 globally

SSH
$ npm install -g pm2
$ pm2 -v
5.3.x
3

Clone the repo and install dependencies

SSH
$ cd /var/www
$ git clone https://github.com/yourname/osztromok-next.git
$ cd osztromok-next
$ npm ci                  # clean install — uses package-lock.json exactly
Use 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.
4

Create the production .env file

Never commit .env.local to git. Create it directly on the server:

SSH
$ nano .env.local
.env.local — on the production server Environment
# 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"
5

Generate Prisma client and run the build

SSH
$ 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.
Build output legend: = 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.
6

Start the app with PM2

ecosystem.config.js — PM2 configuration file (commit this to git) JavaScript
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
  }],
}
SSH
$ 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
7

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).

/etc/nginx/sites-available/osztromok.com Nginx config
# 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;
    }
}
SSH
$ sudo nginx -t                # test config — must say "ok"
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx
8

Smoke test every route type

SSH or local machine
$ 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.

SSH — deploy script (save as deploy.sh)
$ 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
deploy.sh — make it one command Bash
#!/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)"
SSH — make it executable once
$ chmod +x deploy.sh
$ ./deploy.sh

PM2 day-to-day commands

PM2 cheat sheet Terminal
# ── 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_pass is :3000
  • Build failed → pm2 logs to see the error
  • Missing .env.local → process crashes on startup

ISR pages not updating

  • Call POST /api/revalidate with the correct token
  • Check revalidate is exported from the page file
  • Confirm Nginx isn't caching the response itself (check proxy_cache)
  • pm2 restart clears the in-memory ISR cache as last resort

Database connection refused

  • Check DATABASE_URL in .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 after npm run build
  • Check Nginx alias path ends with /
  • Permissions: Nginx user (www-data) needs read access to .next/static/
🎉

Course Complete — osztromok.com is rebuilt

You've taken the site from a FastAPI/Jinja2 stack all the way to a production Next.js 14 app with TypeScript, Prisma, Tailwind, NextAuth, Server Actions, ISR caching, and a TinyMCE-powered admin panel. The architecture is faster, more maintainable, and entirely yours.

Next.js 14 TypeScript React Prisma ORM MySQL Tailwind CSS NextAuth.js v5 PM2 Nginx ISR Server Actions Zod