In this tutorial, you will host a Node.js app on a VPS with Cloudflare, Nginx, HTTPS, DNS records, firewall rules, and a production launch checklist.
To host app on VPS with Cloudflare means your application runs on a Linux virtual server while Cloudflare manages public DNS, proxying, SSL/TLS settings, and edge security. The safe production pattern is simple: Cloudflare receives public traffic, Nginx listens on ports 80 and 443, and your app listens privately on 127.0.0.1:3000.
Raff Technologies operates 10,000+ VMs from Vint Hill, Virginia, giving small teams a practical Linux VM foundation for app hosting. Raff Linux VMs include NVMe SSD storage, unmetered bandwidth, 3 Gbps port speed, Linux OS options, and predictable monthly pricing.
Raff's General Purpose 2 vCPU / 4 GB VM costs $9.99/month with 80 GB NVMe SSD and unmetered VM traffic.
Pre-publish QA note: Before publishing, run this tutorial end-to-end on the
testedOnVM and replace this note with: "Tested on Raff infrastructure by Aybars Altınyay, platform engineer at Raff Technologies."
Prerequisites:
- A Raff Ubuntu 24.04 VM
- SSH access with sudo privileges
- A domain added to Cloudflare
- Access to Cloudflare DNS settings
- A Node.js app, or the sample app created in this tutorial
- Your VPS public IPv4 address
- A local terminal with
ssh,curl, anddig
This tutorial was prepared for a Raff VM with 2 vCPU, 4 GB DDR5 RAM, 40 GB NVMe storage, running Ubuntu 24.04 LTS.
Use these placeholders throughout the tutorial:
| Placeholder | Replace with |
|---|---|
your_server_ip | Your VPS public IPv4 address |
example.com | Your root domain |
www.example.com | Your www hostname |
deploy | Your non-root Linux user |
/opt/raff-node-app | The app directory on the VPS |
Step 1 — Launch a Linux VPS
Launch a Linux VPS first, because Cloudflare needs an origin server IP address before DNS can point traffic to your app.
In the Raff dashboard:
- Create a new Linux VM.
- Select Ubuntu 24.04 LTS.
- Choose a VM size that matches your workload.
- Add your SSH key.
- Launch the VM.
- Copy the public IPv4 address.
For a small Node.js app, start with a VM around 2 vCPU and 2-4 GB RAM. If the app also runs a database, background worker, queue, or build process, use more RAM or split those roles later.
For CPU, RAM, and storage planning, use the Choosing the Right VM Size guide before publishing a production app.
Verify that your VPS is reachable:
ssh root@your_server_ip
Expected result:
Welcome to Ubuntu 24.04 LTS
Exit the session for now:
exit
You now have a Linux VPS with a public IP address ready for the Cloudflare DNS setup later in the tutorial.
Step 2 — Prepare the server
Prepare the server with system updates and a non-root deployment user.
Connect as root:
ssh root@your_server_ip
Update packages:
apt update apt upgrade -y
Create a non-root deployment user:
adduser deploy usermod -aG sudo deploy
Copy your existing SSH authorized keys to the new user:
mkdir -p /home/deploy/.ssh cp ~/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys chown -R deploy:deploy /home/deploy/.ssh chmod 700 /home/deploy/.ssh chmod 600 /home/deploy/.ssh/authorized_keys
Open a second terminal and verify that the new user can log in:
ssh deploy@your_server_ip
Verify sudo access:
sudo whoami
Expected output:
root
Keep this deploy session open. Use it for the rest of the tutorial.
Step 3 — Configure firewall rules
Configure the VPS firewall before running the app. Public traffic should reach only SSH, HTTP, and HTTPS.
Ubuntu uses ufw as a simple host firewall interface. Allow SSH first so you do not lock yourself out:
sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow OpenSSH sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable
When prompted, type:
y
Check the firewall:
sudo ufw status verbose
Expected output:
Status: active To Action From -- ------ ---- 22/tcp ALLOW IN Anywhere 80/tcp ALLOW IN Anywhere 443/tcp ALLOW IN Anywhere
Do not open port 3000. The app will listen on 127.0.0.1:3000, and Nginx will forward traffic to it locally.
The safe request path is:
Cloudflare → VPS public IP → Nginx on ports 80/443 → app on 127.0.0.1:3000
Verify that port 3000 is not allowed by UFW:
sudo ufw status numbered
Expected result: ports 22, 80, and 443 are listed, but port 3000 is not listed.
For a deeper firewall setup, use the Set Up UFW Firewall on Ubuntu 24.04 tutorial.
Step 4 — Install Nginx
Install Nginx so it can receive public HTTP traffic and reverse proxy requests to your local app.
Install Nginx:
sudo apt install nginx -y
Enable and start the service:
sudo systemctl enable --now nginx
Verify the installed version:
nginx -v
Expected output:
nginx version: nginx/1.24.x
Check service status:
sudo systemctl status nginx --no-pager
Expected output includes:
Active: active (running)
Test the local Nginx response from the VPS:
curl -I http://127.0.0.1
Expected output begins with:
HTTP/1.1 200 OK Server: nginx
Nginx is now installed and ready to proxy traffic to the app.
For a separate Nginx walkthrough, use the Install Nginx on Ubuntu 24.04 tutorial.
Step 5 — Run the app locally
Run the app locally on 127.0.0.1:3000. Public traffic should not hit this port directly.
Install Node.js and npm from the Ubuntu repository:
sudo apt install nodejs npm -y
Verify Node.js and npm:
node -v npm -v
Expected output:
v18.x.x 9.x.x
Create a sample app directory:
sudo mkdir -p /opt/raff-node-app sudo chown deploy:deploy /opt/raff-node-app cd /opt/raff-node-app
Create a package file and install Express:
npm init -y npm install express
Create the app:
cat > server.js <<'EOF' const express = require("express"); const app = express(); const host = "127.0.0.1"; const port = process.env.PORT || 3000; app.get("/", (req, res) => { res.type("text/plain").send("Raff VPS app behind Cloudflare is running\n"); }); app.get("/health", (req, res) => { res.status(200).json({ status: "ok" }); }); app.listen(port, host, () => { console.log(`App listening on http://${host}:${port}`); }); EOF
Start the app temporarily:
node server.js > /tmp/raff-node-app.log 2>&1 & echo $! > /tmp/raff-node-app.pid
Verify the app locally:
curl http://127.0.0.1:3000/health
Expected output:
{"status":"ok"}
Verify that the app is bound to localhost:
ss -tulpn | grep ':3000'
Expected output includes:
127.0.0.1:3000
Stop the temporary process:
kill "$(cat /tmp/raff-node-app.pid)" rm /tmp/raff-node-app.pid
The app now works locally. Next, make it persistent with systemd.
Step 6 — Create a systemd service
Create a systemd service so the app starts after reboot and restarts if it crashes.
Create the service file:
sudo nano /etc/systemd/system/raff-node-app.service
Paste this configuration:
[Unit] Description=Raff Node app After=network.target [Service] Type=simple User=deploy WorkingDirectory=/opt/raff-node-app Environment=NODE_ENV=production Environment=PORT=3000 ExecStart=/usr/bin/node /opt/raff-node-app/server.js Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target
Save and close the file.
Reload systemd:
sudo systemctl daemon-reload
Enable and start the app service:
sudo systemctl enable --now raff-node-app
Check the service:
sudo systemctl status raff-node-app --no-pager
Expected output includes:
Active: active (running)
Verify the app again:
curl http://127.0.0.1:3000/health
Expected output:
{"status":"ok"}
Check recent logs:
journalctl -u raff-node-app -n 20 --no-pager
Expected output includes:
App listening on http://127.0.0.1:3000
Your app is now running as a persistent service on the VPS.
Step 7 — Configure Nginx reverse proxy
Configure Nginx to receive browser traffic and forward it to the local Node.js app.
Create a server block:
sudo nano /etc/nginx/sites-available/example.com
Paste this configuration:
server { listen 80; listen [::]:80; server_name example.com www.example.com; access_log /var/log/nginx/example.com.access.log; error_log /var/log/nginx/example.com.error.log; 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"; } }
Enable the site:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com
Disable the default site:
sudo rm -f /etc/nginx/sites-enabled/default
Test the Nginx configuration:
sudo nginx -t
Expected output:
syntax is ok test is successful
Reload Nginx:
sudo systemctl reload nginx
Test the reverse proxy locally by sending the domain as the Host header:
curl -H "Host: example.com" http://127.0.0.1/health
Expected output:
{"status":"ok"}
Nginx is now forwarding requests from port 80 to the app on 127.0.0.1:3000.
The NGINX reverse proxy pattern uses proxy_pass to forward requests from Nginx to the local application server. For the official reference, see the NGINX reverse proxy documentation: https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/
Step 8 — Add Cloudflare DNS records
Add Cloudflare DNS records so your domain points to the VPS.
In Cloudflare, open:
Websites → example.com → DNS → Records
Create this root domain record:
| Type | Name | Content | Proxy status | TTL |
|---|---|---|---|---|
| A | @ | your_server_ip | Proxied | Auto |
Create this www record:
| Type | Name | Content | Proxy status | TTL |
|---|---|---|---|---|
| CNAME | www | example.com | Proxied | Auto |
Cloudflare can proxy A, AAAA, and CNAME records that serve web traffic. Proxied records route HTTP and HTTPS traffic through Cloudflare before it reaches your origin server.
Wait for DNS to update, then check resolution from your local machine:
dig +short example.com
Expected result: you see Cloudflare IP addresses, not your VPS origin IP, when the record is proxied.
Test HTTP through the domain:
curl -I http://example.com
Expected output begins with either:
HTTP/1.1 200 OK
or:
HTTP/1.1 301 Moved Permanently
If the request reaches Nginx, your domain is now pointing through Cloudflare to the VPS.
For Cloudflare DNS behavior, see the official DNS records documentation: https://developers.cloudflare.com/dns/manage-dns-records/
Step 9 — Enable HTTPS
Enable HTTPS on the VPS so Cloudflare can connect securely to your origin server.
The recommended production path is:
Browser → HTTPS → Cloudflare → HTTPS → VPS
Install snapd if it is missing:
sudo apt install snapd -y
Install Certbot with snap:
sudo snap install core sudo snap refresh core sudo snap install --classic certbot
Make the certbot command available system-wide:
sudo ln -sf /snap/bin/certbot /usr/local/bin/certbot
Request a certificate and let Certbot update Nginx:
sudo certbot --nginx -d example.com -d www.example.com
Follow the prompts:
- Enter an email address.
- Accept the terms.
- Choose whether to share your email.
- Select redirect to HTTPS if Certbot asks.
Test automatic renewal:
sudo certbot renew --dry-run
Expected output includes:
Congratulations, all simulated renewals succeeded
Test HTTPS:
curl -I https://example.com
Expected output begins with:
HTTP/2 200
or:
HTTP/2 301
Certbot's official Nginx instructions document the certbot --nginx flow and renewal test: https://certbot.eff.org/instructions?ws=nginx
📌 Note: If certificate issuance fails while the Cloudflare record is proxied, temporarily switch the A and CNAME records to DNS only, wait a few minutes, run Certbot again, then switch the records back to Proxied.
Step 10 — Set Cloudflare SSL/TLS mode
Set Cloudflare SSL/TLS mode to validate the certificate on your VPS.
In Cloudflare, open:
Websites → example.com → SSL/TLS → Overview
Set encryption mode to:
Full (strict)
Use Full (strict) when your VPS has a valid Let's Encrypt certificate or Cloudflare Origin CA certificate. This mode lets Cloudflare connect to your origin over HTTPS and validate the origin certificate.
Avoid Flexible mode for production apps. Flexible mode can show HTTPS in the browser while Cloudflare connects to your VPS over HTTP, which can cause redirect loops and weaker origin security.
Verify HTTPS from your local machine:
curl -I https://example.com
Expected output includes:
HTTP/2 200
or:
HTTP/2 301
Then check for Cloudflare headers:
curl -I https://example.com | grep -i "server\|cf-cache-status\|cf-ray"
Expected output includes at least one Cloudflare-related header when proxying is active:
server: cloudflare cf-cache-status: DYNAMIC cf-ray: ...
Cloudflare's SSL/TLS documentation explains that Full and Full (strict) modes protect the Cloudflare-to-origin connection, with Full (strict) adding certificate validation: https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/
Step 11 — Verify the deployment
Verify the full deployment path before calling the app production-ready.
Check the app health endpoint through Cloudflare:
curl https://example.com/health
Expected output:
{"status":"ok"}
Check HTTP-to-HTTPS redirect:
curl -I http://example.com
Expected output includes:
HTTP/1.1 301 Moved Permanently location: https://example.com/
Check Nginx:
sudo systemctl status nginx --no-pager
Expected output includes:
Active: active (running)
Check the app service:
sudo systemctl status raff-node-app --no-pager
Expected output includes:
Active: active (running)
Confirm that the app port listens only on localhost:
ss -tulpn | grep ':3000'
Expected output includes:
127.0.0.1:3000
From your local machine, check that the app port is not publicly reachable:
curl -m 5 http://your_server_ip:3000
Expected result:
curl: (28) Connection timed out
or:
curl: (7) Failed to connect
Run a reboot test:
sudo reboot
Wait for the VPS to come back, then reconnect:
ssh deploy@your_server_ip
Check Nginx and the app:
sudo systemctl status nginx --no-pager sudo systemctl status raff-node-app --no-pager curl https://example.com/health
Expected output from the health endpoint:
{"status":"ok"}
A deployment is not complete until the app works through Cloudflare, uses HTTPS, survives reboot, and keeps the runtime port private.
Step 12 — Secure and monitor the setup
Add basic production hardening after the app is live.
Install automatic security updates:
sudo apt install unattended-upgrades -y
Verify the service:
sudo systemctl status unattended-upgrades --no-pager
Expected output includes:
Active: active (running)
Review open firewall rules:
sudo ufw status numbered
Expected result: only SSH, HTTP, and HTTPS are open.
Restrict SSH to your admin IP after confirming your current IP is stable:
sudo ufw allow from your_admin_ip to any port 22 proto tcp sudo ufw delete allow OpenSSH
Verify the firewall again:
sudo ufw status numbered
Expected output includes SSH allowed only from your_admin_ip.
⚠️ Warning: Do not restrict SSH until you have confirmed your current admin IP and opened a second SSH session. A wrong IP address can lock you out of the VPS.
Check app logs:
journalctl -u raff-node-app -n 50 --no-pager
Check Nginx logs:
sudo tail -n 50 /var/log/nginx/example.com.access.log sudo tail -n 50 /var/log/nginx/example.com.error.log
Check memory, disk, and uptime:
free -h df -h uptime
Expected result: memory and disk have safe headroom, and load average is normal for your VM size.
Before production traffic grows, configure backups or snapshots, review SSH access, and document the deployment path. For broader hardening, use Cloud Security Fundamentals and Secure Ubuntu 24.04 Server.
Cleanup
Use this section only if you want to remove the sample app and reverse proxy configuration from the VPS.
⚠️ Warning: The following commands remove the sample app service, Nginx site configuration, and local app directory. Back up any files you need before continuing.
Stop and disable the app service:
sudo systemctl stop raff-node-app sudo systemctl disable raff-node-app
Remove the service file:
sudo rm -f /etc/systemd/system/raff-node-app.service sudo systemctl daemon-reload
Remove the app directory:
sudo rm -rf /opt/raff-node-app
Remove the Nginx site:
sudo rm -f /etc/nginx/sites-enabled/example.com sudo rm -f /etc/nginx/sites-available/example.com sudo nginx -t sudo systemctl reload nginx
Delete the Let's Encrypt certificate:
sudo certbot delete --cert-name example.com
Close HTTP and HTTPS firewall ports if the VPS no longer serves web traffic:
sudo ufw delete allow 80/tcp sudo ufw delete allow 443/tcp
Verify cleanup:
sudo systemctl status raff-node-app --no-pager sudo nginx -t sudo ufw status numbered
Expected result: the app service no longer exists, Nginx syntax is valid, and only the ports you still need remain open.
Troubleshooting
Nginx returns 502 Bad Gateway
Cause: Nginx can reach the server, but the app process is stopped, crashed, or not listening on 127.0.0.1:3000.
Fix:
sudo systemctl status raff-node-app --no-pager journalctl -u raff-node-app -n 50 --no-pager curl http://127.0.0.1:3000/health
Restart the app:
sudo systemctl restart raff-node-app
Verify again:
curl -H "Host: example.com" http://127.0.0.1/health
Expected output:
{"status":"ok"}
Certbot fails domain validation
Cause: DNS does not point to the VPS, Cloudflare proxying interferes with validation, or Nginx is not reachable on port 80.
Fix:
dig +short example.com sudo ufw status numbered sudo systemctl status nginx --no-pager curl -I http://example.com
If needed, temporarily set the Cloudflare A and CNAME records to DNS only, wait a few minutes, then run:
sudo certbot --nginx -d example.com -d www.example.com
After the certificate is issued, switch the records back to Proxied.
Cloudflare shows 521 or 522 errors
Cause: Cloudflare cannot connect to the VPS. Nginx may be stopped, UFW may block traffic, or the DNS record may point to the wrong IP.
Fix:
sudo systemctl status nginx --no-pager sudo ufw status verbose curl -I http://127.0.0.1
Confirm the Cloudflare A record points to the correct VPS public IP.
Restart Nginx:
sudo systemctl restart nginx
Verify:
curl -I https://example.com
Expected output begins with:
HTTP/2 200
or:
HTTP/2 301
HTTPS causes a redirect loop
Cause: Cloudflare SSL/TLS mode is set to Flexible while Nginx or Certbot redirects HTTP to HTTPS.
Fix:
In Cloudflare, set:
SSL/TLS → Overview → Full (strict)
Then test:
curl -I https://example.com
Expected result: a stable 200 or a single redirect to the canonical HTTPS URL.
The app port is publicly reachable
Cause: The app is bound to 0.0.0.0, the firewall allows port 3000, or both.
Fix:
Confirm the app is bound to localhost:
ss -tulpn | grep ':3000'
Expected output should include:
127.0.0.1:3000
If UFW allows port 3000, remove the rule:
sudo ufw status numbered sudo ufw delete allow 3000/tcp
Restart the app:
sudo systemctl restart raff-node-app
Test from your local machine:
curl -m 5 http://your_server_ip:3000
Expected result: the connection fails.
DNS points to the wrong server
Cause: The Cloudflare A record uses an old VPS IP address.
Fix:
Check your current VPS IP in the Raff dashboard, then update the Cloudflare A record:
Type: A Name: @ Content: your_server_ip Proxy status: Proxied TTL: Auto
Verify:
curl -I https://example.com
Expected result: your app responds through the correct VPS.
Production checklist
Use this checklist before sending real users to the app.
VPS checklist
- Ubuntu packages are updated
- Non-root deployment user exists
- SSH key login works
- Password SSH login is disabled after key login is confirmed
- UFW allows only required public ports
- App runtime port is not public
- Nginx is enabled and running
- App service is enabled and running
- Reboot test passed
- Logs are available
- Backups or snapshots are configured
Cloudflare checklist
- Domain is active on Cloudflare
- Root A record points to the VPS public IP
wwwCNAME points to the root domain- Web records are Proxied
- SSL/TLS mode is Full (strict)
- HTTPS works
- HTTP redirects to HTTPS
- Dynamic app routes are not cached by mistake
- Admin routes have extra protection if needed
- Cloudflare analytics and security events are reviewed
Application checklist
- Production environment variables are set
- Secrets are not stored in Git
- Health endpoint works
- App survives reboot
- Error logs are monitored
- Database ports are private
- File uploads work if used
- Background workers run if used
- Deployment rollback path is documented
Conclusion
You now have a working app running on a VPS behind Cloudflare with Nginx, HTTPS, DNS records, firewall rules, and production checks.
The key pattern is simple: Cloudflare handles public DNS and proxying, Nginx receives traffic on ports 80 and 443, and the app stays private on 127.0.0.1:3000. That keeps the runtime port out of public traffic while still making the app reachable through your domain.
If you have not deployed your Raff VM yet, you can launch a Linux VM from the Raff VM product page.
Next, read Virtual Private Server Hosting if you want the broader VPS decision framework, Best VPS Hosting for Developers if you are comparing developer infrastructure, and Cloud Security Fundamentals before hardening production access.
