How to Host an App on a VPS with Cloudflare and Nginx

Aybars Altınyay
Aybars Altınyay
Platform Engineer & Technical Writer
IntermediateUpdated Oct 27, 202618 min read~45 minutes total
CloudflareVPSNginxNode.jsUbuntu 24.04Reverse ProxyDeployment
Last reviewed:
Tutorial
How to Host an App on a VPS with Cloudflare and Nginx
rafftechnologies.com/learn
On this page

Prerequisites

  • Ubuntu 24.04 Linux VM, non-root sudo user, domain active in Cloudflare, access to DNS and SSL/TLS settings, public IPv4, and a recovery path before firewall changes.

Don’t have a server yet?

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:

Text
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:

PlaceholderReplace with
your_server_ipYour VM public IPv4 address
example.comYour root domain
www.example.comYour www hostname
deployYour non-root deployment user
/opt/raff-node-appApplication directory on the VM

Step 1 — Prepare the Ubuntu 24.04 VPS

Connect to the VM:

Bash
ssh deploy@your_server_ip

Confirm the OS and available resources:

Bash
lsb_release -ds nproc free -h lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINTS

Install the packages used by the tutorial:

Bash
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:

Bash
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:

Bash
sudo ufw status verbose

Determine the actual SSH port from the effective OpenSSH configuration:

Bash
SSH_PORT="$(sudo sshd -T | awk '/^port / {print $2}')" echo "$SSH_PORT"

Allow the working SSH port and Nginx web traffic:

Bash
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:

Bash
sudo ufw enable

Check the rules again:

Bash
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:

Bash
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.7/install.sh \ -o /tmp/nvm-install.sh

Inspect it before execution:

Bash
sed -n '1,220p' /tmp/nvm-install.sh

Run the installer as the non-root deployment user:

Bash
bash /tmp/nvm-install.sh

Load nvm into the current shell:

Bash
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:

Bash
nvm install 24 nvm alias default 24 nvm use 24

Verify:

Bash
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:

Bash
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:

Bash
npm init -y npm install express

Create server.js:

Bash
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:

Bash
node server.js

From a second SSH session:

Bash
curl -i http://127.0.0.1:3000/health sudo ss -lntp | grep ':3000'

Expected health body:

Json
{"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:

Bash
NODE_BIN="$(command -v node)" echo "$NODE_BIN"

Create the service with that absolute path:

Bash
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:

Bash
sudo systemctl daemon-reload sudo systemctl enable --now raff-node-app

Check it:

Bash
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:

Bash
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:

Bash
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:

Bash
sudo nginx -t sudo systemctl enable --now nginx sudo systemctl reload nginx

Test locally with the intended Host header:

Bash
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:

TypeNameContentProxy statusTTL
A@your_server_ipProxiedAuto
CNAMEwwwexample.comProxiedAuto

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:

Bash
dig +short example.com dig +short www.example.com

Then test HTTP:

Bash
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:

Bash
sudo snap install --classic certbot sudo ln -sf /snap/bin/certbot /usr/local/bin/certbot

Request certificates and let Certbot update Nginx:

Bash
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:

Bash
sudo certbot certificates sudo certbot renew --dry-run sudo nginx -t

Test HTTPS:

Bash
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:

Text
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:

Bash
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:

Bash
sudo ss -lntp | grep ':3000' sudo ufw status numbered

The application should still listen on:

Text
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:

Bash
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:

Bash
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:

Bash
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:

Bash
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:

Bash
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:

Text
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:

Bash
sudo reboot

After reconnecting:

Bash
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:

Bash
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:

Bash
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:

Bash
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.

Bash
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:

Bash
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:

Bash
curl -IL https://example.com

The app is reachable on public port 3000

Inspect the listener and firewall:

Bash
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.

Bash
dig +short example.com

Node.js stops starting after an nvm upgrade

Re-resolve the current binary:

Bash
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:

Bash
systemctl cat raff-node-app

Update ExecStart when the nvm-managed Node.js path changes, then run:

Bash
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:

Bash
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:

Bash
sudo systemctl disable --now raff-node-app

Disable the Nginx site:

Bash
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.

Deploy a Raff Ubuntu Linux VM for your Node.js application behind Nginx and Cloudflare Full (strict) TLS. Patch Node.js, keep port 3000 private, and verify the origin is protected before production use.

Sources

  • Cloudflare DNS — Proxy status and proxy eligibility
  • Cloudflare SSL/TLS — Full (strict) mode requirements
  • Cloudflare Fundamentals — Origin protection and Cloudflare IP ranges
  • Cloudflare Support — CF-Connecting-IP and restoring visitor addresses
  • Node.js — October 2026 security releases
  • nvm — v0.40.7 installer used by this tutorial
  • Certbot — Nginx on Linux snap installation guidance
  • Cloudflare setup last documentation-reviewed: 2026-09-08; Node.js security release rechecked: 2026-10-27; no new VM retest claimed
Was this article helpful?
Aybars Altınyay
Aybars Altınyay
Platform Engineer & Technical Writer

Aybars is a Platform Engineer & Technical Writer at Raff Technologies. He works on Linux VM templates, self-hosted application workflows, and security-focused deployment validation, turning tested infrastructure procedures into clear, practical tutorials for developers and teams.

Put it into practice

Spin up a Raff server, follow the steps above on real infrastructure, and you are live. Linux or Windows, backed by a 14-day money-back guarantee.

Frequently asked questions

How do I host an app on a VPS with Cloudflare?
Run the application privately on the VPS, put Nginx in front on ports 80 and 443, point proxied Cloudflare DNS records at the VPS, install a valid origin certificate, select Full (strict), and verify the complete Cloudflare-to-Nginx-to-app path after reboot.
Does Cloudflare provide the VPS in this setup?
No. Cloudflare is the DNS, reverse-proxy, security, and edge layer in this architecture. The Linux VM still runs at a VPS or cloud provider such as Raff Technologies, while Cloudflare sits in front of the origin.
Should my Node.js app listen on 127.0.0.1 or 0.0.0.0?
Use `127.0.0.1` when Nginx runs on the same VM. This keeps the application runtime private while Nginx accepts public HTTP and HTTPS traffic.
Which Cloudflare SSL/TLS mode should I use for a VPS?
Use Full (strict) whenever possible. Cloudflare requires the origin to present an unexpired certificate issued by a publicly trusted CA or Cloudflare Origin CA with a hostname that matches the request.
Do I need to open port 3000 on the VPS?
No. Keep the application port private. Nginx reaches Node.js locally on `127.0.0.1:3000`, while visitors connect through Cloudflare and the origin's ports 80 and 443.
Does a proxied Cloudflare DNS record prevent all direct access to my origin?
No. Proxied DNS returns Cloudflare addresses instead of the origin IP, but an origin IP discovered through historical DNS or another service can still be contacted directly unless you also restrict origin traffic, use Cloudflare Tunnel, or apply another origin-protection control.
How do I preserve the real visitor IP behind Cloudflare?
Cloudflare sends the original client address in `CF-Connecting-IP`. Configure Nginx's real IP module only while trusting Cloudflare's published IP ranges, and keep those ranges updated; otherwise the header can be spoofed by direct requests to the origin.