Deployment and production environment

This guide explains how to deploy a VextJS application into production, covering practices such as building, Docker containerization, Nginx reverse proxy, PM2 process management, log collection, and health checks.

Publish the document station to the organization homepage

If you want to access the site directly through https://vextjs.github.io instead of https://vextjs.github.io/vext after building the document in the vext repository, the current repository already supports dual-mode publishing:

  1. Add VEXTJS_GH_PAGES_TOKEN in GitHub Secrets of vext repository
    • It is recommended to use Fine-grained PAT
    • Grant at least vextjs/vextjs.github.io repository Contents: Read and write
  2. Make sure the GitHub Pages source of the vextjs.github.io repository is main / root
  3. Keep .github/workflows/docs.yml using the current default logic:
    • Exists VEXTJS_GH_PAGES_TOKEN → After the document is built, it is published to the root directory of vextjs/vextjs.github.io, and the access address is https://vextjs.github.io
    • Does not exist VEXTJS_GH_PAGES_TOKEN → Return to the current warehouse Pages, the access address is https://vextjs.github.io/vext

Implementation details: The workflow will automatically switch the build parameters; use VEXT_DOCS_BASE=/ in root domain name mode, and use VEXT_DOCS_BASE=/vext/ in fallback mode.

Build production product

vext build

Use vext build to refresh the generated / manifest tool artifact and compile the TypeScript source code into production-grade JavaScript:

vext build

The compiled product is output to the dist/ directory, maintaining the same directory structure as src/:


If the deployment pipeline requires type checking, use `vext build --typecheck`. This command will first refresh `.vext/types/`, `src/types/generated/index.d.ts` and `.vext/manifest/` before executing `tsc --noEmit` and production compilation.
src/dist/
├── index.ts → ├── index.js + index.js.map
├── config/ ├── config/
│ ├── default.ts → │ ├── default.js
│ └── production.ts → │ └── production.js
├── routes/ ├── routes/
│ └── users.ts → │ └── users.js
├── services/ ├── services/
│ └── user.ts → │ └── user.js
├── plugins/ ├── plugins/
│ └── redis.ts → │ └── redis.js
└── middlewares/ └── middlewares/
    └── auth.ts → └── auth.js

Compile options

OptionsDefaultDescription
Source MapOn (external .js.map)Error stack mapped back to TypeScript line numbers
MinifyOffOptional on, reduce product volume
Targetnode20Align with engines.node >= 20.19.0
FormatCJSCommonJS output, Node.js runs stably
Tree ShakingEnableRemove unused exports
Keep NamesOnKeep function/class names (error stack readability)

Compile Exclusion

Production compilation automatically excludes the following files:

  • *.d.ts — type declaration
  • *.test.* / *.spec.* — test files
  • __tests__/ — test directory
  • config/development.* — development environment configuration
  • config/local.* — local override configuration
  • config/test.* — test environment configuration

Compile the bottom layer

vext build is implemented based on esbuild, and the pure compilation phase is extremely fast. Compilation time for a typical project (50+ source files) is usually under 1 second**.

process.env.NODE_ENV = "production" will be automatically injected during compilation, so the environment branch in the user source code after build will be statically folded according to production semantics; the runtime config profile is selected independently with --config or VEXT_CONFIG.

Start production service

Start directly

# Use vext start (recommended)
vext start

# You can also load a custom config profile (src/config/sg-sit.ts needs to exist)
vext start --config sg-sit

# Enable Source Map support (error stack shows TypeScript line numbers)
NODE_OPTIONS=--enable-source-maps vext start

When deploying a TypeScript project, vext start will require the existence of a valid dist/ build product:

  • A valid build product exists → directly use node to run the compiled code (does not depend on tsx)
  • Does not exist or is incomplete → Fails directly and prompts to execute vext build first

Please use vext dev to start source code during the development period. Production vext start will not fall back to the TypeScript runtime.

Environment variables

VariableDescriptionRecommended value
NODE_ENVRuntime mode; vext start sets it to productionUsually not set manually
VEXT_CONFIGConfig profile; lower priority than --configproduction
PORTListening port3000
HOSTListening address0.0.0.0

Docker deployment

Dockerfile

#──Phase 1: Build───────────────────────────────────────
FROM node:22-alpine AS builder

WORKDIR/app

# Copy dependency files first (using Docker cache layer)
COPY package.json package-lock.json ./

# Install all dependencies (including devDependencies, required for compilation)
RUN npm ci#Copy source code
COPY src/ src/
COPY tsconfig.json ./

# compile
RUN npx vext build

# ── Stage 2: Run ──────────────────────────────────────
FROM node:22-alpine AS runner

WORKDIR/app

# Only install production dependencies
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

#Copy the compiled product
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src ./src

#Run as non-root user
RUN addgroup --system --gid 1001 vext && \
    adduser --system --uid 1001 vext
USER vext

#Environment variables
ENV NODE_OPTIONS=--enable-source-maps
ENVPORT=3000

EXPOSE 3000

#HealthCheck
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# start
CMD ["npm", "start"]

.dockerignore

node_modules
dist
.vext
.git
*.md
test
website
.ai-memory
reports

Docker Compose

# docker-compose.yml
version: "3.8"

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment: -PORT=3000
      - MONGODB_URL=mongodb://mongo:27017/myapp
    depends_on:
      mongo:
        condition: service_healthy
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"

  mongo:
    image: mongo:7
    ports:
      - "27017:27017"
    volumes:
      - mongo-data:/data/db
    healthcheck:
      test: echo 'db.runCommand("ping").ok' | mongosh --quiet
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  mongo-data:

Build and run

# Build image
docker build -t myapp:latest .

# Run container
docker run -d \
  --name myapp \
  -p 3000:3000 \
  -e MONGODB_URL=mongodb://host.docker.internal:27017/myapp \
  myapp:latest

# View log
docker logs -f myapp

Nginx reverse proxy

Basic configuration


nginx
# /etc/nginx/conf.d/myapp.conf

upstream vext_backend {
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 80;
    server_name api.example.com;

    # Redirect to HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    # SSL Certificate
    ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    # SSL security configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # Request body size limit
    client_max_body_size 10M;

    # Proxy to VextJS
    location/{
        proxy_pass http://vext_backend;
        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_set_header X-Request-ID $request_id;

        # Timeout setting
        proxy_connect_timeout 10s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;

        # cache
        proxy_cache_bypass $http_upgrade;
    }

    # Health check endpoint (no logging)
    location = /health {
        proxy_pass http://vext_backend;
        access_log off;
    }

    # Static files (if any)
    location /static/ {
        alias /var/www/myapp/static/;
        expires 30d;
        add_header Cache-Control "public, no-transform";
    }
}

Multi-instance load balancing


nginx
upstream vext_backend {
    least_conn;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
    server 127.0.0.1:3004;
    keepalive 64;
}

PM2 process management

Installation

npm install -g pm2

ecosystem configuration file

// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: "myapp",
      script: "node_modules/vextjs/dist/cli/index.js",
      args: "start",
      node_args: "--enable-source-maps",

      // Multiple instances (or use VextJS built-in Cluster mode)
      instances: 1,

      // environment variables
      env: {
        PORT: 3000,
      },

      // log
      error_file: "/var/log/myapp/error.log",
      out_file: "/var/log/myapp/out.log",
      log_date_format: "YYYY-MM-DD HH:mm:ss.SSS",
      merge_logs: true,

      // Automatically restart
      max_restarts: 10,
      min_uptime: "10s",
      restart_delay: 5000,

      //Memory limit (restart if exceeded)
      max_memory_restart: "500M",

      //Close gracefully
      kill_timeout: 10000,
      listen_timeout: 10000,
      shutdown_with_message: true,

      // Monitor
      exp_backoff_restart_delay: 100,
    },
  ],
};

PM2 common commands

# start
pm2 start ecosystem.config.cjs

# Check status
pm2 status

# View log
pm2 logs myapp

# Restart
pm2 restart myapp

# Graceful reloading
pm2 reload myapp

# stop
pm2 stop myapp

# Monitoring panel
pm2monit

#Set auto-start at boot
pm2 startup
pm2 save
VextJS Cluster vs PM2 Cluster

VextJS has built-in Cluster multi-process mode (vext start --cluster), providing advanced functions such as Rolling Restart and heartbeat monitoring. If you use the built-in Cluster, just set instances of PM2 to 1 and let VextJS manage the worker process by itself.

See Cluster multi-process for details.

Log collection

JSON log format

VextJS outputs JSON logs by default in the production runtime mode used by vext start, which is suitable for parsing by the log collection system. Pretty level colors only work in pretty text mode, and the produced JSON output will not contain ANSI:

{
  "level": 30,
  "time": "2026-03-05T14:23:05.123Z",
  "requestId": "abc-123",
  "msg": "→ GET /api/users 200 45ms"
}

Configure log level

// src/config/production.ts
export default {
  logger: {
    level: "info", // Recommended info for production environment (does not output debug)
    pretty: false, // disable pretty in production environment (default behavior)
    prettyColor: "never", // Optional: Explicitly disable pretty ANSI
  },
};

Log collection plan

Solution 1: File + Filebeat → ELK

# PM2 output log to file
pm2 start ecosystem.config.cjs

# Filebeat collects log files → Elasticsearch → Kibana
# filebeat.yml
filebeat.inputs:
  - type: log
    paths:
      - /var/log/myapp/*.log
    json.keys_under_root: true
    json.overwrite_keys: true

output.elasticsearch:
  hosts: ["http://elasticsearch:9200"]
  index: "myapp-%{+yyyy.MM.dd}"

Option 2: Docker log → Loki

# docker-compose.yml
services:
  app:
    build: .
    logging:
      driver: loki
      options:
        loki-url: "http://loki:3100/loki/api/v1/push"
        loki-batch-size: "400"

Option 3: stdout → Cloud native

In platforms such as Kubernetes / AWS ECS / Cloud Run, output directly to stdout, which is automatically collected by the platform:

# No additional configuration is required, JSON logs are output directly to stdout
vext start

Health Check

Implement health check endpoint

// src/routes/health.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  app.get(
    "/health",
    {
      override: { rateLimit: false },
      docs: { summary: "Health Check", tags: ["System"] },
    },
    async (req, res) => {
      const checks: Record<string, unknown> = {
        status: "ok",
        uptime: process.uptime(),
        timestamp: new Date().toISOString(),
        memory: {
          rss: Math.round(process.memoryUsage().rss / 1024 / 1024) + "MB",
          heap: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB",
        },
      };

      // Database connection check
      if (app.db) {
        try {
          await app.db.client.db().admin().ping();
          checks.database = "connected";
        } catch {
          checks.database = "disconnected";
          checks.status = "degraded";
        }
      }
      const statusCode = checks.status === "ok" ? 200 : 503;
      res.json(checks, statusCode);
    },
  );

  // Readiness check (Kubernetes readinessProbe)
  app.get(
    "/ready",
    {
      override: { rateLimit: false },
    },
    async (req, res) => {
      // Check if all key dependencies are ready
      const ready = app.db !== undefined;

      if (ready) {
        res.json({ status: "ready" });
      } else {
        res.json({ status: "not_ready" }, 503);
      }
    },
  );
});

Kubernetes probe configuration

# k8s deployment.yaml
spec:
  containers:
    - name: myapp
      image: myapp:latest
      ports:
        - containerPort: 3000
      livenessProbe:
        httpGet:
          path: /health
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 30
        timeoutSeconds: 5
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 3
      resources:
        requests:
          memory: "128Mi"
          cpu: "250m"
        limits:
          memory: "512Mi"
          cpu: "1000m"

Abnormal crash notification (onFatalError)

VextJS has a built-in process-level exception catching mechanism. When an uncaughtException or unhandledRejection occurs, the framework will:

  1. Record fatal level logs
  2. Call the user-configured onFatalError callback (if any)
  3. Perform graceful shutdown (onClose hooks clean up resources)
  4. process.exit(1) Exit the process

Configure onFatalError

Add the onFatalError callback in the shutdown configuration to access alarm notifications:

// src/config/production.ts
export default {
  shutdown: {
    timeout: 10,
    onFatalError: async (error, origin) => {
      // origin: 'uncaughtException' | 'unhandledRejection'

      // Example: Send DingTalk Webhook
      await fetch("https://oapi.dingtalk.com/robot/send?access_token=xxx", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          msgtype: "markdown",
          markdown: {
            title: "⚠️ Service exception",
            text: [
              "## ⚠️ The service crashed abnormally",
              `- **service**: my-service`,
              `- **Source**: ${origin}`,
              `- **Error**: ${error.message}`,
              `- **Time**: ${new Date().toISOString()}`,
              `- **Stack**:\n\`\`\`\n${error.stack}\n\`\`\``,
            ].join("\n"),
          },
        }),
      });
    },
  },
};

Enterprise WeChat Webhook Example

onFatalError: async (error, origin) => {
  await fetch('https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      msgtype: 'markdown',
      markdown: {
        content: [
          `## <font color="warning">Service crashed abnormally</font>`,
          `> Service: my-service`,
          `> Source: ${origin}`,
          `> Error: ${error.message}`,
          `> Time: ${new Date().toISOString()}`,
        ].join('\n'),
      },
    }),
  });
},

Slack Webhook Example

onFatalError: async (error, origin) => {
  await fetch('https://hooks.slack.com/services/T00/B00/xxx', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `🚨 *Service Crash* (${origin})\nError: ${error.message}\nTime: ${new Date().toISOString()}`,
    }),
  });
},

Generic HTTP Webhook Example

onFatalError: async (error, origin) => {
  await fetch(process.env.ALERT_WEBHOOK_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      app: 'my-service',
      runtimeMode: process.env.NODE_ENV,
      configProfile: process.env.VEXT_CONFIG,
      origin,
      error: error.message,
      stack: error.stack,
      hostname: require('os').hostname(),
      pid: process.pid,
      time: new Date().toISOString(),
    }),
  });
},

Notes

ProjectDescription
Timeout ProtectionThe onFatalError callback has a 10-second timeout, and the process will be forced to exit after the timeout
Error IsolationExceptions thrown inside the callback will be caught and recorded, and will not prevent the process from exiting
UnrecoverableAfter uncaughtException, the process is in an uncertain state, the callback should be as light as possible (just send a notification)
Test ModeDo not register fatal error handlers under _testMode to avoid interfering with testing
Cooperating with PM2PM2 itself also has restart notification capabilities (plug-ins such as pm2-slack), which can be used in conjunction with onFatalError
Why can’t it be implemented using middleware?

uncaughtException and unhandledRejection occur outside the HTTP middleware execution chain (such as exceptions in scheduled tasks and event listeners), and middleware cannot catch such errors. Therefore, process level event listeners must be registered in the framework bootstrap layer.

Security hardening

Production environment list

#Check itemsDescription
1HTTPSTerminates TLS via Nginx/CDN, does not handle SSL at the Node.js layer
2CORSConfigure config.cors to limit allowed source domain names
3Rate LimitConfigure config.rateLimit to set stricter rate limits for sensitive interfaces such as login
4HelmetSet security response headers through middleware (X-Frame-Options, CSP, etc.)
5Environment variablesSensitive information (database password, API Key) is passed in through environment variables and is not written to the configuration file
6config/local.tsMake sure .gitignore contains config/local.*
7LogDo not output sensitive data (password, token, etc.) to the log
8Dependency AuditRegular npm audit to fix known vulnerabilities in a timely manner
9Non-rootRunning as a non-root user in a Docker container
10Graceful shutdownEnsure SIGTERM signal is handled correctly (VextJS built-in support)

Environment variable management

# .env (local development, not submitted to Git)
PORT=3000
MONGODB_URL=mongodb://localhost:27017/myapp
JWT_SECRET=local-dev-secret

# Production environment is injected via CI/CD or key management service
# Docker: docker run -e MONGODB_URL=... myapp
# K8s: Secret + ConfigMap

Performance optimization

Node.js Parameters

# Increase the memory limit (default ~1.5GB)
NODE_OPTIONS=--max-old-space-size=4096 vext start

# Enable Source Map (recommended)
NODE_OPTIONS=--enable-source-maps vext start

# Use in combination
NODE_OPTIONS="--enable-source-maps --max-old-space-size=4096" vext start

Cluster multi-process

VextJS has built-in Cluster mode to make full use of multi-core CPUs:

# Automatically use workers with the number of CPU cores
vext start --cluster

# Specify the number of workers
vext start --cluster --workers 4

See Cluster multi-process for details.

Connection pool optimization

// src/config/production.ts
export default {
  // HTTP fetch connection optimization
  fetch: {
    timeout: 5000, // Shorten timeout for production environment
    retry: 2, // Idempotent method automatically retries
  },

  //Database connection pool
  database: {
    config: {
      url: process.env.MONGODB_URL,
      options: {
        maxPoolSize: 20,
        minPoolSize: 5,
        maxIdleTimeMS: 30000,
      },
    },
  },
};

Deployment process suggestions

CI/CD pipeline

Push to main
  → CI check (lint + typecheck + test)
  → vext build
  → Docker build (build image)
  → Push to Registry
  → Deploy (Rolling Update)
  → Health Check (verification)

Grayscale release

# 1. Build a new version of the image
docker build -t myapp:v1.2.0 .# 2. Deploy to grayscale environment
docker run -d --name myapp-canary -p 3001:3000 myapp:v1.2.0

# 3. Nginx grayscale routing (10% of traffic goes to the new version)
upstream vext_backend {
    server 127.0.0.1:3000 weight=9; # Old version
    server 127.0.0.1:3001 weight=1; # New version (grayscale)
}

# 4. Observe monitoring indicators (error rate, delay)
# 5. Confirm that there are no abnormalities and then switch to full volume.

Monitor alarms

Key monitoring indicators

IndicatorsNormal rangeAlarm conditions
Response time P99< 500ms> 1s for 5 minutes
Error rate (5xx)< 0.1%> 1% for 1 minute
Memory usage< 80% limit> 90% for 5 minutes
CPU usage< 70%> 90% for 5 minutes
Number of active connections< 1000> 5000
Database connection poolNo waitingWaiting time > 1s

Prometheus Metrics Endpoint

Combined with OpenTelemetry access example to expose Prometheus indicators:

app.get(
  "/metrics",
  {
    override: { rateLimit: false },
  },
  async (req, res) => {
    // OpenTelemetry Prometheus Exporter will expose metrics at this endpoint
    // See OpenTelemetry access example for details
    res.json({ message: "See /examples/opentelemetry for setup" });
  },
);

Next step