Chapter 11: Application Hosting — WordPress, Flask and Express
11.3 Express application with pm2 + Nginx
Ekdum simple aahe — once you have done Flask, Express follows exactly the same idea: app on localhost, a process manager to keep it alive, Nginx in front.
Why pm2?
If you start Node with node app.js and close the SSH session, the app dies. If it crashes, nobody restarts it. pm2 is a production process manager for Node.js that keeps apps alive, restarts on crash, starts them at boot, manages logs, and can run one process per CPU core (cluster mode) with zero-downtime reloads.
Step 1 — Install Node.js, pm2 and Nginx
# AL2023 / CentOS Stream 9
curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
sudo yum install -y nodejs nginx git
# Ubuntu
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs nginx git
sudo npm install -g pm2
Step 2 — Create the Express app
sudo mkdir -p /opt/expressdemo/public && sudo chown -R $USER:$USER /opt/expressdemo
cd /opt/expressdemo
npm init -y
npm install express
cat > server.js <<'EOF'
const express = require("express");
const os = require("os");
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.use(express.static("public"));
const todos = [{ id: 1, task: "Learn EC2" }, { id: 2, task: "Deploy with pm2" }];
app.get("/api/todos", (req, res) => res.json(todos));
app.post("/api/todos", (req, res) => {
const todo = { id: todos.length + 1, task: req.body.task };
todos.push(todo);
res.status(201).json(todo);
});
app.get("/api/health", (req, res) => res.json({ status: "ok", host: os.hostname(), pid: process.pid }));
app.listen(PORT, "127.0.0.1", () => console.log(`Express running on 127.0.0.1:${PORT}`));
EOF
cat > public/index.html <<'EOF'
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Express on EC2</title></head>
<body style="font-family:Arial;max-width:600px;margin:40px auto">
<h1>Express + pm2 + Nginx</h1><ul id="list"></ul>
<script>
fetch("/api/todos").then(r => r.json()).then(items => {
document.getElementById("list").innerHTML = items.map(t => "<li>" + t.task + "</li>").join("");
});
</script></body></html>
EOF
Step 3 — pm2 ecosystem file (cluster mode)
cat > ecosystem.config.js <<'EOF'
module.exports = {
apps: [{
name: "expressdemo",
script: "server.js",
cwd: "/opt/expressdemo",
instances: "max", // one process per vCPU
exec_mode: "cluster",
env: { NODE_ENV: "production", PORT: 3000 },
max_memory_restart: "300M"
}]
};
EOF
pm2 start ecosystem.config.js
pm2 status
curl -s http://127.0.0.1:3000/api/health
pm2 startup systemd # copy and run the 'sudo env PATH=...' line it prints
pm2 save
Useful pm2 commands
| Command | Purpose |
|---|---|
pm2 list / pm2 status |
Show processes |
pm2 logs expressdemo |
Stream logs (--lines 100) |
pm2 monit |
Live CPU/memory dashboard |
pm2 restart expressdemo |
Restart (brief downtime) |
pm2 reload expressdemo |
Zero-downtime reload (cluster mode) |
pm2 stop / pm2 delete expressdemo |
Stop / remove from list |
pm2 save |
Save process list for resurrection at boot |
pm2 install pm2-logrotate |
Rotate logs so the disk doesn't fill up |
Step 4 — Nginx reverse proxy
# AL2023 / CentOS path; Ubuntu: /etc/nginx/sites-available/expressdemo + symlink + remove default
sudo tee /etc/nginx/conf.d/expressdemo.conf > /dev/null <<'EOF'
upstream express_backend {
server 127.0.0.1:3000;
keepalive 16;
}
server {
listen 80;
server_name YOUR_SERVER_NAME;
location / {
proxy_pass http://express_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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;
}
}
EOF
sudo sed -i "s/YOUR_SERVER_NAME/$(curl -s https://checkip.amazonaws.com)/" /etc/nginx/conf.d/expressdemo.conf
sudo setsebool -P httpd_can_network_connect 1 2>/dev/null # CentOS only
sudo service nginx start
sudo systemctl enable nginx
sudo nginx -t && sudo service nginx reload
Browse to http://<PUBLIC_IP>/ and http://<PUBLIC_IP>/api/health (refresh a few times — the pid changes because requests are shared across cluster workers).
Step 5 — Deploying updates
cd /opt/expressdemo
git pull
npm ci --omit=dev # clean install exactly from package-lock.json
pm2 reload expressdemo # zero downtime