A production Node.js deployment is more than copying files to a server and running node server.js. The application needs a predictable runtime, a supervisor that restarts it after failure, a reverse proxy that handles public traffic, and a release process you can reverse when something goes wrong.
What this deployment includes
- Ubuntu as the host operating system
- A dedicated, non-root service account
- systemd for startup, restart, and log collection
- Nginx for HTTP handling and reverse proxying
- TLS certificates for HTTPS
- A repeatable release and rollback workflow
The examples assume your application listens on 127.0.0.1:3000 and exposes a lightweight /health endpoint.
1. Prepare the server and application user
Update the server, install Nginx, and create a dedicated account for the application. Avoid running the Node.js process as root.
sudo apt update
sudo apt upgrade -y
sudo apt install -y nginx
sudo adduser --system --group --home /srv/myapp myapp
sudo mkdir -p /srv/myapp/releases /srv/myapp/shared
sudo chown -R myapp:myapp /srv/myapp
Install a supported Node.js LTS release from a source you trust. Confirm the runtime before deploying:
node --version
npm --version
2. Place the application in a versioned release directory
Versioned directories make rollback straightforward. Put each build in a timestamped folder, install production dependencies, then point a stable current symlink at the active release.
sudo -u myapp mkdir -p /srv/myapp/releases/20260923-1000
# Copy the tested application build into this directory
cd /srv/myapp/releases/20260923-1000
sudo -u myapp npm ci --omit=dev
sudo -u myapp ln -sfn /srv/myapp/releases/20260923-1000 /srv/myapp/current
Keep secrets out of the release directory. Store environment values in a protected file such as /srv/myapp/shared/app.env, owned by root and readable only by the service group.
sudo install -o root -g myapp -m 0640 /dev/null /srv/myapp/shared/app.env
3. Run Node.js with systemd
Create /etc/systemd/system/myapp.service:
[Unit]
Description=My Node.js application
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/srv/myapp/shared/app.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
KillSignal=SIGTERM
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Reload systemd, enable the service at boot, and verify that it starts cleanly:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
curl -f http://127.0.0.1:3000/health
Application logs are available through the journal:
sudo journalctl -u myapp -n 100 --no-pager
sudo journalctl -u myapp -f
4. Put Nginx in front of the application
Create /etc/nginx/sites-available/myapp and replace the example domain:
server {
listen 80;
listen [::]:80;
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-Real-IP $remote_addr;
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";
proxy_read_timeout 60s;
}
}
Enable the site only after the configuration test passes:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo nginx -t
sudo systemctl reload nginx
Keep port 3000 bound to localhost. Public traffic should reach the application through Nginx, not by connecting directly to the Node.js process.
5. Add HTTPS and verify the full path
Point the domain's DNS records at the server, obtain a certificate with your preferred ACME client, and configure automatic renewal. After HTTPS is active, test from outside the server:
curl -I https://app.example.com
curl -f https://app.example.com/health
Also test a real application route, a static asset, any upload limits, and WebSocket connections if the app uses them. A passing local health check does not prove that DNS, TLS, proxy headers, and public routing are all correct.
6. Make releases safe and reversible
For each release, install dependencies and run migrations before switching the current symlink. Restart the service only after the new release is ready.
sudo -u myapp ln -sfn /srv/myapp/releases/NEW_RELEASE /srv/myapp/current
sudo systemctl restart myapp
curl -f http://127.0.0.1:3000/health
If validation fails, point current back to the previous release and restart the service. Database changes require extra care: use backward-compatible migrations so the old application version can still run during rollback.
7. Monitor what users actually depend on
- Alert when the public HTTPS health check fails, not only when the process stops.
- Track response time, error rate, memory use, disk space, and certificate expiry.
- Rotate and retain logs long enough to investigate incidents.
- Test backups by restoring them to an isolated environment.
- Patch Ubuntu, Node.js, dependencies, and Nginx on a planned cadence.
Production checklist
- The Node.js process runs as a dedicated non-root user.
- The application port is bound to localhost.
- systemd starts the service at boot and restarts it after failure.
- Nginx configuration passes
nginx -t. - HTTPS works and certificate renewal is monitored.
- Secrets are outside the release directory with restrictive permissions.
- A health check validates the public request path.
- The previous release remains available for rollback.
This structure keeps the deployment understandable: Nginx owns the public edge, systemd owns the application process, and versioned releases make changes traceable. Whether you operate the server directly or through a control panel, those boundaries are the foundation of a reliable Node.js hosting workflow.



