To host an app on a VPS with Cloudflare, run the application on your Linux VM, keep its runtime port private, place Nginx in front, and proxy the public hostname through Cloudflare. The production request path in this tutorial is:
Visitor -> Cloudflare -> HTTPS to Nginx on the VPS -> 127.0.0.1:3000 -> Node.js app
Raff Technologies is used as the Ubuntu VM platform in the saved workflow. Cloudflare is not the VPS provider in this architecture: the VM runs at Raff (or another cloud/VPS provider), while Cloudflare provides DNS, the HTTP/HTTPS reverse-proxy layer, and edge security/performance features.
Cloudflare's production guidance supports proxied web DNS records and Full (strict) SSL/TLS with a valid origin certificate. As of October 27, 2026, Node.js 24.22.1 LTS includes the October security fixes; install a patched v24 build and verify the selected version before deploying the application. This walkthrough retains the documented nvm 0.40.7 installer but does not claim it is the newest nvm version.
The original article workflow was prepared for a Raff Ubuntu 24.04 VM with 2 vCPU and 4 GB RAM. Current Cloudflare proxy behavior, Full (strict), Node.js, nvm, and Certbot installation guidance were documentation-reviewed for this refresh; no new full Raff + Cloudflare end-to-end machine retest is claimed.
Prerequisites:
- A Raff Linux VM running Ubuntu 24.04
- SSH access through a non-root sudo user such as
deploy - A domain active in Cloudflare
- Access to Cloudflare DNS and SSL/TLS settings
- The VM's public IPv4 address
- Ports 80 and 443 available for Nginx
- A recovery path before enabling or changing firewall rules
Use these placeholders throughout:
| Placeholder | Replace with |
|---|---|
your_server_ip | Your VM public IPv4 address |
example.com | Your root domain |
www.example.com | Your www hostname |
deploy | Your non-root deployment user |
/opt/raff-node-app | Application directory on the VM |
Step 1 — Prepare the Ubuntu 24.04 VPS
Connect to the VM:
ssh deploy@your_server_ip
Confirm the OS and available resources:
lsb_release -ds nproc free -h lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINTS
Install the packages used by the tutorial:
sudo apt update sudo apt upgrade -y sudo apt install -y curl ca-certificates git nginx ufw snapd dnsutils
Check whether the upgrade requests a reboot:
test -f /var/run/reboot-required && cat /var/run/reboot-required
If a reboot is required, perform it during a safe maintenance window before proceeding with production traffic.
Verify: Ubuntu 24.04 should be running, the non-root SSH account should work, and Nginx, UFW, curl, dig, and snapd should be available.
Step 2 — Configure UFW without risking SSH lockout
Inspect the firewall before changing it:
sudo ufw status verbose
Determine the actual SSH port from the effective OpenSSH configuration:
SSH_PORT="$(sudo sshd -T | awk '/^port / {print $2}')" echo "$SSH_PORT"
Allow the working SSH port and Nginx web traffic:
sudo ufw allow "${SSH_PORT}/tcp" sudo ufw allow 'Nginx Full' sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw status numbered
Keep the current SSH session open and successfully open a second SSH session before enabling an inactive firewall.
If UFW is currently inactive, enable it only after the second SSH session succeeds:
sudo ufw enable
Check the rules again:
sudo ufw status numbered
Do not use ufw --force enable as a shortcut around verifying the SSH path. Do not create a public allow rule for port 3000; the application will listen only on loopback.
Verify: the actual SSH port should remain reachable, Nginx ports 80/443 should be allowed, UFW should be active, and port 3000 should have no public allow rule.
Step 3 — Install Node.js 24 LTS with nvm
The tutorial uses nvm 0.40.7 to install Node.js 24 LTS; as of October 27, 2026, choose Node.js 24.22.1 or a later patched v24 LTS release instead of an older October-vulnerable Node build.
Download the nvm installer to a file:
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.7/install.sh \ -o /tmp/nvm-install.sh
Inspect it before execution:
sed -n '1,220p' /tmp/nvm-install.sh
Run the installer as the non-root deployment user:
bash /tmp/nvm-install.sh
Load nvm into the current shell:
export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
Install Node.js 24 and make it the user's default:
nvm install 24 nvm alias default 24 nvm use 24
Verify:
nvm --version node --version npm --version command -v node
Node.js 24.22.1 was released with October 2026 security updates. Verify the actual Node patch release with node --version and ensure it meets or exceeds this patched version; npm can advance independently.
Verify: the pinned nvm installer completes, Node.js reports a patched v24 LTS build at 24.22.1 or later, and npm runs for the deployment user. Do not treat the v24 major version alone as proof that the October security fixes are installed.
Step 4 — Create a localhost-only Node.js application
Create the application directory:
sudo mkdir -p /opt/raff-node-app sudo chown deploy:deploy /opt/raff-node-app cd /opt/raff-node-app
Initialize the sample project and install Express:
npm init -y npm install express
Create server.js:
cat > server.js <<'EOF' const express = require('express'); const app = express(); const host = '127.0.0.1'; const port = Number(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
Run it temporarily:
node server.js
From a second SSH session:
curl -i http://127.0.0.1:3000/health sudo ss -lntp | grep ':3000'
Expected health body:
{"status":"ok"}
The socket should be bound to 127.0.0.1:3000, not 0.0.0.0:3000.
Stop the foreground process with Ctrl+C after the check.
Verify: /health should return HTTP 200 and the application should listen only on loopback.
Step 5 — Run the Node.js app with systemd
Resolve the exact Node.js binary for the deployment user:
NODE_BIN="$(command -v node)" echo "$NODE_BIN"
Create the service with that absolute path:
sudo tee /etc/systemd/system/raff-node-app.service > /dev/null <<EOF [Unit] Description=Raff Node app After=network-online.target Wants=network-online.target [Service] Type=simple User=deploy WorkingDirectory=/opt/raff-node-app Environment=NODE_ENV=production Environment=PORT=3000 ExecStart=${NODE_BIN} /opt/raff-node-app/server.js Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target EOF
Load and start the service:
sudo systemctl daemon-reload sudo systemctl enable --now raff-node-app
Check it:
systemctl is-active raff-node-app curl http://127.0.0.1:3000/health journalctl -u raff-node-app -n 30 --no-pager
Because nvm stores Node.js in a version-specific user path, re-run command -v node and update ExecStart whenever you change the Node.js version used by this service.
Verify: systemd should report the service as active, the local health route should work, and the journal should not show a restart loop.
Step 6 — Put Nginx in front of the application
Create a server block:
sudo tee /etc/nginx/sites-available/example.com > /dev/null <<'NGINX' 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-Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 10s; proxy_read_timeout 60s; } } NGINX
Enable the site and disable the default site:
sudo ln -sfn /etc/nginx/sites-available/example.com \ /etc/nginx/sites-enabled/example.com sudo rm -f /etc/nginx/sites-enabled/default
Validate and reload:
sudo nginx -t sudo systemctl enable --now nginx sudo systemctl reload nginx
Test locally with the intended Host header:
curl -i -H 'Host: example.com' http://127.0.0.1/health
Verify: nginx -t should succeed and Nginx should return the application's /health response while proxying to 127.0.0.1:3000.
Step 7 — Add proxied Cloudflare DNS records
In Cloudflare DNS, create web records similar to:
| Type | Name | Content | Proxy status | TTL |
|---|---|---|---|---|
| A | @ | your_server_ip | Proxied | Auto |
| CNAME | www | example.com | Proxied | Auto |
Cloudflare currently allows A, AAAA, and CNAME records that resolve web traffic to be proxied and recommends proxying records that serve HTTP or HTTPS. A proxied record returns Cloudflare addresses to public DNS clients instead of directly returning the origin IP.
Check from your local computer:
dig +short example.com dig +short www.example.com
Then test HTTP:
curl -I http://example.com
Important: proxied DNS reduces direct origin exposure through current DNS answers, but it does not make a known origin IP unreachable. Historical DNS, mail records, or other services can still reveal an origin address unless you add separate origin protection.
Verify: the web records should show Proxied in Cloudflare, DNS should return Cloudflare addresses, and HTTP should reach Nginx through the hostname.
Step 8 — Install a trusted TLS certificate on the Nginx origin
Cloudflare Full (strict) accepts an origin certificate that is unexpired, matches the hostname, and is issued by either a publicly trusted CA or Cloudflare Origin CA. This tutorial uses a publicly trusted Let's Encrypt certificate so the origin certificate can also be validated independently.
Certbot currently recommends its snap package for most Linux users.
Install it:
sudo snap install --classic certbot sudo ln -sf /snap/bin/certbot /usr/local/bin/certbot
Request certificates and let Certbot update Nginx:
sudo certbot --nginx --redirect \ -d example.com \ -d www.example.com
If you do not use www, omit it consistently from Cloudflare DNS, Nginx, and Certbot.
Check the certificate and renewal path:
sudo certbot certificates sudo certbot renew --dry-run sudo nginx -t
Test HTTPS:
curl -I https://example.com
If validation fails, inspect DNS, Nginx, port 80, and the ACME challenge path first. Do not make DNS only the default troubleshooting step because gray-clouding a public web record exposes the origin address in DNS and removes Cloudflare's HTTP proxy for that hostname while it is disabled.
Verify: Certbot should issue the certificate, renew --dry-run should pass, Nginx syntax should remain valid, and the site should respond over HTTPS.
Step 9 — Set Cloudflare SSL/TLS to Full (strict)
In the Cloudflare dashboard, open the zone's SSL/TLS settings and select:
Full (strict)
Cloudflare recommends Full (strict) whenever possible. In this mode both network legs are encrypted and Cloudflare validates the origin certificate presented by Nginx.
Do not use Flexible for this architecture. Flexible leaves the Cloudflare-to-origin connection on HTTP and commonly creates confusing redirect behavior when the origin itself redirects HTTP to HTTPS.
Test the public route:
curl -I https://example.com curl -sI https://example.com | grep -Ei '^cf-ray:|^server:'
A proxied request normally includes a cf-ray response header.
Verify: Full (strict) should be active, HTTPS should succeed, and the response should show evidence that traffic is passing through Cloudflare.
Step 10 — Confirm the application port remains private
On the VM:
sudo ss -lntp | grep ':3000' sudo ufw status numbered
The application should still listen on:
127.0.0.1:3000
There should be no UFW allow rule for port 3000.
From a machine outside the VPS, attempt the direct runtime port:
curl --connect-timeout 5 http://your_server_ip:3000
The connection should fail.
Verify: the application should work through Nginx but should not be reachable directly on public port 3000.
Step 11 — Check logs, resource headroom, and origin exposure
Inspect application and Nginx logs:
journalctl -u raff-node-app -n 50 --no-pager sudo tail -n 50 /var/log/nginx/example.com.access.log sudo tail -n 50 /var/log/nginx/example.com.error.log
Check basic resource headroom:
free -h df -h / uptime
When Cloudflare proxies requests, Nginx sees Cloudflare edge IPs as the direct TCP clients by default. Cloudflare sends the original visitor address in the CF-Connecting-IP header. Do not blindly trust that header from arbitrary direct origin traffic; if your application needs the true visitor IP, configure Nginx's real-IP handling only together with Cloudflare's published trusted IP ranges and keep those ranges updated.
Also understand the origin-protection boundary: proxied DNS does not by itself prevent someone who already knows the VPS IP from connecting directly to ports 80 or 443. Cloudflare recommends stronger controls such as restricting origin web traffic to Cloudflare IP ranges, authenticated origin mechanisms, or Cloudflare Tunnel where appropriate.
Before production traffic, configure backups for application configuration and persistent data. If the application stores uploads, databases, queues, or other state, protect those components separately instead of treating one VM snapshot as the complete backup plan. See Raff Data Protection for infrastructure-level recovery options.
Verify: logs should be readable, disk and memory should have headroom, and you should know whether direct origin access is intentionally allowed or will be restricted before production.
Step 12 — Verify the Cloudflare VPS deployment end to end
From your local computer, test the public path:
printf 'HTTPS headers:\n' curl -I https://example.com printf '\nApplication health:\n' curl https://example.com/health printf '\nCloudflare marker:\n' curl -sI https://example.com | grep -Ei '^cf-ray:|^server:'
On the VM, verify the origin path:
printf 'Nginx:\n' systemctl is-active nginx sudo nginx -t printf '\nApplication service:\n' systemctl is-active raff-node-app printf '\nLocal application:\n' curl http://127.0.0.1:3000/health printf '\nApplication socket:\n' sudo ss -lntp | grep ':3000' printf '\nFirewall:\n' sudo ufw status numbered
Confirm the expected chain:
Visitor -> Cloudflare proxied hostname -> HTTPS to Nginx on the Raff VM -> proxy to 127.0.0.1:3000 -> Node.js application
Finally, reboot during a safe test window:
sudo reboot
After reconnecting:
systemctl is-active nginx systemctl is-active raff-node-app curl https://example.com/health
End-to-end verification is complete when the proxied HTTPS hostname works, /health returns {"status":"ok"}, Full (strict) is active, Nginx and the application survive reboot, the Node.js process remains bound to 127.0.0.1:3000, and port 3000 is not publicly allowed.
Troubleshooting
Nginx returns 502 Bad Gateway
Test the local application before changing Cloudflare:
systemctl status raff-node-app --no-pager journalctl -u raff-node-app -n 50 --no-pager curl -i http://127.0.0.1:3000/health
Then validate Nginx:
sudo nginx -t sudo tail -n 50 /var/log/nginx/example.com.error.log
A 502 usually means Nginx cannot reach the local application or the application is failing.
Cloudflare returns 521
A 521 response commonly means Cloudflare reached the origin network but the web server refused the connection. Check that Nginx is active, the origin IP is correct, and the firewall accepts the intended web traffic:
systemctl is-active nginx sudo ss -lntp | grep -E ':80|:443' sudo ufw status numbered
Cloudflare returns 522
A 522 indicates an origin connection timeout. Check routing, firewall rules, current Cloudflare DNS content, and whether the VPS is reachable and has resource headroom.
sudo ufw status numbered sudo ss -lntp | grep -E ':80|:443' free -h uptime
Cloudflare returns 526
With Full (strict), a 526 means Cloudflare could not validate the certificate presented by the origin. Inspect hostname coverage, expiry, and Nginx configuration:
sudo certbot certificates sudo certbot renew --dry-run sudo nginx -t
HTTPS enters a redirect loop
Confirm Cloudflare uses Full (strict), not Flexible, then inspect the redirect chain:
curl -IL https://example.com
The app is reachable on public port 3000
Inspect the listener and firewall:
sudo ss -lntp | grep ':3000' sudo ufw status numbered
The application should bind to 127.0.0.1:3000. Remove an accidental public UFW rule only after identifying the exact numbered rule and confirming it is not used by another service.
DNS returns the VPS address instead of Cloudflare addresses
Check the web record's proxy status in Cloudflare. Proxied A, AAAA, and CNAME records return Cloudflare addresses; DNS-only records return the configured origin target directly.
dig +short example.com
Node.js stops starting after an nvm upgrade
Re-resolve the current binary:
export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" command -v node node --version
Compare that path with the service:
systemctl cat raff-node-app
Update ExecStart when the nvm-managed Node.js path changes, then run:
sudo systemctl daemon-reload sudo systemctl restart raff-node-app
Optional hardening — reduce direct origin exposure
For stronger origin protection, Cloudflare recommends blocking web traffic that does not come from Cloudflare IP ranges, or using an origin-protection design such as Cloudflare Tunnel where appropriate.
Review Cloudflare's current published ranges before making firewall changes:
curl -fsSL https://www.cloudflare.com/ips-v4 curl -fsSL https://www.cloudflare.com/ips-v6
Do not copy a static IP list from an old tutorial into a long-lived firewall policy. Cloudflare states that trusted prefixes need to be kept current.
If you choose to restrict ports 80 and 443 to Cloudflare ranges, add and verify every required allow rule before removing the broad Nginx web rule, keep SSH recovery access open, and rerun both public HTTPS and certbot renew --dry-run afterward.
For correct visitor IP logging, pair the trusted Cloudflare ranges with Nginx's real IP module and real_ip_header CF-Connecting-IP; do not trust CF-Connecting-IP from unrestricted direct origin traffic.
Cleanup and rollback
To take the application offline while keeping its files:
sudo systemctl disable --now raff-node-app
Disable the Nginx site:
sudo rm -f /etc/nginx/sites-enabled/example.com sudo nginx -t sudo systemctl reload nginx
If permanently decommissioning the hostname, back up required data first, then remove only the application files, systemd unit, Nginx configuration, certificate material, and Cloudflare DNS records that belong to this deployment.
Do not delete application data or certificates merely to troubleshoot a temporary proxy error.
Conclusion
You now have the production pattern for hosting an application on a VPS with Cloudflare and Nginx: Cloudflare proxies the public hostname, Nginx terminates the origin HTTPS connection, and the Node.js application stays private on 127.0.0.1:3000 under systemd.
The important security boundary is that Cloudflare proxying and private application ports solve different problems. The runtime port stays local by design, while stronger protection against direct origin access requires an additional origin-control decision such as a Cloudflare IP allowlist or Tunnel.
For related deployment work, continue with Install Node.js on Ubuntu 24.04, Install Nginx on Ubuntu 24.04, and Secure Nginx with Let's Encrypt on Ubuntu 24.04.
