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.
As of September 8, 2026, Cloudflare recommends proxying A, AAAA, and CNAME records that serve web traffic and recommends Full (strict) SSL/TLS whenever possible. Node.js 24.20.0 LTS is the current v24 LTS release, and the Node.js download page currently references nvm 0.40.7 for Linux installation.
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 official Node.js download page currently lists v24.20.0 LTS and nvm 0.40.7.
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
On September 8, 2026, the official Node.js download page reports v24.20.0 with npm 11.19.0.
Verify: nvm should report 0.40.7, Node.js should be on the supported v24 LTS line, and npm should run successfully for the deployment user.
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.
