Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
DevOpsJavaScriptNode.jsTutorial

Next.js NestJS VPS deployment with nginx and PM2

Next.js NestJS VPS deployment with private listeners, locked builds, nginx routing, PM2 startup, browser verification and compatible release recovery.

Cloudsoft Zone

7 min read

Next.js + NestJS: VPS deployment. Sourced Next.js web, NestJS API, nginx reverse proxy artwork with article-specific deployment checkpoints.
Image: Cloudsoft Zone layout; Vercel / CC BY-SA 4.0; NestJS / Public domain; Igor Sysoev / Public domain
In this article

A Next.js frontend and a NestJS API can both run successfully over SSH, then disappear after a server restart. Another deployment fails differently: the browser calls the VPS's loopback address, nginx forwards the wrong path, or PM2 starts the API from a directory with no production build. These are integration problems, not reasons to reinstall Node.

This guide covers Next.js NestJS VPS deployment with nginx and PM2. We will give each application a private listener, supervise it under a non-root account, route two public hostnames and verify the full browser-to-API path. The target is one Linux VPS and one instance of each application. Database setup and high availability are separate decisions.

Use a staging VPS first. Record the application's locked Next.js, NestJS and Node versions, because build layouts and framework features vary. The commands below assume npm lockfiles and conventional separate application directories. For pnpm workspaces, use the committed workspace lockfile and your existing build scripts instead of converting package managers.

nginx routes public web and API hostnames to private Next.js and NestJS listeners and private state.
A browser uses the public API hostname, never the server's loopback address.

Step 1: Define the network and release boundaries

A simple layout keeps the frontend on 127.0.0.1:3000 and the API on 127.0.0.1:3001. nginx accepts public HTTP and HTTPS. MySQL, Redis and management interfaces remain private. PM2 manages application processes, not the database or nginx.

Text
app.example.com -> nginx -> Next.js 127.0.0.1:3000
api.example.com -> nginx -> NestJS  127.0.0.1:3001
                          -> private database and Redis

Separate hostnames keep API path rewriting out of the example. You can use one hostname with /api, but then document whether nginx preserves that prefix and whether Nest uses a global prefix. A browser's 127.0.0.1 refers to the visitor's computer, so it must never be the public API address in client-side code.

Use versioned directories such as /srv/saas/releases/<release-id>/web and /api, plus /srv/saas/current. Retain uploads and private configuration outside disposable releases. The deployment account owns code; request processes should not need root permissions.

Step 2: Build the exact release you intend to run

Install a supported Node version through your approved runtime method. Pin it in the project and CI. An interactive shell using one Node binary while the boot service uses another creates failures that are hard to reproduce.

For each application, install dependencies from its lockfile and run its build:

Shell
cd /srv/saas/releases/<release-id>/web
npm ci
npm run build

cd /srv/saas/releases/<release-id>/api
npm ci
npm run build

Build tools normally require development dependencies. Do not install with production-only dependencies before compiling TypeScript unless your build pipeline provides those tools separately. If you prune runtime dependencies afterwards, confirm the artifact still starts and includes native modules compiled for the target platform.

Inspect the real API entry point. A Nest project may produce dist/main.js or dist/src/main.js, and a monorepo can produce another layout. Set the PM2 script to the file your build actually generated. Do not guess and then mask the error with an endless restart loop.

Step 3: Make environment values explicit

Next.js public variables are included in the browser bundle at build time. Changing a runtime environment file does not rewrite an already-built NEXT_PUBLIC_ value. Never use that prefix for secrets. Server-only variables may be read at runtime where the rendering path actually executes dynamically; static generation can still evaluate values during the build.

A useful separation is:

INI
# Frontend build-time public setting, only if browser requests need it
NEXT_PUBLIC_API_ORIGIN=https://api.example.com

# Frontend server-only setting for server-side requests
API_ORIGIN=http://127.0.0.1:3001

# API runtime settings
NODE_ENV=production
PORT=3001
HOST=127.0.0.1

The frontend must implement the intended server/browser separation; merely naming a variable does not enforce it. Supply real secrets through a protected service environment or secret store. PM2 can expose environment values through inspection commands, so restrict access to the deployment account and keep diagnostics private.

In Nest's bootstrap, bind deliberately and enable shutdown hooks when the application has resources to close:

TypeScript
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(Number(process.env.PORT ?? 3001), '127.0.0.1');

Add configuration validation and a readiness route appropriate to your application. The literal host is intentional for this VPS example. A container deployment needs a different bind address.

Step 4: Start both applications with a reviewed PM2 declaration

Store a CommonJS ecosystem file named ecosystem.config.cjs, which also works when the project uses ES modules. This example assumes a standard next start deployment and an API output at dist/main.js:

JavaScript
module.exports = {
  apps: [
    {
      name: 'saas-web',
      cwd: '/srv/saas/current/web',
      script: './node_modules/next/dist/bin/next',
      args: 'start --hostname 127.0.0.1 --port 3000',
      instances: 1,
      exec_mode: 'fork',
      env: { NODE_ENV: 'production' },
      kill_timeout: 10000,
    },
    {
      name: 'saas-api',
      cwd: '/srv/saas/current/api',
      script: 'dist/main.js',
      instances: 1,
      exec_mode: 'fork',
      env: { NODE_ENV: 'production', PORT: '3001' },
      kill_timeout: 10000,
    },
  ],
};

Run PM2 as the deployment account. Configure its version through your reviewed toolchain; do not install an unspecified latest binary during every deployment. Start the declaration and check each listener directly:

Shell
pm2 start ecosystem.config.cjs
pm2 list
curl --fail --max-time 5 http://127.0.0.1:3000/
curl --fail --max-time 5 http://127.0.0.1:3001/health/ready

Implement the readiness route before using it. A PM2 ONLINE label cannot prove database connectivity or successful requests. Confirm the process working directory, listener, logs and restart count under the correct user.

PM2 deployment lifecycle covers verified build output, the correct process identity and reboot startup configuration.
An ONLINE label is not evidence that an application request succeeds.

Step 5: Put nginx in front of the private listeners

The following HTTP blocks are a bootstrap configuration. Add trusted TLS and renewal verification before accepting production credentials. Reuse your existing certificate procedure or our nginx HTTPS guide.

Nginx
server {
    listen 80;
    server_name app.example.com;
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_buffering off;
    }
}
server {
    listen 80;
    server_name api.example.com;
    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
    }
}

This example assumes nginx is the only trusted edge and overwrites client-supplied forwarding information. A CDN or additional proxy requires a separately reviewed trusted-proxy chain. Disabling buffering on the frontend supports streamed responses; confirm any intervening CDN does not buffer them again.

WebSockets require explicit upgrade handling when the application uses them. Set upload limits and timeouts for real routes rather than copying enormous global values. Apply a syntax-checked reload and verify requests through the selected public hostname.

Step 6: Configure startup and prove it after reboot

Under the deployment account, run pm2 startup. Execute only the administrator command PM2 prints for that user and runtime path, then save the intended process list:

Shell
pm2 startup
pm2 save

Check the generated service identity before using sudo. After upgrading Node, regenerate startup integration as documented by PM2; the old service may reference a removed binary. A saved process list and an enabled service are separate requirements.

On staging, reboot and repeat the public checks. That verifies the startup environment rather than the environment inherited from your SSH session. Configure log rotation and retention without recording authorization headers, cookies or database passwords.

Step 7: Verify browser behaviour and retain compatible releases

Deployment checks proceed from direct listener to public proxy route and browser authentication workflow.
Each boundary has its own observable check and recovery action.

Symptom Check first
Browser cannot reach API Public API origin, CORS allowlist and mixed content
nginx returns 502 Real upstream listener and PM2 working directory
Works until reboot Correct user's startup service and saved list
Old API URL after env change Frontend build-time public configuration
Streaming arrives all at once Proxy/CDN buffering along the whole route
Old tabs fail after release Asset retention and version-skew strategy

Verify login, a representative API request, logout, error states and a mobile layout. Cross-origin cookie authentication needs an intentional CORS/credentials/cookie policy; do not enable every origin to make a request succeed. For diagnosis, use our nginx 502 guide.

Keep the previous artifact and a compatible database state. This single-instance fork deployment may interrupt requests during restart; PM2 does not turn it into guaranteed zero downtime. Introduce multi-instance rolling releases only after reviewing Next.js cache coordination, encryption keys, old assets and API migration compatibility. For an application deployment review, contact Cloudsoft Zone.

Featured image: Cloudsoft Zone explanatory layout with sourced product artwork: nextjs — Vercel (CC BY-SA 4.0); nestjs — NestJS (Public domain); nginx — Igor Sysoev (Public domain). Product marks resized and arranged for identification; no endorsement implied. Layout: CC BY-SA 4.0.

Frequently asked questions

  • Can I point browser API requests at 127.0.0.1?

    No. In a visitor's browser that address refers to the visitor's machine. Use the public API origin or an intentional same-origin proxy.

  • Why does changing NEXT_PUBLIC_API_ORIGIN not change my built frontend?

    Public Next.js variables are embedded during the build. Rebuild with the reviewed public value; keep secrets server-side.

  • Does PM2 guarantee zero-downtime releases here?

    No. This single-instance fork example can interrupt requests. Multi-instance releases require application and state coordination.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Next.js self-hosting — Next.js
  2. NestJS deployment — NestJS
  3. PM2 ecosystem declaration — PM2
  4. PM2 startup integration — PM2
  5. nginx HTTP proxy module — nginx

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.