DeployEasy
Menu
NestJSTrung bình

Deploy NestJS bằng Docker Compose và PostgreSQL trên VPS

Hướng dẫn deploy NestJS production với Dockerfile multi-stage, PostgreSQL, migration, healthcheck, Nginx, secret, backup và rollback.

· 8 phút đọc · 1.749 từ
Mục lục bài viết

NestJS là framework Node.js phía server. Để đưa một API NestJS lên production, bạn không chỉ cần chạy npm run start:prod. Hệ thống còn cần database, migration, secret, healthcheck, reverse proxy, backup và rollback.

Mô hình trong bài:

Internet

Nginx :443

NestJS container :3000
   ↓ private Docker network
PostgreSQL container :5432

Chuẩn bị ứng dụng NestJS

package.json nên có:

{
  "scripts": {
    "build": "nest build",
    "start:prod": "node dist/main.js",
    "migration:run": "typeorm-ts-node-commonjs migration:run -d src/data-source.ts"
  }
}

Script migration thực tế phụ thuộc TypeORM, Prisma hoặc ORM bạn sử dụng.

Production không dùng:

synchronize: true

vì đồng bộ schema tự động có thể gây thay đổi ngoài kiểm soát.

Bootstrap production

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableShutdownHooks();

  app.setGlobalPrefix("api");

  await app.listen(
    Number(process.env.PORT ?? 3000),
    "0.0.0.0",
  );
}

bootstrap();

Trong container, app cần bind 0.0.0.0.

Nếu chạy trực tiếp cùng host với Nginx, có thể bind 127.0.0.1.

Validation environment

Không để app start với env thiếu.

Ví dụ dùng Joi:

ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: Joi.object({
    NODE_ENV: Joi.string()
      .valid("production", "development", "test")
      .required(),

    PORT: Joi.number()
      .port()
      .default(3000),

    DATABASE_HOST: Joi.string()
      .required(),

    DATABASE_PORT: Joi.number()
      .port()
      .default(5432),

    DATABASE_NAME: Joi.string()
      .required(),

    DATABASE_USER: Joi.string()
      .required(),

    DATABASE_PASSWORD: Joi.string()
      .min(16)
      .required(),
  }),
});

Fail fast tốt hơn start app với config sai.

Dockerfile multi-stage

FROM node:22-bookworm-slim AS deps

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-bookworm-slim AS builder

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

FROM node:22-bookworm-slim AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

COPY package.json package-lock.json ./

RUN npm ci --omit=dev \
  && npm cache clean --force

COPY --from=builder \
  --chown=node:node \
  /app/dist \
  ./dist

USER node

EXPOSE 3000

CMD ["node", "dist/main.js"]

Nếu migration runtime cần source TypeScript hoặc CLI trong devDependencies, image runner trên có thể không chạy migration. Tốt hơn là có migration stage/image hoặc build migration thành JavaScript.

.dockerignore

node_modules
dist
coverage
.git
.github
.env
.env.*
*.log
README.md
compose*.yaml

Health module

Cài Terminus nếu phù hợp:

npm install @nestjs/terminus

Ví dụ controller:

@Controller("health")
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck("database"),
    ]);
  }
}

Public health response không nên lộ connection string hoặc secret.

Compose file

name: deployeasy-api

services:
  database:
    image: postgres:18
    restart: unless-stopped

    environment:
      POSTGRES_DB: ${DATABASE_NAME}
      POSTGRES_USER: ${DATABASE_USER}
      POSTGRES_PASSWORD: ${DATABASE_PASSWORD}

    volumes:
      - postgres-data:/var/lib/postgresql/data

    networks:
      - backend

    healthcheck:
      test:
        [
          "CMD-SHELL",
          "pg_isready -U ${DATABASE_USER} -d ${DATABASE_NAME}",
        ]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  api:
    build:
      context: .
      dockerfile: Dockerfile

    restart: unless-stopped

    environment:
      NODE_ENV: production
      PORT: 3000
      DATABASE_HOST: database
      DATABASE_PORT: 5432
      DATABASE_NAME: ${DATABASE_NAME}
      DATABASE_USER: ${DATABASE_USER}
      DATABASE_PASSWORD: ${DATABASE_PASSWORD}

    depends_on:
      database:
        condition: service_healthy

    ports:
      - "127.0.0.1:3000:3000"

    networks:
      - backend

    healthcheck:
      test:
        [
          "CMD",
          "node",
          "-e",
          "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  postgres-data:

networks:
  backend:
    driver: bridge

Database không có ports, vì API truy cập bằng:

database:5432

File .env

DATABASE_NAME=deployeasy
DATABASE_USER=deployeasy_app
DATABASE_PASSWORD=CHANGE_TO_A_LONG_RANDOM_PASSWORD

Permission:

chmod 600 .env

Không commit.

Kiểm tra Compose config

docker compose config

Lệnh có thể in secret đã thay thế. Không chia sẻ output công khai.

Build

docker compose build api

Chạy database:

docker compose up -d database

Kiểm tra:

docker compose ps
docker compose logs -f database

Migration service

Không để nhiều API instance tự migration cùng lúc.

Có thể thêm stage:

FROM builder AS migration

CMD ["npm", "run", "migration:run"]

Hoặc service dùng builder image.

Ví dụ khái niệm:

  migrate:
    build:
      context: .
      target: builder

    command: npm run migration:run

    environment:
      DATABASE_HOST: database
      DATABASE_PORT: 5432
      DATABASE_NAME: ${DATABASE_NAME}
      DATABASE_USER: ${DATABASE_USER}
      DATABASE_PASSWORD: ${DATABASE_PASSWORD}

    depends_on:
      database:
        condition: service_healthy

    networks:
      - backend

    restart: "no"

Chạy:

docker compose run --rm migrate

Chỉ start API sau migration thành công:

docker compose up -d api

TypeORM production

Cấu hình:

{
  type: "postgres",
  host: process.env.DATABASE_HOST,
  port: Number(process.env.DATABASE_PORT),
  username: process.env.DATABASE_USER,
  password: process.env.DATABASE_PASSWORD,
  database: process.env.DATABASE_NAME,

  synchronize: false,
  migrationsRun: false,

  entities: [join(__dirname, "**", "*.entity.{js,ts}")],
  migrations: [join(__dirname, "migrations", "*.{js,ts}")],
}

Trong compiled runtime, glob phải khớp file .js trong dist.

Lỗi thường gặp là migration nằm ở src nhưng image runtime chỉ copy dist.

Prisma production

Quy trình thường dùng:

npx prisma migrate deploy

Prisma Client phải được generate trong build phù hợp.

Không dùng:

prisma migrate dev

trên production.

Kiểm tra tài liệu Prisma version hiện tại vì output/generated client có thể thay đổi.

Chạy toàn hệ thống

docker compose up -d

Kiểm tra:

docker compose ps
docker compose logs -f api

Test:

curl -f http://127.0.0.1:3000/api/health

Nginx

server {
    listen 80;
    listen [::]:80;

    server_name api.deployeasy.vn;

    client_max_body_size 10M;

    location / {
        proxy_pass http://127.0.0.1:3000;

        proxy_http_version 1.1;
        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_connect_timeout 10s;
        proxy_read_timeout 60s;
    }
}

Kiểm tra:

sudo nginx -t
sudo systemctl reload nginx

Sau đó cài HTTPS.

Trust proxy

Khi app đứng sau Nginx, có thể cần trust proxy cho:

  • Secure cookie.
  • IP.
  • Protocol.
  • Rate limit.

Cấu hình phải giới hạn proxy đáng tin cậy. Không tin mọi X-Forwarded-For từ Internet.

Tùy adapter Express/Fastify, cách cấu hình khác nhau.

CORS

Ví dụ:

app.enableCors({
  origin: [
    "https://deployeasy.vn",
    "https://www.deployeasy.vn",
  ],
  credentials: true,
});

Không dùng:

origin: "*",
credentials: true,

Với cookie/credential.

CORS không phải cơ chế xác thực.

Nếu dùng refresh token cookie:

{
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  path: "/",
}

secure: true yêu cầu HTTPS.

Nếu frontend và API khác site, SameSite/CORS/CSRF cần thiết kế cẩn thận.

Secret

Không đặt JWT secret yếu:

secret123

Dùng secret ngẫu nhiên dài, tách từng môi trường và có kế hoạch rotate.

Không log:

  • Access token.
  • Refresh token.
  • Password.
  • Database URL.
  • Cookie.

Database connection pool

Mỗi app instance mở một pool. Nếu scale nhiều container, tổng connection có thể vượt PostgreSQL limit.

Ví dụ:

5 containers × pool 20 = 100 connections

Cần tính:

  • App replicas.
  • Migration.
  • Admin tools.
  • Background worker.
  • Monitoring.
  • Reserved connections.

Không tăng max_connections chỉ để che pool cấu hình quá lớn.

Backup PostgreSQL

mkdir -p backups

docker compose exec -T database \
  pg_dump \
  -U "$DATABASE_USER" \
  -d "$DATABASE_NAME" \
  -Fc \
  > "backups/deployeasy-$(date +%Y%m%d-%H%M%S).dump"

Backup ngoài VPS và test restore.

Upload file

Không lưu file quan trọng trong writable layer của API container.

Dùng:

  • Named volume.
  • Bind mount được quản lý.
  • Object storage.

Ví dụ:

volumes:
  - uploads:/app/uploads

Backup volume/file upload riêng.

Queue và cron

Nếu app có worker:

services:
  api:
  worker:

Không chạy cùng cron job ở mọi API replica nếu tác vụ chỉ nên chạy một lần.

Tách scheduler hoặc dùng distributed lock phù hợp.

Logging

NestJS log ra stdout/stderr để Docker thu thập.

Structured log:

{
  "level": "error",
  "message": "Database query failed",
  "requestId": "abc123"
}

Không log dữ liệu nhạy cảm.

Deploy bằng image registry

CI:

Build image
Test
Push ghcr.io/...:git-sha

VPS:

docker compose pull api
docker compose run --rm migrate
docker compose up -d api
curl -f http://127.0.0.1:3000/api/health

Rollback

Đổi image tag về release cũ:

image: ghcr.io/username/deployeasy-api:git-old
docker compose pull api
docker compose up -d api

Migration phải tương thích với code cũ. Nếu migration đã xóa cột, rollback code có thể không hoạt động.

Expand/contract migration

  1. Thêm cột/table mới.
  2. Deploy code đọc được cũ và mới.
  3. Chuyển dữ liệu.
  4. Chuyển traffic/logic.
  5. Xóa cấu trúc cũ ở release sau.

Giúp rolling deploy và rollback an toàn hơn.

Lỗi authentication PostgreSQL

password authentication failed

Kiểm tra:

  • User.
  • Password.
  • Database.
  • Secret bị space/newline.
  • Container dùng env cũ.
  • Volume PostgreSQL đã khởi tạo bằng password trước đó.

Biến POSTGRES_PASSWORD chỉ dùng lúc khởi tạo data directory mới; đổi env không tự đổi password trong database đã tồn tại.

Đổi password bằng SQL phù hợp:

ALTER ROLE deployeasy_app
WITH PASSWORD 'new-password';

Lỗi ECONNREFUSED database:5432

Kiểm tra:

docker compose ps
docker compose logs database
docker compose exec api getent hosts database

Nguyên nhân:

  • Database chưa ready.
  • Sai network.
  • Service name sai.
  • Database crash.
  • Port sai.
  • API retry không đủ.

Lỗi migration không tìm thấy file

Kiểm tra trong image:

docker compose run --rm api \
  find /app/dist -maxdepth 4 -type f | sort

Migration path phải trỏ tới compiled .js.

Lỗi app chỉ chạy localhost trong container

Nếu app bind:

127.0.0.1

Docker port mapping không vào được.

Dùng:

0.0.0.0

trong container.

Checklist

[ ] Env validation
[ ] synchronize false
[ ] Migration service riêng
[ ] Dockerfile multi-stage
[ ] App non-root
[ ] DB named volume
[ ] DB không public
[ ] Healthcheck DB và API
[ ] App bind 0.0.0.0 trong container
[ ] Host publish 127.0.0.1
[ ] Nginx + HTTPS
[ ] CORS rõ ràng
[ ] Secret không trong image
[ ] Pool connection được tính
[ ] Backup và restore test
[ ] Image version/SHA
[ ] Rollback và migration tương thích

Câu hỏi thường gặp

Có cần PM2 trong container NestJS?

Thường không. Node process là process chính và Docker quản lý container.

Có nên để NestJS tự chạy migration khi start?

Không nên khi có nhiều replica hoặc migration cần kiểm soát. Dùng job/service riêng.

PostgreSQL có cần public port?

Không nếu chỉ API trong cùng Docker network truy cập.

Có thể đặt API và database cùng VPS không?

Có với dự án nhỏ, nhưng đây là một điểm lỗi chung. Cần backup ngoài VPS và kế hoạch nâng cấp.

Kết luận

Deploy NestJS production cần quản lý schema, secret, healthcheck, database state và rollback. Docker chỉ đóng gói app; nó không tự giải quyết migration, backup hoặc bảo mật.

Nguồn tham khảo

Đọc tiếp