What a production Node.js server needs
A Node.js app in production is a long running process that listens on a local port. Everything around it exists to keep that process alive, reachable and replaceable. Before you write a single command, it helps to see the whole picture.
- A non root user that owns the app files and runs the process
- A firewall that only allows SSH, HTTP and HTTPS
- A pinned Node.js version, ideally per app, so one upgrade does not break another app
- A process manager (systemd or PM2) that starts the app on boot and restarts it on crash
- nginx or Caddy in front, terminating TLS and proxying to the local port
- A release layout where each deploy builds in its own folder and a symlink switch makes it live
- Environment variables stored outside the repository
- Logs you can actually read when something goes wrong
If any of these is missing, the app may still run, but the first crash at night or the first broken deploy will cost you an hour instead of a minute.
Prepare the server and the deploy user
Start with a fresh Ubuntu 24.04 or Debian 12 VPS from any provider. Log in as root once, create a deploy user, copy your SSH key, then disable password login and root login over SSH.
adduser --disabled-password --gecos "" deploy mkdir -p /home/deploy/.ssh cp /root/.ssh/authorized_keys /home/deploy/.ssh/ chown -R deploy:deploy /home/deploy/.ssh sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config systemctl reload ssh ufw allow OpenSSH && ufw allow 80 && ufw allow 443 && ufw enable apt install -y unattended-upgrades
Small servers with 1 or 2 GB of RAM also benefit from a swap file, because npm installs and Next.js or TypeScript builds can spike memory well above what the running app uses.
fallocate -l 2G /swapfile && chmod 600 /swapfile mkswap /swapfile && swapon /swapfile echo '/swapfile none swap sw 0 0' >> /etc/fstab
Install Node.js with a pinned version
Avoid the Node.js package that ships with the distribution, which is usually several major versions behind. Install a current LTS release from NodeSource, or use a version manager such as fnm under the deploy user if different apps on the same server need different versions. Pin the version in your repository too, so local, CI and production agree.
{
"engines": { "node": "22.x" }
}A .nvmrc or .node-version file next to package.json does the same job for version managers. Whatever you choose, the goal is that a deploy never silently upgrades Node.js under a running app.
Use a release folder layout instead of git pull
The most common mistake when people deploy node app code by hand is building in the live directory. During npm ci the node_modules folder is half deleted, and during the build the dist folder is half written. Any request or restart in that window fails. The fix is the same layout that Capistrano made popular years ago.
/home/deploy/myapp/
releases/
20261019-101500/
20261019-143200/
shared/
.env
storage/
current -> releases/20261019-143200Each deploy clones or exports the commit into a new folder under releases, links shared files such as .env into it, installs dependencies, builds, and only then moves the current symlink. The switch is a single rename, so it is atomic. Rolling back means pointing current at the previous folder and restarting.
REL=/home/deploy/myapp/releases/$(date +%Y%m%d-%H%M%S) git clone --depth 1 --branch main [email protected]:acme/myapp.git "$REL" ln -s /home/deploy/myapp/shared/.env "$REL/.env" cd "$REL" && npm ci && npm run build && npm prune --omit=dev ln -sfn "$REL" /home/deploy/myapp/current.tmp mv -T /home/deploy/myapp/current.tmp /home/deploy/myapp/current sudo systemctl restart myapp
Keep the last five releases and delete older ones, so the disk does not fill up after a few months of deploys.
Run the app with systemd
PM2 is popular and works well, but on a server you control, systemd is already there, starts on boot, restarts on failure and sends logs to journald. A unit file for a Node.js app is short.
[Unit] Description=myapp After=network.target [Service] User=deploy WorkingDirectory=/home/deploy/myapp/current EnvironmentFile=/home/deploy/myapp/shared/.env ExecStart=/usr/bin/node dist/server.js Restart=always RestartSec=3 Environment=NODE_ENV=production PORT=3000 [Install] WantedBy=multi-user.target
Save it as /etc/systemd/system/myapp.service, then run systemctl daemon-reload and systemctl enable --now myapp. Read logs with journalctl -u myapp -f. If you need several processes to use all CPU cores, either run several units on different ports behind nginx, or use the Node.js cluster module inside the app. PM2 cluster mode is another valid choice if your team already knows it.
Put nginx in front and add TLS
nginx handles TLS, compression, slow clients and static files far better than a Node.js process. It also lets you run several apps on one server, each behind its own domain.
server {
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}The Upgrade and Connection headers keep WebSockets working. Then run certbot with the nginx plugin to get a Let's Encrypt certificate, which renews on a timer. If your app reads the client IP, enable trust proxy in Express or the equivalent in Fastify, otherwise every request looks like it comes from 127.0.0.1.
Avoid the restart gap
A plain systemctl restart stops the old process before the new one listens, which leaves a gap of a few hundred milliseconds to several seconds while the app boots. For many apps that is acceptable. If it is not, you have three practical options.
- Run two units on two ports and switch the nginx upstream after the new one passes a health check
- Use PM2 reload in cluster mode, which replaces workers one by one
- Use systemd socket activation so the kernel holds incoming connections while the new process starts
Whichever you pick, add a health endpoint such as /healthz that checks the database connection, and refuse to switch traffic until it returns 200. That single check catches most broken releases before users see them. Our guide on zero downtime deployment strategies compares these approaches in more depth.
Background jobs, cron and environment variables
Most real Node.js apps have more than one process. A queue worker for BullMQ, a scheduler, or a WebSocket server should each get their own systemd unit, restarted on every deploy so they run the new code. Cron jobs belong in the deploy user's crontab or in systemd timers, with output written to a log file, not lost to /dev/null. Keep secrets in shared/.env with permissions 600, never in the repository, and keep one file per environment so staging cannot accidentally talk to the production database.
Deploy node app releases automatically from git
Once the manual steps work, wrap them in a script and trigger it from a git push. A GitHub webhook or a CI job that connects over SSH and runs the script is enough to start. Add a lock so two deploys never run at the same time, write each deploy's output to a log file, and send a message to your team chat when a deploy fails. At that point you have a basic pipeline you can trust.
How Deployment does all of this for you
Deployment runs exactly this setup on your own server, without the hand written scripts. You connect a VPS over SSH or a cloud account by API token, and Deployment provisions it with a firewall, SSH hardening, unattended security updates, swap, nginx or Caddy and the Node.js version your repository asks for. When you deploy from git, each push builds in a new release folder, runs your editable deploy script, checks your health endpoint and only then switches the symlink, which gives you zero downtime deployment and one click rollback to any kept release. Workers and daemons restart on every deploy, cron jobs keep their logs, environment variables are encrypted per environment with a diff before each deploy, and Let's Encrypt certificates renew on their own. See the node js hosting page for the full Node.js stack, or paste a public repository into the demo on the home page to see the server plan and the first lines of the deploy script it generates.