Tạo systemd service cho Node.js: Tự chạy sau khi VPS reboot
Hướng dẫn tạo systemd service cho Node.js/NestJS, cấu hình User, WorkingDirectory, EnvironmentFile, restart, log và graceful shutdown.
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.
Node.js có thể chạy bằng một terminal:
node dist/main.js
Nhưng khi đóng SSH hoặc VPS reboot, process có thể dừng. Production cần một service manager để quản lý vòng đời ứng dụng.
Trên Ubuntu hiện đại, systemd là hệ thống init và service manager mặc định.
systemd là gì?
systemd quản lý:
- Service.
- Startup order.
- Restart policy.
- Environment.
- User chạy process.
- Log qua journal.
- Dependency.
- Resource limit.
- Shutdown.
Một service được định nghĩa bằng unit file có đuôi:
.service
Khi nào dùng systemd?
Phù hợp khi:
- Chạy Node.js trực tiếp trên host.
- Không dùng Docker.
- Muốn giảm dependency vào PM2.
- Cần tích hợp chặt với Linux.
- Muốn log trong journal.
- Cần quyền và hardening ở service level.
Không cần systemd để quản lý app bên trong container nếu Docker/Compose đã quản lý container. Docker daemon bản thân vẫn do systemd quản lý.
Chuẩn bị ứng dụng
Giả sử:
App directory: /var/www/deployeasy-api/current
Entry file: dist/main.js
User: deploy
Port: 3001
Env file: /var/www/deployeasy-api/shared/.env.production
Kiểm tra chạy thủ công:
cd /var/www/deployeasy-api/current
/usr/bin/node dist/main.js
Terminal khác:
curl -f http://127.0.0.1:3001/health
Chỉ tạo service sau khi app chạy trực tiếp thành công.
Xác định đường dẫn Node.js
command -v node
Ví dụ:
/usr/bin/node
Không viết node mơ hồ trong ExecStart nếu PATH của service không giống shell.
Nếu dùng NVM, đường dẫn có thể nằm trong home user và thay đổi theo version. Production ổn định hơn khi dùng runtime có đường dẫn rõ ràng hoặc symlink được quản lý.
Tạo service file
sudo nano /etc/systemd/system/deployeasy-api.service
Nội dung:
[Unit]
Description=DeployEasy API
After=network.target
Wants=network.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/deployeasy-api/current
Environment=NODE_ENV=production
EnvironmentFile=/var/www/deployeasy-api/shared/.env.production
ExecStart=/usr/bin/node dist/main.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Giải thích [Unit]
Description
Description=DeployEasy API
Mô tả service.
After
After=network.target
Yêu cầu startup order sau target mạng cơ bản. Nó không đảm bảo database bên ngoài đã sẵn sàng.
Wants
Wants=network.target
Tạo dependency yếu với network target.
Giải thích [Service]
Type=simple
Process trong ExecStart được xem là process chính. Phù hợp Node.js chạy foreground.
Không daemonize app bên trong systemd.
User và Group
User=deploy
Group=deploy
App chạy bằng user thường, không phải root.
User cần quyền:
- Đọc source.
- Đọc env.
- Ghi thư mục upload/cache nếu cần.
- Bind port trên 1024.
WorkingDirectory
WorkingDirectory=/var/www/deployeasy-api/current
Đặt thư mục chạy ứng dụng.
Nếu đường dẫn không tồn tại, service fail.
Environment
Environment=NODE_ENV=production
Đặt biến trực tiếp.
Không nên đặt secret dài trong unit file vì file thường có thể được đọc bởi user có quyền phù hợp.
EnvironmentFile
EnvironmentFile=/var/www/deployeasy-api/shared/.env.production
File:
PORT=3001
DATABASE_URL=postgresql://...
JWT_SECRET=...
Permission:
sudo chown deploy:deploy \
/var/www/deployeasy-api/shared/.env.production
sudo chmod 600 \
/var/www/deployeasy-api/shared/.env.production
Lưu ý: cú pháp EnvironmentFile không hoàn toàn giống parser .env của mọi framework. Tránh syntax phức tạp và kiểm tra thực tế.
ExecStart
ExecStart=/usr/bin/node dist/main.js
Phải dùng đường dẫn executable rõ ràng.
Không viết shell pipeline trực tiếp:
ExecStart=cd /app && npm start
ExecStart không chạy qua shell mặc định.
Nếu cần shell:
ExecStart=/bin/bash -lc 'exec /usr/bin/node dist/main.js'
Nhưng nên tránh shell khi không cần.
Restart policy
Restart=on-failure
RestartSec=5
on-failure restart khi process thoát lỗi hoặc bị signal bất thường.
Không restart vô hạn mà thiếu alert. Service có thể crash-loop.
Shutdown
TimeoutStopSec=30
KillSignal=SIGTERM
systemd gửi SIGTERM và chờ app tắt.
Ứng dụng phải xử lý graceful shutdown.
[Install]
WantedBy=multi-user.target
Cho phép enable service trong boot target thông thường.
Nạp unit mới
sudo systemctl daemon-reload
Start:
sudo systemctl start deployeasy-api
Kiểm tra:
sudo systemctl status deployeasy-api
Enable boot:
sudo systemctl enable deployeasy-api
Hoặc start và enable cùng lúc:
sudo systemctl enable --now deployeasy-api
Xem log
sudo journalctl -u deployeasy-api
Realtime:
sudo journalctl -u deployeasy-api -f
100 dòng cuối:
sudo journalctl -u deployeasy-api -n 100
Từ 30 phút trước:
sudo journalctl \
-u deployeasy-api \
--since "30 minutes ago"
Restart và reload
Restart:
sudo systemctl restart deployeasy-api
Stop:
sudo systemctl stop deployeasy-api
Start:
sudo systemctl start deployeasy-api
reload chỉ hoạt động nếu service có ExecReload và ứng dụng hỗ trợ.
Kiểm tra unit file
sudo systemd-analyze verify \
/etc/systemd/system/deployeasy-api.service
Xem effective unit:
systemctl cat deployeasy-api
Xem property:
systemctl show deployeasy-api
Graceful shutdown với NestJS
Trong bootstrap:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(
Number(process.env.PORT ?? 3001),
"127.0.0.1",
);
}
Provider có thể triển khai lifecycle hook để đóng resource.
Node.js thuần:
const server = app.listen(3001, "127.0.0.1");
process.on("SIGTERM", () => {
server.close(() => {
process.exit(0);
});
});
Cần đóng thêm:
- Database pool.
- Redis connection.
- Queue worker.
- Cron.
- File handle.
Bind localhost
Nếu Nginx cùng VPS:
127.0.0.1:3001
Không cần app public 0.0.0.0 trừ khi kiến trúc yêu cầu.
Kiểm tra:
sudo ss -ltnp | grep :3001
Nginx reverse proxy
server {
listen 80;
server_name api.deployeasy.vn;
location / {
proxy_pass http://127.0.0.1:3001;
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;
}
}
Start bằng npm có được không?
Có thể:
ExecStart=/usr/bin/npm run start:prod
Nhưng đường dẫn, working directory và signal forwarding phải được kiểm tra.
Chạy trực tiếp Node entry thường đơn giản hơn:
ExecStart=/usr/bin/node dist/main.js
Dùng symlink current
/var/www/deployeasy-api/
├── releases/
│ ├── 20260710-100000/
│ └── 20260710-120000/
└── current -> releases/20260710-120000/
Unit dùng:
WorkingDirectory=/var/www/deployeasy-api/current
Khi deploy:
- Build release mới.
- Test.
- Đổi symlink.
- Restart service.
- Healthcheck.
- Rollback symlink nếu lỗi.
Environment thay đổi
Sau khi sửa env:
sudo systemctl restart deployeasy-api
daemon-reload chỉ cần khi unit file/drop-in thay đổi, không phải mọi lần sửa EnvironmentFile.
Override unit an toàn
Thay vì sửa file package cung cấp:
sudo systemctl edit service-name
Tạo drop-in:
[Service]
Environment=EXAMPLE=value
Với service tự tạo trong /etc/systemd/system, bạn có thể quản lý file trực tiếp trong hệ thống cấu hình.
Resource limit
Ví dụ:
[Service]
MemoryMax=600M
CPUQuota=100%
TasksMax=200
Limit cần load test. Đặt quá thấp có thể khiến app bị kill.
Kiểm tra:
systemctl show deployeasy-api \
-p MemoryCurrent \
-p MemoryMax \
-p CPUUsageNSec
Security hardening
Có thể thêm:
[Service]
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/www/deployeasy-api/shared/uploads
Các tùy chọn hardening có thể làm app fail nếu nó cần ghi/đọc vị trí bị chặn.
Đánh giá:
systemd-analyze security deployeasy-api.service
Áp dụng từng bước và test.
PrivateTmp
PrivateTmp=true
Service có /tmp riêng tương đối với service khác.
Nếu app cần chia sẻ file qua /tmp, cấu hình này có thể phá luồng đó.
ProtectSystem
ProtectSystem=strict
Làm phần lớn filesystem read-only với service.
Cho phép ghi cụ thể:
ReadWritePaths=/var/www/deployeasy-api/shared
Đây là cách tốt để bảo đảm app không tự sửa source.
Service phụ thuộc database
Nếu PostgreSQL cùng host và do systemd quản lý:
After=postgresql.service
Wants=postgresql.service
Điều này chỉ ảnh hưởng thứ tự và dependency, không thay thế retry connection.
Nếu database là Docker hoặc remote, app vẫn cần retry.
Lỗi status=203/EXEC
Thường do:
ExecStartsai đường dẫn.- File không executable.
- Shebang sai.
- Permission.
- Binary không tồn tại.
Kiểm tra:
command -v node
ls -l /usr/bin/node
Lỗi status=200/CHDIR
WorkingDirectory không tồn tại hoặc user không có quyền traverse.
namei -l /var/www/deployeasy-api/current
Lỗi environment không được đọc
Kiểm tra:
- Đường dẫn.
- Owner/permission.
- Cú pháp.
- Tên biến.
- User service.
Xem environment mà systemd biết:
sudo systemctl show deployeasy-api \
--property=Environment
Không đăng output nếu chứa secret.
Service restart liên tục
sudo systemctl status deployeasy-api
sudo journalctl -u deployeasy-api -n 200
Xem restart counter:
systemctl show deployeasy-api \
-p NRestarts
Nguyên nhân:
- Port bận.
- Env thiếu.
- Database lỗi.
- Entry file sai.
- Permission.
- OOM.
- App exit ngay.
Reset trạng thái failed
sudo systemctl reset-failed deployeasy-api
Sau khi sửa lỗi:
sudo systemctl restart deployeasy-api
Deploy script
#!/usr/bin/env bash
set -Eeuo pipefail
APP_ROOT="/var/www/deployeasy-api"
RELEASE="$APP_ROOT/releases/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$RELEASE"
# Copy artifact vào release
tar -xzf /tmp/release.tar.gz -C "$RELEASE"
ln -sfn "$APP_ROOT/shared/.env.production" \
"$RELEASE/.env.production"
ln -sfn "$RELEASE" "$APP_ROOT/current"
sudo systemctl restart deployeasy-api
curl -f http://127.0.0.1:3001/health
Cần rollback nếu healthcheck fail.
sudo giới hạn cho CI
Dùng visudo:
sudo visudo -f /etc/sudoers.d/deployeasy-ci
Ví dụ:
ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart deployeasy-api
ci ALL=(root) NOPASSWD: /usr/bin/systemctl status deployeasy-api
Đánh giá kỹ vì restart service có thể thực thi source do CI user kiểm soát.
Checklist
[ ] App chạy thủ công trước
[ ] ExecStart dùng đường dẫn tuyệt đối
[ ] User/Group không phải root
[ ] WorkingDirectory đúng
[ ] Env file permission hạn chế
[ ] Restart policy rõ
[ ] Graceful shutdown
[ ] Enable boot
[ ] Log đọc bằng journalctl
[ ] Bind localhost
[ ] Resource limit đã test
[ ] Hardening áp dụng từng bước
[ ] Healthcheck sau restart
[ ] Có rollback release
Câu hỏi thường gặp
systemd hay PM2 tốt hơn?
Cả hai đều dùng được. systemd tích hợp sâu Linux; PM2 có CLI và tính năng Node.js thuận tiện hơn. Chọn một process manager chính.
Có cần daemon-reload sau khi sửa env file?
Không, nếu chỉ sửa EnvironmentFile. Cần restart service để process nhận env mới.
systemd có tự build app không?
Không. Build nên nằm trong pipeline hoặc script deploy.
Service có tự restart khi VPS reboot không?
Có nếu đã enable và unit không lỗi.
Kết luận
systemd quản lý Node.js bằng cơ chế chuẩn của Linux. Unit production tốt cần user riêng, đường dẫn rõ, env được bảo vệ, restart có kiểm soát và graceful shutdown.
Nguồn tham khảo
Đọc tiếp
Bài viết liên quan
Linux File System khi deploy: /etc, /var, /home và /opt
Giải thích cấu trúc thư mục Linux quan trọng khi deploy production: /etc, /var, /home, /opt, /tmp, /usr, log, config và dữ liệu.
Đọc bài →SSH Key là gì? Đăng nhập VPS an toàn không cần mật khẩu
Hướng dẫn tạo SSH key, thêm public key vào VPS, cấu hình SSH alias, sửa permission và xử lý lỗi publickey trên Windows, Linux.
Đọc bài →Linux Permission, User, Group và sudo cho môi trường production
Hướng dẫn quyền Linux rwx, chmod, chown, user, group, sudo, umask, setgid và cách phân quyền thư mục deploy an toàn.
Đọc bài →