Running a Node.js application on your own cloud server gives you full control over the environment, no usage caps, and a predictable monthly cost. Once the setup is done, deploying new versions takes seconds.
This guide covers everything: provisioning the server, installing Node.js, running the app with PM2, setting up Nginx as a reverse proxy, getting HTTPS, and automating deploys via GitHub Actions.
I use Tencent Cloud Lighthouse for this. Lighthouse has a pre-built Node.js application image — select it at instance creation and Node.js is already installed and configured when the server starts up, so you can skip the NVM installation steps. It also includes a built-in OrcaTerm browser terminal so you can manage the server without opening a local SSH client, and the snapshot feature means you can take a full server backup before any major deployment change.
Key Takeaways
- Use the Lighthouse Node.js application image to skip manual NVM/Node.js installation
- PM2 keeps your app running continuously — set it up before anything else
- Nginx handles HTTPS and proxies to your app's internal port — never expose Node directly
pm2 reload myappprovides zero-downtime updatespm2 startup && pm2 saveensures your app survives server reboots
Managed PaaS platforms are a great choice when you want to get an app running quickly without touching server configuration. They handle a lot of the operational complexity for you.
Self-hosting on a VPS makes sense when you want more control or predictability:
The setup takes about 20 minutes. After that, deploying updates is automated.
| What | Notes |
|---|---|
| A Tencent Cloud Lighthouse account | Free to sign up |
| A Node.js app | Express, Fastify, whatever — any HTTP server works |
| Basic comfort with a terminal | If you can cd and run commands, you're fine |
| A domain name (optional) | Only needed if you want HTTPS with a real domain |
Cost: Lighthouse plans start at $5–6/month. Check current new-user promotions before signing up.
Log into the console, go to Lighthouse → New.
| Plan | vCPU | RAM | Good for |
|---|---|---|---|
| Starter | 2 | 2 GB | Hobby project, low-traffic API |
| Basic | 2 | 4 GB | Production app, most side projects |
| Standard | 4 | 8 GB | High concurrency, multiple apps |
Click your instance → Firewall → Add Rule:
| Port | Protocol | Purpose |
|---|---|---|
| 22 | TCP | SSH (keep this) |
| 80 | TCP | HTTP — Nginx handles this, not Node directly |
| 443 | TCP | HTTPS |
One thing worth flagging: don't open port 3000 (or whatever port your Node app uses) to the public. Nginx sits in front and handles the public traffic. Your Node process stays on localhost. This is both safer and cleaner.
⚡ Skip this section if you selected the Node.js application image in Part 1. Node.js is already installed. Run
node --versionto confirm and jump to Part 3.
SSH in (or click OrcaTerm in the Lighthouse console for a browser terminal — I use this constantly):
ssh ubuntu@YOUR_SERVER_IP
First, update the system:
sudo apt update && sudo apt upgrade -y
sudo apt install -y git curl build-essential
Now install Node.js via NVM. I've seen people do apt install nodejs and immediately regret it when they need a different version or want to update without breaking everything. NVM is the right move:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# Load NVM in current session
export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh"
# Install Node.js 20 LTS
nvm install 20
nvm use 20
nvm alias default 20
# Verify
node --version # v20.x.x
npm --version # 10.x.x
mkdir -p ~/apps && cd ~/apps
git clone https://github.com/your-username/your-app.git
cd your-app
npm install --production
This is where I see people make security mistakes. Never hardcode secrets. Create a .env file:
nano .env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=make-this-a-long-random-string
Lock down the permissions:
chmod 600 .env
.env goes in .gitignore. Always.
node server.js
# In another OrcaTerm tab:
curl http://localhost:3000
If you get a response, press Ctrl+C and move on. We'll use PM2 to run it properly.
PM2 is a process manager for Node.js. Without it, your app dies the moment you close the SSH session. With it, your app restarts automatically if it crashes, starts back up when the server reboots, and keeps logs you can actually read.
npm install -g pm2
# Start your app
pm2 start ~/apps/your-app/server.js --name "my-api"
# Check it's running
pm2 status
# Watch logs live
pm2 logs my-api
pm2 startup
# PM2 prints a command — copy and run it exactly
# It looks like: sudo env PATH=$PATH:/home/ubuntu/.nvm/... pm2 startup systemd -u ubuntu --hp /home/ubuntu
pm2 save
Do this now. I've forgotten it before and wondered why my app wasn't running after a server restart. Do it now.
pm2 reload my-api # Zero-downtime restart — use this in production
pm2 restart my-api # Hard restart — brief downtime, use for debugging
pm2 logs my-api # Last N lines of logs
pm2 monit # Live dashboard with CPU/memory per process
Your Node app runs on port 3000 internally. Nginx listens on port 80/443 publicly and forwards traffic to Node. This setup:
sudo apt install -y nginx
sudo nano /etc/nginx/sites-available/my-api
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
# Serve static files directly (optional)
location /static/ {
root /home/ubuntu/apps/your-app/public;
expires 30d;
}
# Everything else goes to Node
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
proxy_read_timeout 86400s;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_vary on;
}
sudo ln -s /etc/nginx/sites-available/my-api /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t # Must say "test is successful"
sudo systemctl reload nginx
Visit http://YOUR_SERVER_IP — your API should respond.
Once your domain's DNS A record points to the server IP (wait 5–30 minutes for propagation), run:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
When it asks about HTTP-to-HTTPS redirect: choose yes. Certbot edits your Nginx config and sets up auto-renewal. You don't have to touch SSL again.
Verify renewal works:
sudo certbot renew --dry-run
Your API is now live at https://yourdomain.com. 🎉
I set this up on day one and haven't manually SSH'd in to deploy since. Here's how it works: you push to main, GitHub Actions SSHs into your server and runs a deploy script.
nano ~/deploy.sh
#!/bin/bash
set -e
APP_DIR=~/apps/your-app
echo "=== Deploy started: $(date) ==="
cd $APP_DIR
git fetch origin
git reset --hard origin/main
npm install --production
pm2 reload my-api --update-env
echo "=== Deploy done ==="
chmod +x ~/deploy.sh
Create .github/workflows/deploy.yml:
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: SSH deploy
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.SERVER_IP }}
username: ubuntu
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: ~/deploy.sh
Add two secrets in your GitHub repo settings (Settings → Secrets → Actions):
SERVER_IP = your Lighthouse public IPSSH_PRIVATE_KEY = paste the full contents of your ~/.ssh/id_ed25519 private keyPush to main. Watch the Actions tab. Done.
After I set everything up and deployed my first update via GitHub Actions, the app was returning stale data. PM2 showed it was running fine. Nginx was fine. Everything looked green.
Turns out: PM2 reload re-reads the code, but it doesn't automatically pick up changes to the .env file unless you pass --update-env. My deploy script had pm2 reload my-api without that flag — so the new code was running with the old environment variables.
The fix is the --update-env flag I already have in the deploy script above. Make sure you include it:
pm2 reload my-api --update-env
If you're seeing mysterious behavior after a deploy and the logs look clean, check whether your env vars are actually current:
pm2 env my-api
# Shows all environment variables the process sees
| Symptom | First thing to check | Fix |
|---|---|---|
502 Bad Gateway |
Is PM2 running? | pm2 status → pm2 restart my-api |
| App not responding after reboot | PM2 startup configured? | pm2 startup && pm2 save |
| HTTPS cert expired | Auto-renewal cron | sudo certbot renew --dry-run |
| Env vars not updating | --update-env missing |
pm2 reload my-api --update-env |
| Port already in use | Another process on 3000 | lsof -i :3000 → kill the PID |
EACCES on port 80 |
Node binding directly | Use Nginx proxy instead, never bind Node to port 80 |
Here's what running Node.js on a VPS looks like in practice:
| Self-hosted VPS (Lighthouse $6/mo) | |
|---|---|
| Monthly cost | Flat rate, predictable |
| Process uptime | Continuous — PM2 keeps it running |
| Root access | Full |
| Multiple apps | Yes — separate PM2 processes per app |
| Background workers | Yes — additional PM2 entries |
| Deploy speed | Git push → ~15 seconds via GitHub Actions |
| Maintenance | System updates, monitor PM2 status |
When a VPS is the right choice: side projects, internal tools, production APIs where you want predictable costs and full environment control.
When managed platforms make more sense: if you need to scale to many instances automatically and prefer not to manage infrastructure at all, managed PaaS is easier for that use case.
For most individual developers and small teams, a $6/month VPS running PM2 + Nginx handles production workloads comfortably.
| Issue | Likely Cause | Fix |
|---|---|---|
| Connection refused | Service not running or wrong port | Check systemctl status SERVICE and verify firewall rules |
| Permission denied | Wrong file ownership or permissions | Check file ownership with ls -la and use chown/chmod to fix |
| 502 Bad Gateway | Backend service not running | Restart the backend service; check logs with journalctl -u SERVICE |
| SSL certificate error | Certificate expired or domain mismatch | Run sudo certbot renew and verify domain DNS points to server IP |
| Service not starting | Config error or missing dependency | Check logs with journalctl -u SERVICE -n 50 for specific error |
| Out of disk space | Logs or data accumulation | Run df -h to identify usage; clean logs or attach CBS storage |
| High memory usage | Too many processes or memory leak | Check with htop; consider upgrading instance plan if consistently high |
| Firewall blocking traffic | Port not open in UFW or Lighthouse console | Open port in Lighthouse console firewall AND sudo ufw allow PORT |
Do I need to install Node.js manually on Tencent Cloud Lighthouse?
No — select the Node.js application image and Node.js is pre-installed. Skip the NVM installation section entirely.
What is PM2 and why use it?
PM2 keeps your app running continuously, restarts it automatically on failure, and persists across reboots. Running node server.js directly stops when you close the terminal.
Can I run multiple Node.js apps on one server?
Yes. Each app runs as a separate PM2 process on a different internal port. Nginx routes public traffic to the right app by domain name.
How do I deploy updates without downtime?
Use pm2 reload myapp for zero-downtime reload — PM2 restarts workers one at a time.
What port should my Node.js app listen on?
Any unprivileged port (3000, 4000, 5000) internally. Nginx handles public 80/443 and proxies to your app.
Set up your server today:
👉 Tencent Cloud Lighthouse — Developer-friendly VPS
👉 Current pricing and new user promotions
👉 Explore all active deals and offers