Running a Node.js application locally is just the first step. To make it production ready, you need to configure it for real-world conditions that require stability, security, and performance. A properly configured production environment ensures your app can recover from crashes, serve traffic securely, and handle increased demand without downtime.
In this guide, you will build a complete production setup on Ubuntu using Node.js, PM2, Nginx, Let’s Encrypt, and UFW. Node.js runs the application, PM2 manages processes and restarts them automatically, Nginx handles incoming requests and serves traffic over HTTPS, Let’s Encrypt provides free SSL certificates, and UFW secures the server by controlling network access. Together, these tools form a strong foundation for deploying applications that are reliable, efficient, and secure.
Deploy your Node applications from GitHub using DigitalOcean App Platform. Let DigitalOcean focus on scaling your app.
Key Takeaways:
pm2 startup and pm2 save.127.0.0.1) in production to prevent direct public access and rely on Nginx for external traffic.sudo certbot renew --dry-run..env files loaded by dotenv, and keep them out of version control.pm2 monit, htop, or integration with tools like Grafana/Prometheus) to detect performance issues early.sudo apt update && sudo apt upgrade, npm audit) to maintain system and dependency security.This guide assumes that you have the following:
When you’ve completed the prerequisites, you will have a server serving your domain’s default placeholder page at https://example.com/.
In this step, you’ll install Node.js using the NodeSource PPA and verify that the installation was successful. This method ensures that you have access to the latest Long-Term Support (LTS) version of Node.js and its associated package manager, npm.
First, install the NodeSource PPA to gain access to its repository. Make sure you’re in your home directory, and use curl to download the setup script for the most recent LTS version of Node.js.
- cd ~
- curl -sL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
You can review the script contents with nano before running it:
- nano nodesource_setup.sh
When you’re ready, execute the script with sudo:
- sudo bash nodesource_setup.sh
This will add the PPA to your configuration and automatically update your local package cache.
Now, you can install Node.js:
- sudo apt install nodejs
To verify the installation and check the version of Node.js, run:
- node -v
You should see an output similar to this:
v24.11.1
The nodejs package also installs npm, the Node.js package manager. To confirm it’s installed and initialize its configuration file, run:
- npm -v
You should see a similar output:
11.6.2
In order for some npm packages to work (those that require compiling code from source, for example), you will need to install the build-essential package:
- sudo apt install build-essential
With Node.js and npm successfully installed, you now have the necessary tools to develop and manage your application dependencies. In the next section, you’ll create a simple Node.js app to test the runtime environment.
Let’s write a simple Node.js application that returns “Hello World” to any HTTP requests. This sample app will verify that Node.js is installed correctly and demonstrate how applications should be configured to listen securely on localhost in production.
Create a new file called hello.js in your home directory:
- cd ~
- nano hello.js
Add the following code to the file:
const http = require('http');
const hostname = 'localhost';
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World!\n');
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
Save and close the file when finished.
This script creates a simple HTTP server that listens on port 3000 and responds with Hello World! to any incoming requests. Because it’s bound to localhost, it will only accept connections from the same machine.
Run your application using Node.js:
- node hello.js
You should see output similar to:
Server running at http://localhost:3000/
In another terminal session, test your app with curl:
- curl http://localhost:3000
You should see the following output:
Hello World!
If you do not get the expected output, make sure that your Node.js application is running and configured to listen on the proper address and port.
Once you’re sure it’s working, kill the application by pressing CTRL+C in the original terminal to stop the server.
In production environments, Node.js applications typically sit behind a reverse proxy such as Nginx. The Node.js app listens on an internal address (like localhost:3000), and Nginx handles incoming web traffic, TLS termination, and routing.
Binding your application to localhost provides several security and reliability benefits:
This setup ensures that your Node.js app is isolated, secure, and only accessible through the Nginx reverse proxy.
In this section, you’ll install PM2, a production process manager for Node.js applications. PM2 allows you to keep your applications running continuously, automatically restart them after crashes, and set them to start on system boot.
Use npm to install PM2 globally:
- sudo npm install -g pm2
The -g flag installs PM2 system-wide, so it’s available for all users and applications.
Once it’s installed, start your sample application using PM2:
- pm2 start hello.js
You’ll see an output table that shows PM2 has started managing your app:
[PM2] Spawning PM2 daemon with pm2_home=/home/user/.pm2
[PM2] PM2 Successfully daemonized
[PM2] Starting /root/nodeapp/hello.js in fork_mode (1 instance)
[PM2] Done.
┌────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ hello │ default │ N/A │ fork │ 3826 │ 0s │ 0 │ online │ 0% │ 33.3mb │ root │ disabled │
└────┴──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
As indicated above, PM2 automatically assigns an App name (based on the filename, without the .js extension) and a PM2 id. PM2 also maintains other information, such as the PID of the process, its current status, and memory usage.
PM2 automatically restarts your application if it crashes or is killed. To make sure PM2 itself starts automatically when the server reboots, run:
- pm2 startup systemd
PM2 will generate a command that looks something like this (replace sammy with your username):
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/home/sammy/.nvm/versions/node/v24.11.1/bin /home/sammy/.nvm/versions/node/v24.11.1/lib/node_modules/pm2/bin/pm2 startup systemd -u sammy --hp /home/sammy
Note: Your path will vary depending on your Node.js version and username.
Run that command, then save your running apps so PM2 remembers them for the next reboot:
- pm2 save
To confirm everything is set up properly, check the service status:
- systemctl status pm2-sammy
Note: For a detailed overview of systemd, please review Systemd Essentials: Working with Services, Units, and the Journal.
If you encounter issues, reboot your server and check that your app starts automatically.
When you’re ready to run more than one app or manage different environments, PM2’s ecosystem file is a huge help. It’s a simple configuration file that lets you define environment variables, ports, and deployment settings all in one place.
Generate a starter file by running:
- pm2 ecosystem
Then edit the generated ecosystem.config.js file:
module.exports = {
apps: [
{
name: 'hello',
script: 'hello.js',
env: {
NODE_ENV: 'development',
PORT: 3001
},
env_production: {
NODE_ENV: 'production',
PORT: 3000
}
}
]
};
To start your app in production mode, use:
- pm2 start ecosystem.config.js --env production
The ecosystem file makes it easy to manage multiple environments and securely store environment variables outside your source code. With PM2 in place, your app will survive crashes, reboots, and updates, all while running quietly in the background.
In addition to those we have covered, PM2 provides many subcommands that allow you to manage or look up information about your applications.
Stop an application with this command (specify the PM2 App name or id):
- pm2 stop app_name_or_id
Restart an application:
- pm2 restart app_name_or_id
List the applications currently managed by PM2:
- pm2 list
Get information about a specific application using its App name:
- pm2 info app_name
To view live logs for your application:
- pm2 logs
The PM2 process monitor can be pulled up with the monit subcommand. This displays the application status, CPU, and memory usage:
- pm2 monit
Note that running pm2 without any arguments will also display a help page with example usage.
Now that your Node.js application is running and managed by PM2, let’s set up the reverse proxy.
With your Node.js app running under PM2, the next step is to make it accessible to users through the web. Instead of exposing the app directly on port 3000, you’ll use Nginx as a reverse proxy. Nginx will handle all incoming HTTP and HTTPS requests and pass them to your Node.js app running on localhost.
Open your site’s Nginx configuration file. If you followed the setup from the prerequisites section, this file should be located in /etc/nginx/sites-available/example.com:
- sudo nano /etc/nginx/sites-available/example.com
Inside the server block, locate (or create) the location / section and replace its contents with the following configuration. If your Node.js app listens on a different port, update the highlighted value accordingly.
server {
...
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
...
}
This tells Nginx to listen on port 80 (HTTP) and forward all incoming traffic to your Node.js app running on port 3000.
To test multiple apps, you can define additional location blocks. For example, if you have another Node.js app running on port 3001, add this to the same server block:
server {
...
location /app2 {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
...
}
Save the file, then test the configuration for syntax errors by running:
- sudo nginx -t
If everything checks out, reload Nginx:
- sudo systemctl reload nginx
At this point, visiting your server’s domain (for example, http://example.com) should load the Node.js app running through the Nginx proxy.
To secure your app, configure Nginx to handle HTTPS traffic. If you already have SSL certificates from Let’s Encrypt, ensure they’re defined in your Nginx config. Here’s an example of how the updated server block might look:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
The first server block automatically redirects all HTTP requests to HTTPS, and the second block handles secure traffic on port 443.
If your server hosts multiple applications, Nginx can handle them all by defining different paths or subdomains. For instance, you might have:
https://example.com/ forwards to port 3000https://example.com/app2 forwards to port 3001https://api.example.com/ forwards to port 4000Each app can be managed by its own PM2 process and served through Nginx.
Once you’ve confirmed everything is working, your Node.js app should now be accessible securely through Nginx.
Now that your Node.js app is running behind Nginx, let’s add a final layer of network security. Ubuntu ships with a built-in firewall called UFW (Uncomplicated Firewall). It’s lightweight, simple to configure, and perfect for blocking unwanted access to your server.
Before making any changes, check whether UFW is already enabled:
- sudo ufw status
If it says inactive, don’t worry, we’ll enable it shortly. But first, we’ll define which types of traffic should be allowed. It’s always best to set up the rules before turning it on.
At a minimum, you’ll need to allow SSH so you can still connect to your server, and HTTP/HTTPS so Nginx can serve web traffic. To do that, run the following commands:
- sudo ufw allow OpenSSH
- sudo ufw allow 'Nginx Full'
Now we can enable the firewall:
- sudo ufw enable
You’ll be prompted to confirm: type y and press Enter.
Check the firewall again to make sure your rules are active:
- sudo ufw status
You should see something like this:
Status: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
Nginx Full ALLOW Anywhere
OpenSSH (v6) ALLOW Anywhere (v6)
Nginx Full (v6) ALLOW Anywhere (v6)
That’s it! Only SSH and web traffic are now permitted; everything else is blocked.
Firewalls are fundamental to server security because they establish strict control over what network traffic can reach your system. Even well-configured applications and web servers can be vulnerable if unnecessary ports are left open.
Here are a few key reasons why firewalls are critical in production:
For most production servers, keeping a firewall active with only the essential ports open strikes the right balance between accessibility and protection. It’s a low-maintenance safeguard that provides an extra layer of control and helps prevent unauthorized network access.
With UFW configured, your system is now more secure at the network level.
Now that your application is publicly accessible through Nginx, it’s time to add HTTPS for secure communication. SSL encrypts all traffic between your server and users, ensuring privacy and data integrity. The easiest way to add SSL to your site is by using Let’s Encrypt, a free and automated certificate authority.
Let’s Encrypt works through a tool called Certbot, which handles the entire process of requesting, installing, and renewing certificates. On Ubuntu, you can install Certbot along with its Nginx plugin in a single step:
- sudo apt update
- sudo apt install certbot python3-certbot-nginx -y
This installs both the Certbot client and its Nginx integration plugin, which will automatically configure HTTPS in your existing Nginx server block.
Once Certbot is installed, you can use it to request a new SSL certificate for your domain. Replace example.com and www.example.com with your actual domain names:
- sudo certbot --nginx -d example.com -d www.example.com
During the process, Certbot will:
You’ll be prompted for an email address (used for renewal notifications) and asked to agree to the terms of service. Once the setup completes, you should see a confirmation message stating that your certificate has been successfully installed.
You can double-check your certificate details with:
- sudo certbot certificates
Then visit your domain to verify that the site is now being served securely.
Let’s Encrypt certificates are only valid for 90 days, but there’s no need to worry about manual renewals. Certbot installs a system timer that runs twice daily to check for expiring certificates and renew them automatically.
You can test this renewal process at any time with:
- sudo certbot renew --dry-run
If the dry run completes without errors, your certificates will renew automatically in the background, and Nginx will reload to apply the new ones.
Running your application over HTTPS is no longer optional, it’s an essential part of running any production system. SSL/TLS ensures that all communication between your server and clients is encrypted and tamper-proof, protecting against eavesdropping and data manipulation.
Beyond security, HTTPS offers several additional advantages:
By using Let’s Encrypt, you get these benefits with minimal setup and zero cost. Certificates renew automatically, making it an easy long-term solution for secure deployments.
With HTTPS in place, your server is now serving encrypted, trusted traffic.
Every production application needs a reliable way to handle configuration data such as API keys, database credentials, or secret tokens. Storing these values directly in your source code can expose them through version control, logs, or even client-side leaks. A safer approach is to use environment variables, which keep sensitive information separate from your codebase.
.env Files with dotenvA common and straightforward method to manage environment variables in Node.js is by using a .env file together with the dotenv package. This setup lets your application read configuration values at runtime while keeping them out of the source code.
Run the following to install the dotenv package:
- npm install dotenv
Next, create a .env file in your project directory:
- nano .env
Add your configuration values in key-value pairs, like this:
PORT=3000
NODE_ENV=production
DB_HOST=localhost
DB_USER=myuser
DB_PASS=securepassword
JWT_SECRET=myjwtsecretkey
Now, modify your main application file (for example, app.js) to load the environment variables at startup:
require('dotenv').config();
const express = require('express');
const app = express();
app.listen(process.env.PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV} mode on port ${process.env.PORT}`);
});
When your app runs, it reads the variables defined in the .env file and uses them to configure the runtime environment.
If you are running your application with PM2, you can define environment variables directly in PM2 commands or configuration files. This helps keep configurations consistent across environments like development, staging, and production.
You can set variables inline when starting an app:
- pm2 start app.js --name "myapp" --env production
For a cleaner and more scalable solution, use the PM2 ecosystem configuration file. Generate it using:
- pm2 ecosystem
Then edit the ecosystem.config.js file to include your environment variables:
module.exports = {
apps: [
{
name: 'myapp',
script: 'app.js',
env: {
NODE_ENV: 'development',
PORT: 3001
},
env_production: {
NODE_ENV: 'production',
PORT: 3000,
DB_HOST: 'localhost',
DB_USER: 'myuser',
DB_PASS: 'securepassword'
}
}
]
};
Start the app in production mode:
- pm2 start ecosystem.config.js --env production
This ensures that the correct variables are loaded automatically each time the app restarts.
Managing secrets and environment variables securely is critical to maintaining the integrity of your infrastructure. The following best practices will help minimize the risks:
Keep secrets out of source control: Never commit .env files to your Git repository. Add them to .gitignore to avoid accidental exposure.
Limit access: Only trusted users and automated systems should have permission to read or modify environment configuration files.
Use environment-specific files: Maintain separate configurations for development, testing, staging, and production. For example, .env.development and .env.production.
Rotate secrets regularly: Change API keys, tokens, and passwords on a schedule to reduce the impact of potential leaks.
Use a secrets manager for larger systems: For complex applications or teams, consider using services like HashiCorp Vault instead of local .env files.
Avoid logging sensitive data: Be careful not to print or log secrets in your application or deployment logs.
Validate environment variables at startup: Use libraries such as joi or env-schema to ensure all required variables are set before the application runs.
Restrict file permissions: Use the chmod command to limit access to .env files. For example:
- chmod 600 .env
Following these practices helps protect sensitive information, prevents accidental exposure, and makes it easier to manage configuration across multiple environments.
With your application up and running in a stable production environment, it is important to focus on performance and efficiency. Optimization not only improves user experience, but also reduces server costs and helps ensure smooth scalability. In this section, you will learn several techniques to fine-tune your Node.js and Nginx setup for better speed, reliability, and resource usage.
Compression is one of the simplest ways to improve performance. It reduces the size of files sent from the server to the client, which speeds up page loads and reduces bandwidth consumption. Nginx supports two popular compression methods: gzip and Brotli.
To enable gzip compression, open the main Nginx configuration file:
- sudo nano /etc/nginx/nginx.conf
Inside the http block, add or uncomment the following directives:
gzip on;
gzip_types text/plain text/css application/javascript application/json application/xml;
gzip_min_length 256;
gzip_comp_level 5;
These settings enable gzip for common file types, compress files larger than 256 bytes, and apply a moderate compression level to balance CPU load and performance.
If you have the Brotli module installed, you can use it for even better compression ratios:
brotli on;
brotli_comp_level 5;
brotli_types text/plain text/css application/javascript application/json application/xml;
Brotli is particularly effective for text-based assets like CSS and JavaScript. After making these changes, reload Nginx to apply them:
- sudo systemctl reload nginx
With compression enabled, clients will download assets faster, resulting in quicker load times and improved performance on both desktop and mobile devices.
Caching allows your server to reuse previously generated responses instead of regenerating them for every request. This greatly reduces the load on your Node.js application and speeds up response times for end users.
To cache static assets such as images, stylesheets, and scripts, add this configuration to your Nginx server block:
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 7d;
add_header Cache-Control "public, no-transform";
}
This configuration instructs browsers to cache static assets for seven days. You can adjust the duration depending on how often your static files change.
For APIs or dynamic content, caching can be applied more selectively. For example, you might cache responses for frequently accessed endpoints while excluding endpoints that return user-specific data. Nginx also supports microcaching (short-term caching in seconds) for high-traffic APIs.
Logs are essential for monitoring, troubleshooting, and performance tuning. Both PM2 and Nginx provide comprehensive logging tools that help you keep track of what is happening across your system.
PM2 manages your application logs automatically. To view them in real time, use:
- pm2 logs
Over time, logs can grow large and consume disk space. To prevent this, install the PM2 log rotation module:
- pm2 install pm2-logrotate
Then configure the rotation policy:
- pm2 set pm2-logrotate:max_size 10M
- pm2 set pm2-logrotate:retain 7
This setup keeps log files under 10 MB each and retains seven days of logs. You can adjust these values based on your storage capacity and logging needs.
Nginx maintains both access logs (records of each request) and error logs (information about issues and failures). These logs are typically stored in /var/log/nginx/.
You can review access logs manually or generate a detailed traffic report using tools like goaccess:
- sudo apt install goaccess
- sudo goaccess /var/log/nginx/access.log -o /var/www/html/report.html --log-format=COMBINED
This command creates an interactive HTML report with metrics on visitor locations, traffic volume, and response times.
Well-managed logs are critical for identifying performance bottlenecks, failed requests, or security anomalies before they impact users.
Node.js applications run on a single thread by default. To utilize all available CPU cores and improve concurrency, you can use clustering and other process management techniques.
PM2 can automatically start multiple Node.js processes to take advantage of multi-core CPUs:
- pm2 start app.js -i max
This command starts as many instances of your app as there are CPU cores on your server. PM2 will manage load balancing and restart processes if they fail.
Regularly monitor your app’s resource usage to prevent memory leaks or excessive CPU consumption:
- pm2 monit
This opens an interactive dashboard showing live memory and CPU metrics for each process.
Let Nginx serve static files directly instead of routing them through Node.js. This significantly reduces CPU usage on the Node.js side and ensures faster delivery of cached assets.
Use environment variables to fine-tune performance settings such as log verbosity, API rate limits, or cache durations. Managing configuration through environment variables keeps your app flexible and easier to scale.
After optimizing your production environment, the next essential step is setting up effective monitoring and metrics collection. Monitoring gives you visibility into your application’s performance, resource utilization, and system health. It helps you detect problems early, track long-term trends, and make data-driven improvements.
Let’s see how you can monitor your Node.js application, Nginx web server, and system resources using built-in tools and external services.
In production, every second counts. Without monitoring, performance issues or service disruptions can go unnoticed until they affect users. Monitoring helps you:
A well-designed monitoring setup includes both real-time monitoring for immediate alerts and historical metrics for performance analysis over time.
PM2, the process manager used to run your Node.js app, includes powerful monitoring features.
To get an overview of running processes, use:
- pm2 list
This displays all active applications along with their status, uptime, CPU, and memory usage. For a more detailed, live view, use:
- pm2 monit
This opens an interactive dashboard showing key performance metrics, such as CPU and memory utilization per process. Monitoring these metrics helps you detect resource exhaustion, runaway processes, or memory leaks.
PM2 also provides centralized log management. To review your application logs, run:
- pm2 logs
You can view logs for a specific app using:
- pm2 logs myapp
If you notice frequent restarts, check PM2’s process list with:
- pm2 status
It will show how many times each process has restarted, which can be an indicator of errors or crashes.
For larger deployments, PM2 offers PM2 Plus, a web-based dashboard that tracks metrics such as CPU usage, memory consumption, and application uptime across all servers. You can set up alerts for threshold breaches and view performance trends over time.
PM2 can also integrate with popular third-party monitoring systems like Datadog, New Relic, and Prometheus for unified visibility.
Nginx sits at the front of your production stack, managing HTTP requests and SSL termination. Monitoring Nginx is essential for understanding how users interact with your system and how efficiently requests are being processed.
Nginx logs every request it handles in the access log, usually located at:
/var/log/nginx/access.log
Errors are recorded separately in:
/var/log/nginx/error.log
You can review these logs in real time:
- sudo tail -f /var/log/nginx/access.log
Access logs show valuable information such as client IPs, response times, status codes, and user agents. These logs are useful for identifying patterns like high latency, frequent 404 errors, or abnormal request volumes.
For a summarized and visual report, you can use goaccess. It creates an interactive dashboard that highlights traffic sources, popular endpoints, and response distribution.
Nginx includes a lightweight stub_status module that exposes live server metrics such as current connections and request counts. Add the following block to your Nginx configuration:
location /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}
After reloading Nginx, you can view metrics by visiting:
http://localhost/nginx_status
These metrics can be integrated into monitoring dashboards or used for automated alerts.
For more granular application-level monitoring, you can collect custom metrics directly from your Node.js app. These metrics can track API response times, request rates, or database query durations.
Run the following command to install the Prometheus client library:
- npm install prom-client express
Add a /metrics endpoint to your application:
const express = require('express');
const client = require('prom-client');
const app = express();
client.collectDefaultMetrics({ timeout: 5000 });
app.get('/metrics', async (req, res) => {
res.set('Content-Type', client.register.contentType);
res.end(await client.register.metrics());
});
app.listen(3000, () => {
console.log('Metrics available at /metrics');
});
You can connect this endpoint to Prometheus or Grafana to visualize metrics such as request latency, memory usage, and garbage collection performance.
In addition to application monitoring, keep an eye on overall server performance. Linux provides several tools for this purpose:
You can automate system monitoring using tools such as Netdata, Glances, or Grafana Agent, which gather metrics across your infrastructure and visualize them in dashboards.
Even a well-configured production setup can occasionally run into issues. Understanding how to identify, diagnose, and resolve these problems is key to maintaining reliability and minimizing downtime. This section provides detailed troubleshooting steps for common errors and outlines best practices to help you keep your Node.js environment stable, secure, and maintainable over the long term.
Cause: Incorrect file paths, missing dependencies, or misconfigured environment variables.
Solution: Check logs using:
- pm2 logs
Confirm the correct file path is used in your PM2 configuration. Reinstall dependencies if necessary:
- npm install --production
Also verify that required environment variables are loaded properly from .env or your ecosystem file.
Cause: Another process is already listening on the same port.
Solution: Identify and stop the conflicting process:
- sudo lsof -i :3000
- sudo kill -9 <PID>
Alternatively, change your application port in the .env file or the ecosystem configuration.
Cause: Nginx cannot reach the Node.js app, usually because the app crashed, stopped, or is bound to a different port.
Solution: Restart the Node.js app and reload Nginx:
- pm2 restart all
- sudo systemctl reload nginx
Make sure the Nginx proxy_pass value matches your Node.js application’s listening address (for example, http://localhost:3000).
Cause: The application user lacks permission to read or write certain files or directories.
Solution: Set correct ownership and permissions:
- sudo chown -R $USER:$USER /var/www/myapp
- sudo chmod -R 755 /var/www/myapp
Avoid running Node.js or PM2 as the root user.
Cause: DNS issues, expired certificates, or incorrect Nginx configuration.
Solution: Confirm DNS records point to your server and test renewal:
- sudo certbot renew --dry-run
If renewal fails, review the Nginx configuration for syntax errors:
- sudo nginx -t
You can also check Certbot logs in /var/log/letsencrypt/ for detailed errors.
Cause: UFW rules prevent incoming HTTP or HTTPS connections.
Solution: Ensure Nginx traffic is allowed and the firewall is active:
- sudo ufw allow 'Nginx Full'
- sudo ufw enable
- sudo ufw status
When issues occur, follow a structured process:
Check Logs: Review PM2, Nginx, and system logs first as most issues leave error traces here.
Verify Services: Confirm Node.js, PM2, and Nginx are running and enabled at startup:
- pm2 status
- systemctl status nginx
Validate Configuration Files: Run syntax checks before restarting services:
- sudo nginx -t
For PM2 ecosystem files, check JSON or JavaScript formatting.
Check Port Binding: Verify that your app is listening on the expected port:
- sudo netstat -tulnp | grep node
Monitor System Resources: Use tools like htop or pm2 monit to check CPU, memory, and disk usage. Overloaded servers can cause slow responses or crashes.
Test Connectivity: Use curl to simulate requests and verify that your endpoints respond correctly:
- curl http://localhost:3000
Following best practices ensures that your application remains reliable, secure, and easy to maintain as it grows.
Keep the system updated: Regularly update Node.js, PM2, Nginx, and Ubuntu packages using:
- sudo apt update && sudo apt upgrade -y
This ensures security patches and performance improvements are applied.
Use HTTPS everywhere: Always enforce HTTPS through Nginx redirects and automatic certificate renewal with Certbot.
Restrict SSH access: Use key-based authentication, disable root logins, and limit SSH to known IPs.
Run as a non-root user: Use a dedicated application user to limit privileges in case of compromise.
Hide stack traces and error details: Never expose detailed error messages to users. Configure Express or other frameworks to handle errors gracefully.
Regularly audit dependencies: Run:
- npm audit
and update or replace vulnerable packages.
Enable clustering: Use PM2 to run multiple Node.js instances across available CPU cores:
- pm2 start app.js -i max
Use caching effectively: Implement caching layers (such as Redis or in-memory caching) for frequently accessed data.
Monitor regularly: Set up dashboards with PM2 Plus, Grafana, or Prometheus to track performance trends.
Optimize logs: Enable log rotation to prevent excessive disk use and keep only relevant data.
Compress responses: Keep gzip or Brotli compression enabled in Nginx for faster delivery.
A proactive maintenance strategy, combined with continuous monitoring and strict adherence to security principles, helps ensure your Node.js application remains performant, reliable, and resilient in production.
To deploy a production-ready application, follow this standard workflow. Avoid running the app using node index.js directly in a terminal session, as it will stop if the session closes.
Here is a general workflow:
Update System: Run sudo apt update && sudo apt upgrade.
Create a Non-Root User: Never run your app as root. Create a dedicated user (e.g., deploy).
Install Node.js: Use NodeSource binary distributions for the latest LTS version (don’t use the default Ubuntu apt version as it is often outdated).
- curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
- sudo apt-get install -y nodejs
Clone & Install: Clone your repository containing the Node.js app, move into the directory, and run npm ci (clean install) instead of npm install to ensure exact dependency versions.
Environment Variables: Create a .env file for secrets (DB strings, API keys).
Start with a Process Manager: Instead of running node index.js (which stops when you close the terminal), use PM2 to daemonize the app:
- sudo npm install -g pm2
- pm2 start app.js --name "my-production-app"
Ensure Startup Persistence: Tell the server to revive this app if the server reboots:
systemd).- pm2 startup
Run the Generated Command: Copy and paste the command PM2 outputs into your terminal. It will look something like this: sudo env PATH=$PATH:/home/sammy/.nvm/versions/node/v24.11.1/bin /home/sammy/.nvm/versions/node/v24.11.1/lib/node_modules/pm2/bin/pm2 startup systemd -u sammy --hp /home/sammy
Freeze the Process List: Now that the startup system is active, save your currently running processes so PM2 remembers them on reboot.
- pm2 save
The industry standard for managing Node.js processes on a Virtual Private Server (VPS) is PM2.
While you can use systemd directly, PM2 offers features specifically designed for Node.js that standard service managers lack, such as:
pm2 monit).systemd for Node.js in production?The best practice is to use both.
You should use PM2 to manage the application and Systemd to manage PM2. If you only use PM2, your app will vanish if the server reboots.
How to combine them:
pm2 start app.jspm2 startupsystemd to launch PM2 on boot).pm2 saveNode.js is excellent at handling application logic but poor at handling SSL, static files, and load balancing compared to Nginx.
The Setup:
sudo apt install nginx.localhost:3000).Example Configuration (/etc/nginx/sites-available/default):
server {
listen 80;
server_name your_domain.com;
location / {
proxy_pass http://localhost:3000; # Forward to Node
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
After saving, test the config with sudo nginx -t and restart Nginx.
Do not handle HTTPS inside your Node.js application logic. Let Nginx handle the encryption/decryption (SSL Termination).
The Easiest Method (Certbot):
Install Certbot: sudo apt install certbot python3-certbot-nginx
Run the automatic generator:
- sudo certbot --nginx -d your_domain.com
Certbot will automatically edit your Nginx config file to enable HTTPS and set up a cron job to auto-renew the certificate.
Security requires a layered approach.
Operating System Level:
UFW Firewall: Enable the firewall and only allow necessary ports.
- sudo ufw allow ssh
- sudo ufw allow 'Nginx Full'
- sudo ufw enable
SSH Hardening: Disable password login and use SSH keys only.
Application Level:
npm install helmet) in your app to set secure HTTP headers automatically.localhost (127.0.0.1) so it cannot be accessed directly from the internet, forcing traffic through Nginx.You’ve now set up a complete production environment for your Node.js application on Ubuntu. With Node.js running under PM2, Nginx managing web traffic, SSL encryption from Let’s Encrypt, and UFW protecting your network, your server is stable, secure, and ready for real-world use. By adding caching, compression, and proper monitoring, you’ve built a system that performs well and is easy to maintain. This setup gives you a solid foundation to grow your app, whether that means scaling across servers, automating deployments, or adding more advanced tooling down the line.
For more tutorials on Node.js, check out the following articles:
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
Former Developer at DigitalOcean community. Expertise in areas including Ubuntu, Docker, Ruby on Rails, Debian, and more.
Community and Developer Education expert. Former Senior Manager, Community at DigitalOcean. Focused on topics including Ubuntu 22.04, Ubuntu 20.04, Python, Django, and more.
With over 6 years of experience in tech publishing, Mani has edited and published more than 75 books covering a wide range of data science topics. Known for his strong attention to detail and technical knowledge, Mani specializes in creating clear, concise, and easy-to-understand content tailored for developers.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
Thank you for the article, it’s very useful. I’d like to know what should be the Nginx setup if I wanted the second app accessed not through https://example.com/app2 but through https://example2.com? How to use different domains for different apps?
Great tutorial! I have a WordPress site at /var/www/example.com/html (NGINX PHP) and I want my NodeJS apps to run in /var/www/example.com/html/nodejsapps How shall I do that?
Please could you explain the correct way to set up environment variables in production?
I have several sites/apps on the same Ubuntu 20.04 server with NGINX server blocks. The NodeJS apps are using different versions on NodeJS. Anyone who has experience/advice on how to install/configure nvm, volta or nvs in production to switch node versions and use different node versions for respective apps on the same Ubuntu server?
This comment has been deleted
I followed the instructions exactly and for some reason nodejs -v did not work but node -v did.
Thank you for this useful tutorial.
It’s possible to have one for Apache2 ?
Hi, thanks for the tutorial, it’s working great. However, I would like to be able to access the application from other computers on the LAN the server belongs to. At the moment, when I point to the IP of the server, I’m redirected to a standard Nginx landing page instead of the Node.js app. I guess there is an option in the server blocks to exclude an IP range ?
Hey guys are these tutorials still accurate? Visiting an http://your_domain in a browser gets refused because they use https by default now. I’ve used curl and I’m getting my content back but it doesn’t work in a browser. Am I correct?
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.
Full documentation for every DigitalOcean product.
The Wave has everything you need to know about building a business, from raising funding to marketing your product.
Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.
New accounts only. By submitting your email you agree to our Privacy Policy
Scale up as you grow — whether you're running one virtual machine or ten thousand.
Sign up and get $200 in credit for your first 60 days with DigitalOcean.*
*This promotional offer applies to new accounts only.