Node.js Express — deployment na VPS w produkcji
Opublikowano: 9 kwietnia 2026 · Kategoria: Node.js / VPS
Express.js to minimalny, szybki framework Node.js — ale samo napisanie aplikacji to połowa drogi. Produkcyjny deployment wymaga: menedżera procesów (PM2), reverse proxy (Nginx), HTTPS (certbot), logowania i graceful shutdown. Oto kompletny przewodnik wdrożenia Express na własnym VPS.
Struktura projektu produkcyjnego
my-app/ ├── src/ │ ├── app.js # Express app (bez listen — eksportuje app) │ ├── server.js # Entry point (listen + graceful shutdown) │ ├── routes/ # Express Routers │ ├── middleware/ # Auth, rate-limit, error handler │ └── utils/ # Logger, DB connection ├── .env # Zmienne lokalne (NIE w git!) ├── .env.production # Template (bez sekretów, w git) ├── ecosystem.config.js # PM2 config ├── package.json └── .gitignore # Zawiera: .env, node_modules/, logs/
// src/app.js — Express app bez listen()
import express from 'express';
import morgan from 'morgan';
import helmet from 'helmet';
import cors from 'cors';
import router from './routes/index.js';
import errorHandler from './middleware/errorHandler.js';
const app = express();
app.use(helmet()); // Nagłówki bezpieczeństwa
app.use(cors({ origin: process.env.CORS_ORIGIN }));
app.use(express.json({ limit: '10mb' }));
app.use(morgan('combined')); // Logi HTTP do stdout
app.use('/api', router);
// Health check — monitoring i PM2 health probe
app.get('/health', (req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
app.use(errorHandler);
export default app; PM2 ecosystem.config.js
PM2 to menedżer procesów Node.js — zapewnia auto-restart, klastrowanie i logi. Plik
konfiguracyjny ecosystem.config.js:
// ecosystem.config.js
export default {
apps: [
{
name: 'my-express-app',
script: './src/server.js',
// Cluster mode — po jednej instancji na rdzeń CPU
instances: 'max', // lub konkretna liczba: 2, 4
exec_mode: 'cluster',
// Zmienne środowiskowe per environment
env: {
NODE_ENV: 'development',
PORT: 3000,
},
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
// Logi
out_file: '/var/log/my-app/out.log',
error_file: '/var/log/my-app/error.log',
merge_logs: true,
log_date_format: 'YYYY-MM-DD HH:mm:ss',
// Auto-restart przy przekroczeniu pamięci
max_memory_restart: '1G',
// Graceful shutdown
kill_timeout: 5000, // Czas na obsługę otwartych połączeń (ms)
wait_ready: true, // Czekaj na process.send('ready') przed switchem
listen_timeout: 10000, // Maks czas na gotowość
// Ignoruj zmiany w katalogu node_modules
watch: false,
ignore_watch: ['node_modules', 'logs'],
},
],
}; # Uruchomienie i zarządzanie przez PM2 npm install -g pm2 # Start z konfiguracją produkcyjną pm2 start ecosystem.config.js --env production # Zero-downtime reload (graceful restart klastra) pm2 reload my-express-app # Monitorowanie w czasie rzeczywistym pm2 monit # Logi pm2 logs my-express-app --lines 100 # Automatyczny start po reboot systemu pm2 startup # Generuje polecenie systemd pm2 save # Zapisuje aktualną listę procesów
Nginx reverse proxy
Nginx nasłuchuje na portach 80/443 i przekazuje żądania do Node.js na porcie 3000. Dlaczego nie wystawiać Node.js bezpośrednio na port 80? Nginx obsługuje SSL termination, gzip, statyczne pliki, rate limiting i ukrywa serwer aplikacji:
# /etc/nginx/sites-available/my-express-app.conf
upstream express_app {
# PM2 cluster — Nginx round-robins między instancjami
server 127.0.0.1:3000;
keepalive 64;
}
server {
listen 80;
server_name api.twoja-domena.pl;
# Przekieruj HTTP → HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.twoja-domena.pl;
# SSL (certbot uzupełni te ścieżki)
ssl_certificate /etc/letsencrypt/live/api.twoja-domena.pl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.twoja-domena.pl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Kompresja
gzip on;
gzip_types application/json text/plain application/javascript;
# Nagłówki bezpieczeństwa
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req zone=api burst=10 nodelay;
location / {
proxy_pass http://express_app;
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;
proxy_read_timeout 60s;
}
} # Włącz konfigurację i sprawdź poprawność sudo ln -s /etc/nginx/sites-available/my-express-app.conf /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx
SSL przez certbot (Let's Encrypt)
# Instalacja certbota sudo apt install certbot python3-certbot-nginx # Pobierz certyfikat (certbot automatycznie edytuje nginx.conf) sudo certbot --nginx -d api.twoja-domena.pl # Sprawdź automatyczne odnawianie sudo systemctl status certbot.timer sudo certbot renew --dry-run
Zmienne środowiskowe
// src/server.js — załaduj .env jako pierwsze
import 'dotenv/config'; // npm install dotenv
import app from './app.js';
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT} in ${process.env.NODE_ENV} mode`);
// PM2 wait_ready — powiadom PM2 że proces jest gotowy
if (process.send) process.send('ready');
}); # .env.production (template — bez prawdziwych sekretów, w git) NODE_ENV=production PORT=3000 DATABASE_URL=postgresql://user:PASSWORD@localhost:5432/mydb JWT_SECRET=CHANGE_ME_IN_PRODUCTION CORS_ORIGIN=https://twoja-domena.pl # Na serwerze: skopiuj i uzupełnij prawdziwymi wartościami # cp .env.production .env # Edytuj .env — dodaj prawdziwe hasła # Plik .env MUSI być w .gitignore
Logowanie: Morgan + Winston
// src/utils/logger.js
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
// Produkcja: logi do pliku (PM2 przekieruje do /var/log/my-app/)
new winston.transports.Console(),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
maxsize: 10 * 1024 * 1024, // 10 MB
maxFiles: 5,
}),
],
});
export default logger; Graceful shutdown
// src/server.js — obsługa SIGTERM i SIGINT
const shutdown = (signal) => {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// Zamknij serwer HTTP — nie przyjmuj nowych połączeń
server.close((err) => {
if (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
// Zamknij połączenie z bazą danych
db.end().then(() => {
console.log('Database connection closed.');
process.exit(0);
});
});
// Wymuszony shutdown po 10 sekundach (safety net)
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM')); // PM2 reload/stop
process.on('SIGINT', () => shutdown('SIGINT')); // Ctrl+C lokalne