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.
Mục lục bài viết
VPS & Hosting cho dự án của bạn
Khám phá các gói máy chủ và lưu trữ phù hợp từ website cá nhân đến ứng dụng production.
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.
Cookie production
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
- Thêm cột/table mới.
- Deploy code đọc được cũ và mới.
- Chuyển dữ liệu.
- Chuyển traffic/logic.
- 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
Bài viết liên quan
Hướng dẫn deploy REMAP bằng Docker lên VPS
Triển khai monorepo REMAP gồm NestJS, Next.js, React và PostgreSQL lên VPS Ubuntu bằng Docker Compose, Nginx, domain và SSL.
Đọc bài →Bảo mật PostgreSQL trên VPS: pg_hba.conf, user và firewall
Hướng dẫn bảo mật PostgreSQL production bằng listen_addresses, pg_hba.conf, role tối thiểu, password, firewall, SSL và SSH tunnel.
Đọc bài →Docker Compose production: App, PostgreSQL, healthcheck và backup
Hướng dẫn thiết kế Docker Compose production với app, PostgreSQL, network, volume, healthcheck, secret, restart và deploy an toàn.
Đọc bài →