Back to Article List

Deploy NestJS 11 to a VPS with Nginx and PM2

Deploy NestJS 11 to a VPS with Nginx and PM2 - Deploy NestJS 11 to a VPS with Nginx and PM2

Preparing your NestJS application for production

Deploying NestJS means compiling TypeScript to JavaScript, managing environment-specific configuration, and setting up reliable process management. This guide covers the entire pipeline from local development to a production VPS.

You'll use PM2 to manage the process and restart it if it crashes. Nginx acts as a reverse proxy, listening on ports 80 and 443, forwarding requests to your NestJS app on a local port. Certbot provides SSL certificates from Let's Encrypt automatically. By the end, you'll have a production-grade setup handling traffic securely.

Building NestJS for production

NestJS requires compilation before deployment. The build process transforms TypeScript to JavaScript, optimizes dependencies, and generates a dist/ folder ready to run.

npm run build

This command runs the NestJS compiler using the build script in your package.json. It outputs to dist/ by default, with the entry point typically dist/main.js. The output folder contains all compiled code plus bundled node_modules.

Verify your package.json has the required scripts:

"scripts": {
  "build": "nest build",
  "start": "node dist/main.js",
  "start:dev": "nest start --watch",
  "start:prod": "node dist/main.js"
}

The start:prod script is useful for testing production builds locally before deploying to your VPS.

Test the build locally:

npm run build
npm run start:prod

Your app should start and respond to requests. Kill it with Ctrl+C.

Optimizing the build

NestJS CLI has optimization flags:

"build": "nest build --path tsconfig.prod.json"

Create a tsconfig.prod.json with stricter settings to catch errors:

{
  "extends": "./tsconfig.json",
  "exclude": ["node_modules", "dist", "test", "**/*spec.ts"],
  "compilerOptions": {
    "sourceMap": false,
    "declaration": false
  }
}

Disabling source maps and declarations saves build time and dist/ size. In production, stack traces use compiled line numbers, which your error tracking service can map back to source.

Transferring code to your VPS

You have two main approaches: Git-based deployment (pull on VPS) or file transfer (SCP, rsync). Git is cleaner for future updates and versioning.

Git-based deployment

SSH into your VPS and clone your repository:

ssh root@your-vps-ip

mkdir -p /var/www/my-nestjs-app
cd /var/www/my-nestjs-app

git clone https://github.com/yourusername/my-nestjs-app.git .
npm install --production
npm run build

Using --production skips devDependencies, saving time and disk space. Ensure your build script is included in dependencies (like @nestjs/cli), not devDependencies, or it will fail.

If your build fails due to missing deps, you may need --production after build instead:

npm install
npm run build
npm prune --production

This installs everything for build, builds, then removes devDependencies afterward.

File transfer with rsync

If you've built locally:

npm run build
rsync -az --exclude node_modules --exclude .git . root@your-vps-ip:/var/www/my-nestjs-app/

Then SSH and install production dependencies on the VPS:

cd /var/www/my-nestjs-app
npm install --production

Creating a PM2 ecosystem file

PM2 needs an ecosystem.config.js file describing how to start and manage your NestJS app.

module.exports = {
  apps: [
    {
      name: 'my-nestjs-app',
      script: './dist/main.js',
      cwd: '/var/www/my-nestjs-app',
      instances: 'max',
      exec_mode: 'cluster',
      env: {
        NODE_ENV: 'production',
        PORT: 3000
      },
      error_file: '/var/log/pm2/err.log',
      out_file: '/var/log/pm2/out.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
      merge_logs: true,
      autorestart: true,
      max_restarts: 10,
      min_uptime: '10s',
      max_memory_restart: '500M',
      kill_timeout: 10000,
      listen_timeout: 10000
    }
  ]
};

Key settings explained:

instances: 'max' runs one PM2 instance per CPU core, leveraging all processors.

exec_mode: 'cluster' enables zero-downtime reloads by restarting workers sequentially.

max_memory_restart: '500M' restarts workers if memory exceeds 500MB, preventing memory leak impact.

kill_timeout: 10000 waits 10 seconds for graceful shutdown before force-killing.

Commit this file to your repository so every deployment uses consistent configuration.

Configuring environment-specific settings

NestJS provides ConfigModule for managing environment variables per deployment stage.

ConfigModule.forRoot() setup

In your AppModule:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: `.env.${process.env.NODE_ENV}`,
      cache: true
    })
  ]
})
export class AppModule {}

Place ConfigModule first in your imports. This ensures other modules can access configuration immediately.

The envFilePath setting loads .env.production on your VPS (since NODE_ENV=production in PM2 config). You can specify multiple paths as a fallback:

envFilePath: [`.env.${process.env.NODE_ENV}`, '.env']

Environment files per stage

Create .env.production on your VPS (never commit to Git):

NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/nestjs_db
JWT_SECRET=your-secret-key
LOG_LEVEL=warn
REDIS_URL=redis://localhost:6379

Set restrictive file permissions:

chmod 600 /var/www/my-nestjs-app/.env.production

Only your app user can read it. This protects secrets from other system users.

Validation with environment variables

Validate configuration at startup to catch missing variables early:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { plainToClass } from 'class-transformer';
import { IsEnum, IsNumber, IsString, validateSync } from 'class-validator';

class EnvironmentVariables {
  @IsEnum(['development', 'production'])
  NODE_ENV: string;

  @IsNumber()
  PORT: number;

  @IsString()
  DATABASE_URL: string;

  @IsString()
  JWT_SECRET: string;
}

function validate(config: Record) {
  const validatedConfig = plainToClass(EnvironmentVariables, config, {
    enableImplicitConversion: true
  });
  const errors = validateSync(validatedConfig, {
    skipMissingProperties: false
  });
  if (errors.length > 0) {
    throw new Error(errors.toString());
  }
  return validatedConfig;
}

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: `.env.${process.env.NODE_ENV}`,
      validate
    })
  ]
})
export class AppModule {}

Now if DATABASE_URL or JWT_SECRET is missing, your app fails immediately with a clear error, not later when a query tries to execute.

Setting up Nginx as a reverse proxy

Nginx listens on ports 80 (HTTP) and 443 (HTTPS), forwarding requests to your NestJS app on localhost:3000.

Basic HTTP proxy configuration

Create /etc/nginx/sites-available/my-nestjs-app:

server {
    listen 80;
    server_name your-domain.com www.your-domain.com;

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

The headers are critical:

X-Real-IP passes the client's true IP to your app. Without it, req.ip is Nginx's IP (localhost).

X-Forwarded-For contains a list of proxies the request passed through.

X-Forwarded-Proto tells your app whether the original request was HTTP or HTTPS.

Upgrade and Connection headers enable WebSocket proxying.

Enable the configuration:

sudo ln -s /etc/nginx/sites-available/my-nestjs-app /etc/nginx/sites-enabled/
sudo nginx -t  # Test syntax
sudo systemctl restart nginx

SSL with Certbot

Your VPS includes Certbot. Obtain a free Let's Encrypt certificate:

sudo certbot certonly --nginx -d your-domain.com -d www.your-domain.com

Certbot asks you to verify domain ownership. Choose email verification if DNS records aren't updated yet.

Update your Nginx config to use HTTPS and redirect HTTP to HTTPS:

server {
    listen 80;
    server_name your-domain.com www.your-domain.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name your-domain.com www.your-domain.com;

    ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

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

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Visit https://your-domain.com in your browser. You should see your NestJS app. The address bar shows a green lock (secure).

Certbot automatically renews certificates 30 days before expiry via a cron job. No manual action needed.

WebSocket support in proxy

The Upgrade and Connection headers in the proxy config enable WebSocket proxying. Your NestJS app can use WebSockets out of the box. No additional Nginx configuration needed.

Health checks with @nestjs/terminus

Production apps need liveness and readiness checks. Kubernetes, Docker, and load balancers periodically ping health endpoints to verify your app is responsive.

Installing @nestjs/terminus

npm install @nestjs/terminus

Generate a health module:

nest generate module health
nest generate controller health

Implementing HTTP health indicator

Update src/health/health.controller.ts:

import { Controller, Get } from '@nestjs/common';
import {
  HealthCheck,
  HealthCheckService,
  HttpHealthIndicator
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private http: HttpHealthIndicator
  ) {}

  @Get('live')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => this.http.pingCheck('nestjs', 'http://localhost:3000/health/live')
    ]);
  }

  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.http.pingCheck('nestjs', 'http://localhost:3000/health/ready')
    ]);
  }
}

This creates /health/live and /health/ready endpoints. Liveness checks if the app is running. Readiness checks if it's ready to serve traffic (databases connected, caches warm, etc.).

Database health indicator

Add database checks:

import { Controller, Get } from '@nestjs/common';
import {
  HealthCheck,
  HealthCheckService,
  HttpHealthIndicator,
  TypeOrmHealthIndicator
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private http: HttpHealthIndicator,
    private db: TypeOrmHealthIndicator
  ) {}

  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.http.pingCheck('nestjs', 'http://localhost:3000/health/ready'),
      () => this.db.pingCheck('database')
    ]);
  }
}

Now /health/ready checks both HTTP and database connectivity. If the database is down, readiness fails, and your orchestration system marks the app unavailable.

Custom health checks

Add custom checks for dependencies like Redis:

import { Injectable } from '@nestjs/common';
import {
  HealthIndicator,
  HealthIndicatorResult,
  HealthCheckError
} from '@nestjs/terminus';
import { InjectRedis } from '@nestjs-modules/redis';
import { Redis } from 'ioredis';

@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
  constructor(@InjectRedis() private redis: Redis) {
    super();
  }

  async isHealthy(): Promise {
    try {
      await this.redis.ping();
      return this.getStatus('redis', true);
    } catch (error) {
      throw new HealthCheckError('Redis failed', this.getStatus('redis', false));
    }
  }
}

Then use it in your controller:

@Get('ready')
@HealthCheck()
readiness() {
  return this.health.check([
    () => this.http.pingCheck('nestjs', 'http://localhost:3000/health/ready'),
    () => this.db.pingCheck('database'),
    () => this.redis.isHealthy()
  ]);
}

Handling updates and zero-downtime deployments

When you push new code, you want zero downtime.

SSH to your VPS and pull the latest code:

cd /var/www/my-nestjs-app
git pull origin main
npm install
npm run build
pm2 reload my-nestjs-app

pm2 reload performs a graceful, rolling restart. It restarts one PM2 worker at a time. While one worker restarts, others handle traffic. Users see no downtime.

For multi-environment deployments (staging, production), use git tags or branches:

git pull origin main
git checkout v1.2.3  # Deploy a specific version
npm install
npm run build
pm2 reload my-nestjs-app

Automate this with CI/CD. A GitHub Actions workflow can push to your VPS and run teh deployment commands automatically when you push to the main branch.

NestJS production optimizations

Fastify adapter instead of Express

NestJS defaults to Express, but Fastify is faster and uses less memory. Switch in main.ts:

import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(
    AppModule,
    new FastifyAdapter({ logger: true })
  );
  
  await app.listen(3000, '0.0.0.0');
}

bootstrap();

Fastify also handles graceful shutdown more elegantly, as shown in cluster mode docs.

Compression middleware

Enable gzip compression:

import { Module } from '@nestjs/common';
import { APP_MIDDLEWARE } from '@nestjs/core';
import { CompressionMiddleware } from '@nestjs/common';

@Module({
  providers: [
    {
      provide: APP_MIDDLEWARE,
      useClass: CompressionMiddleware
    }
  ]
})
export class AppModule {}

Or with Express adapter:

import compression from 'compression';

app.use(compression());

Compression reduces response sizes by 60-80%, speeding up browsers and mobile clients.

CORS configuration

Configure CORS appropriately for production:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.enableCors({
    origin: ['https://yourdomain.com', 'https://www.yourdomain.com'],
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    credentials: true
  });
  
  await app.listen(3000);
}

bootstrap();

Whitelist your domain instead of using * (all origins), which is a security risk.

Logging for production

NestJS comes with a built-in Logger. Use it instead of console.log:

import { Logger } from '@nestjs/common';

export class UsersService {
  private logger = new Logger(UsersService.name);

  getUsers() {
    this.logger.log('Fetching users');
    return [...];
  }

  handleError(error: Error) {
    this.logger.error(`Error: ${error.message}`, error.stack);
  }
}

The Logger prefixes logs with the class name and timestamp, making debugging easier.

For advanced logging (sending logs to external services like Datadog, CloudWatch), consider custom transports with Winston or Pino.

Database migrations in deployment

If you use TypeORM with migrations, run them before starting your app:

npm run typeorm migration:run
pm2 start ecosystem.config.js

Add a migration script to package.json:

"scripts": {
  "typeorm": "typeorm-ts-node-esm",
  "migration:run": "npm run typeorm migration:run",
  "migration:create": "npm run typeorm migration:create"
}

For zero-downtime migrations, use feature flags or versioned APIs to support both old and new schema during the transition.

Troubleshooting production deployment

502 bad gateway errors

Nginx shows 502 when it can't reach your NestJS app. Check if the app is running:

pm2 list
pm2 logs my-nestjs-app --err

Common causes: app crashed on startup, missing environment variables, or port mismatch (app listening on 3001 instead of 3000).

Test locally:

curl http://localhost:3000/health

If this fails, your app isn't listening on 3000.

TypeScript errors in production

If you see Cannot find module errors, you're running TypeScript in production. Always compile first:

npm run build

Never run ts-node or tsx in production. It's slow and error-prone.

Module not found

This usually means devDependencies are missing. Ensure @nestjs/cli is in dependencies, not devDependencies. Or run npm install without --production flag.

SSL certificate issues

If HTTPS fails, check certificate paths in Nginx config match your domain. List active certificates:

sudo certbot certificates

If a certificate is missing, renew it:

sudo certbot renew --force-renewal -d your-domain.com

Your idea deserves better hosting

24/7 support 30-day money-back guarantee Cancel anytime
Billing Cycle

1 GB RAM VPS

$3.99 Save  50 %
$1.99 Monthly
  • 1 vCPU AMD EPYC
  • 30 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Firewall management
  • Free server monitoring

2 GB RAM VPS

$5.99 Save  17 %
$4.99 Monthly
  • 2 vCPU AMD EPYC
  • 30 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Firewall management
  • Free server monitoring

6 GB RAM VPS

$14.99 Save  33 %
$9.99 Monthly
  • 6 vCPU AMD EPYC
  • 70 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P1

$7.99 Save  25 %
$5.99 Monthly
  • 2 vCPU AMD EPYC
  • 4 GB RAM memory
  • 40 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P2

$14.99 Save  27 %
$10.99 Monthly
  • 2 vCPU AMD EPYC
  • 8 GB RAM memory
  • 80 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P4

$29.99 Save  20 %
$23.99 Monthly
  • 4 vCPU AMD EPYC
  • 16 GB RAM memory
  • 160 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P5

$36.49 Save  21 %
$28.99 Monthly
  • 8 vCPU AMD EPYC
  • 16 GB RAM memory
  • 180 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P6

$56.99 Save  21 %
$44.99 Monthly
  • 8 vCPU AMD EPYC
  • 32 GB RAM memory
  • 200 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P7

$69.99 Save  20 %
$55.99 Monthly
  • 16 vCPU AMD EPYC
  • 32 GB RAM memory
  • 240 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G1

$4.99 Save  20 %
$3.99 Monthly
  • 1 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 1 GB DDR5 memory
  • 25 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G2

$12.99 Save  23 %
$9.99 Monthly
  • 2 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 4 GB DDR5 memory
  • 50 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G4

$25.99 Save  27 %
$18.99 Monthly
  • 4 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 8 GB DDR5 memory
  • 100 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G5

$44.99 Save  33 %
$29.99 Monthly
  • 4 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 16 GB DDR5 memory
  • 150 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G6

$48.99 Save  31 %
$33.99 Monthly
  • 8 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 16 GB DDR5 memory
  • 200 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G7

$74.99 Save  27 %
$54.99 Monthly
  • 8 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 32 GB DDR5 memory
  • 250 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

FAQ

Can I run multiple NestJS instances on one VPS?

Yes. Create multiple apps in ecosystem.config.js, each on a different port. Nginx can route subdomains or paths to each app. Or use Docker to isolate instances.

Your ideas deserve better hosting

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.