Skip to content

Deploying a Node.js app to a VPS from scratch

8 min read

What you’re building (and why)

A typical production setup for a Node.js app on a VPS looks like this:

  • Your Node.js app runs on a local port (e.g., `localhost:3000`) and should not be directly exposed to the internet.
  • PM2 keeps your app running, restarts it on crashes, and can start it on boot.
  • Nginx listens on ports 80/443, serves as a reverse proxy to your app, and handles things like buffering, request limits, and static assets.
  • SSL (HTTPS) is added using Let’s Encrypt via Certbot.
  • Environment variables store secrets/config without hardcoding them into your repo.
This guide assumes an Ubuntu VPS (20.04/22.04), but the ideas apply broadly.

---

Step 1: Provision your VPS and point DNS

1. Create a VPS with a provider (DigitalOcean, Hetzner, Linode, etc.). 2. Choose Ubuntu and add your SSH key during provisioning (recommended). 3. Note your server’s public IP, e.g. `203.0.113.10`. 4. In your domain’s DNS settings create: - `A` record: `example.com` → `203.0.113.10` - `A` record: `www.example.com` → `203.0.113.10` (optional)

DNS propagation can take minutes to hours.

---

Step 2: First login and basic server hardening

SSH into the server:

```bash ssh root@203.0.113.10 ```

Create a non-root user

```bash adduser deploy usermod -aG sudo deploy ```

(Optional) Copy root’s authorized keys to the new user:

```bash rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy ```

Now log in as that user:

```bash exit ssh deploy@203.0.113.10 ```

Update packages

```bash sudo apt update sudo apt -y upgrade ```

Set up a firewall (UFW)

Allow SSH and web traffic:

```bash sudo apt -y install ufw sudo ufw allow OpenSSH sudo ufw allow 'Nginx Full' sudo ufw enable sudo ufw status ```

Optional but recommended: disable password SSH, root login

Edit SSH config:

```bash sudo nano /etc/ssh/sshd_config ```

Common hardening settings:

```text PermitRootLogin no PasswordAuthentication no PubkeyAuthentication yes ```

Then restart SSH:

```bash sudo systemctl restart ssh ```

Be careful: do this only after confirming key-based login works.

---

Step 3: Install Node.js (LTS) and build tools

On Ubuntu, a reliable approach is NodeSource.

```bash curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - sudo apt -y install nodejs ```

Verify:

```bash node -v npm -v ```

Install build essentials (useful for native dependencies):

```bash sudo apt -y install build-essential ```

---

Step 4: Create an app directory and get your code onto the server

A common convention:

```bash sudo mkdir -p /var/www/myapp sudo chown -R deploy:deploy /var/www/myapp ```

Option A: Git-based deployment (common and simple)

Install git:

```bash sudo apt -y install git ```

Clone your repository:

```bash cd /var/www git clone https://github.com/you/myapp.git cd myapp ```

Install dependencies:

```bash npm ci ```

If you have a build step (TypeScript, bundlers, etc.):

```bash npm run build ```

Make sure your app listens correctly

In production behind Nginx, your app should listen on localhost:

  • Host: `127.0.0.1` (or `localhost`)
  • Port: `3000` (or whatever you choose)
Example Node.js snippet:

```js const port = process.env.PORT || 3000; app.listen(port, "127.0.0.1"); ```

---

Step 5: Manage the app with PM2

Install PM2 globally:

```bash sudo npm install -g pm2 ```

Start your app. Examples:

If your entry file is `server.js`

```bash cd /var/www/myapp pm2 start server.js --name myapp ```

If you use npm scripts

```bash pm2 start npm --name myapp -- start ```

Check status/logs:

```bash pm2 status pm2 logs myapp ```

Enable startup on boot

```bash pm2 startup systemd ```

PM2 will print a command—run it (usually with `sudo`). Then save the current process list:

```bash pm2 save ```

---

Step 6: Install and configure Nginx as a reverse proxy

Install Nginx:

```bash sudo apt -y install nginx ```

Create a server block:

```bash sudo nano /etc/nginx/sites-available/myapp ```

Example config (HTTP only for now):

```nginx server { listen 80; server_name example.com www.example.com;

# Optional: limit upload size client_max_body_size 10M;

location / { proxy_pass http://127.0.0.1:3000;

# Preserve client and protocol info 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;

# WebSocket support (if you use it) proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } ```

Enable the site and test Nginx config:

```bash sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx ```

At this point, visiting `http://example.com` should reach your Node app.

---

Step 7: Add SSL (HTTPS) with Certbot (Let’s Encrypt)

Install Certbot and its Nginx plugin:

```bash sudo apt -y install certbot python3-certbot-nginx ```

Request a certificate and auto-configure Nginx:

```bash sudo certbot --nginx -d example.com -d www.example.com ```

Certbot will:

  • Obtain certificates
  • Update your Nginx config for HTTPS
  • Optionally enable HTTP → HTTPS redirect
Test renewal:

```bash sudo certbot renew --dry-run ```

Let’s Encrypt certificates renew automatically via system timers on most Ubuntu setups.

---

Step 8: Environment variables (secrets and configuration)

Never hardcode secrets (DB passwords, API keys) in your repo.

Simple approach: systemd environment file (recommended)

Create an environment file:

```bash sudo nano /etc/myapp.env ```

Example:

```bash NODE_ENV=production PORT=3000 DATABASE_URL=postgres://user:pass@127.0.0.1:5432/mydb JWT_SECRET=change-me ```

Secure it:

```bash sudo chown root:root /etc/myapp.env sudo chmod 600 /etc/myapp.env ```

Then tell PM2 to use these variables by starting via an ecosystem file.

Create `ecosystem.config.js` in your project:

```js module.exports = { apps: [ { name: "myapp", script: "server.js", env: { NODE_ENV: "production", }, env_file: "/etc/myapp.env", }, ], }; ```

Start with PM2:

```bash cd /var/www/myapp pm2 delete myapp pm2 start ecosystem.config.js pm2 save ```

Notes on `dotenv`

`dotenv` is useful for local development. In production, prefer OS-managed env vars or protected env files. If you do use `.env` on the server, ensure it’s outside the repo and permissions are strict.

---

Step 9: A practical deployment workflow (repeatable updates)

A basic manual deployment flow using Git:

```bash cd /var/www/myapp git pull npm ci npm run build # if applicable pm2 restart myapp ```

Zero-downtime-ish reloads

PM2 can reload some apps gracefully:

```bash pm2 reload myapp ```

Whether it’s truly “zero-downtime” depends on how your app handles connections.

Common improvement: deploy script

Create `/var/www/myapp/deploy.sh`:

```bash #!/usr/bin/env bash set -euo pipefail

cd /var/www/myapp git pull npm ci npm run build || true pm2 restart myapp ```

Make executable:

```bash chmod +x deploy.sh ```

Run:

```bash ./deploy.sh ```

---

Production best practices (high impact)

Keep your app bound to localhost

Your Node process should listen on `127.0.0.1` and only be reachable through Nginx. This reduces exposure.

Use `NODE_ENV=production`

Many frameworks optimize behavior in production mode.

Set up logs and monitoring

  • PM2 logs: `pm2 logs`
  • Consider log rotation:
```bash pm2 install pm2-logrotate ```

  • Basic server monitoring tools: `htop`, `vnstat`, or provider dashboards.

Use least privilege

Run app processes as a non-root user (like `deploy`). Only Nginx binds to privileged ports (80/443) and it runs under its own service user.

Regular updates

At minimum:

```bash sudo apt update && sudo apt -y upgrade ```

Consider enabling unattended security upgrades for convenience.

Backups

Back up:
  • Your database (if applicable)
  • `.env`/`/etc/myapp.env` and other config
  • Uploaded files (if you store user uploads locally—prefer object storage if possible)

Security headers and rate limiting (optional, useful)

Nginx can add headers and basic rate limits. As you grow, consider a WAF/CDN (Cloudflare) and application-level protections.

Separate concerns as you scale

As traffic grows, you might split:
  • Database to managed DB or separate server
  • App server and worker processes
  • Load balancer in front of multiple app servers
---

Quick troubleshooting checklist

  • Nginx shows 502 Bad Gateway: your Node app isn’t running, wrong port, or listening on the wrong interface. Check:
- `pm2 status` - `pm2 logs myapp` - Nginx `proxy_pass` matches your app port
  • Certbot fails: confirm DNS points to the VPS IP and port 80 is reachable.
  • Changes not taking effect: you forgot to restart/reload PM2 or rebuild.
  • Permission issues: ensure `/var/www/myapp` is owned by your deployment user and files are readable as needed.
---

Where to go next

Once the basics are working, the next upgrades usually are:

  • CI/CD (GitHub Actions) to deploy automatically on push
  • A staging environment on a separate VPS or subdomain
  • Database hardening and remote access restrictions
  • Centralized logging/metrics (Grafana, Prometheus, managed solutions)
This stack—Node.js + PM2 + Nginx + Certbot—covers a large portion of real-world VPS deployments and is a strong foundation for learning production hosting.