Publishing a Home Server Through a Cloudflare Tunnel

How a web app on a personal computer became reachable from the internet without opening a router port

Vishnu Vardhan ยท 11 October 2026

The goal. Let visitors reach an app on a personal computer at home, under a name such as kanbanassist.vishnuvardhan.io. The router stays unchanged.

The approach. cloudflared on the computer opens one outbound, encrypted connection to Cloudflare. Visitor requests return down that same connection. Nothing is forwarded on the router.

Public internet Cloudflare Personal computer Visitor's browser HTTPS Edge terminates HTTPS matches hostname Home router no inbound port open outbound session, opened by the computer cloudflared holds the outbound session Vite :4173 page GET / Spring :8090 API /api Ollama :11434, never routed Visitor requests ride the session that the computer opened.
The computer opens the only connection, outward. Visitor requests travel down that session to Vite, and /api continues to Spring. Ollama is never routed to the internet.

Why the router blocks visitors

Every device at home has a private address, such as 192.168.1.20. The router shares one public address among them. When the computer opens a connection, the router rewrites the source address and records the mapping. This is network address translation (NAT).

Take one request. The computer connects to 93.184.216.34 on port 443, the standard HTTPS port, from local port 51514. The router records that mapping. A reply that matches it returns to 192.168.1.20. A packet that matches no mapping is discarded, so a visitor's first packet never reaches the computer.

Visitor on the internet Home router no matching entry Personal computer request in dropped reply only A reply is delivered only when the router holds a matching entry.
An unsolicited request has no entry in the router's table, so the router discards it. The computer never sees it.

Why not port forwarding

The design had to leave the router unchanged, serve visitors by name over HTTPS, keep the model private, and serve the page and the API from one origin. Port forwarding sends one port from the router to the computer. It works, and it is the usual answer. But the computer then answers anyone who scans the address, needs a certificate for a domain name, and breaks when the address changes. Many residential providers also use carrier-grade NAT, which shares one address among many customers. Port forwarding cannot work on those connections.

A tunnel reverses who opens the connection. The computer calls out, and visitors ride that call.

Port forwarding Visitor Router Computer Tunnel Visitor Cloudflare Computer outbound session, opened by the computer
Port forwarding passes visitors through the router to the computer. A tunnel reverses who opens the connection, so the router only ever sees connections the computer itself started.
OptionRouter changeWorks for public visitorsMain cost
Port forwardingYesYesThe computer is exposed. Certificates and address changes need upkeep
Cloud hostingNoYesMonthly cost. The data and the model leave the home
Cloudflare TunnelNoYesTrust in Cloudflare. The computer must stay online

GitHub Pages could host the static page but not the API. The API would still need a tunnel, and the two would sit on different origins. Cloud hosting breaks the privacy requirement. The tunnel was the only option that met every requirement.

How the tunnel works

This setup uses two parts of Cloudflare Tunnel. The first is cloudflared, a connector program installed as a Windows service on the computer. The second is tunnel routing: a public hostname that Cloudflare maps to a local service. Here the route points the hostname at the Vite preview server on 127.0.0.1:4173. Vite forwards /api to Spring, so one route serves both.

cloudflared opens an encrypted session outward to Cloudflare. The router already tracks outbound connections, so it needs no change. When a visitor arrives, Cloudflare sends the request down the session the computer opened.

The session has three layers. TCP moves the bytes. TLS, the encryption behind HTTPS, protects them. A tunnel protocol carries many requests over one connection. The connector opens several such connections to different Cloudflare data centers, so one failing path does not take the site down. This article does not test failover.

The connector dialing outbound on port 7844 and opening several connections to data centers
The connector dials port 7844 and opens several connections at once.

Three systems, one job each

Three systems are involved. The edge is Cloudflare's server nearest each visitor. It receives every connection before the computer does.

SystemRoleWhat it had to do
CloudflarePublic entry pointTerminate HTTPS, match the hostname to a route, and send the request down the open session
WindowsRuns cloudflared as a service that survives rebootsRegistering the service requires administrator rights. No inbound firewall rule was added
Spectrum home routerNetwork address translation and connection trackingNothing changed. No port was forwarded

What one request looks like

Loading the page and sending a chat message share their first hops. They differ at Vite. Loading the page ends there. A chat message continues to Spring.

Loading the page Browser Cloudflare cloudflared Vite, port 4173 Sending a chat message Browser Cloudflare cloudflared Vite, port 4173 Spring, port 8090 /api
Both share the same hostname. The last hop is the only difference between them.

The page and the API share one hostname, so the browser treats the call to /api as same-origin. Browsers block cross-origin calls unless the server allows them through CORS, which needs matching headers on both sides. One hostname avoids that. The cost is that Vite's /api rule must be correct.

CNAME record pointing the public hostname to a tunnel-specific name
The CNAME record, a DNS entry, maps the public hostname to the tunnel. This example uses port 8080; this setup uses 4173.

Known gaps

A route is as wide as what sits behind it. The page route exposes the page and, through Vite, the API. A route straight to Spring would skip Vite's host check, which is why the setup keeps one route. Still open, in priority order:

  1. Bind Spring to 127.0.0.1, so only the local proxy can reach the API.
  2. Add Cloudflare Access before sharing the link widely.
  3. Rate-limit the chat. Each request uses the computer's CPU and GPU, and anyone with the link can send prompts.
  4. Replace the Vite preview server with a production reverse proxy such as Caddy. Vite's preview server is for a demo on one machine.
  5. Confirm the route service type. The dashboard shows https:// for a plain HTTP service. The public site works, but the setting should be checked before other services rely on it.
  6. Trace the TLS entry in the Spring log. Spring once logged a TLS handshake on its plain HTTP port. The source was not traced. The public site was not affected.

Setup

Each step ends with a check. Do not start the next step until it passes. Tokens and identifiers are masked in every screenshot.

Assumptions

  1. Pin the API to port 8090. Spring defaults to 8080, which other projects here already use. Two services on one port route requests to the wrong program with no error. The check is the startup log, which shows Tomcat on 8090.
    server:
      port: 8090
  2. Configure Vite's preview server. The preview block fixes the host and port, fails on a port conflict, and lists the public hostname in allowedHosts. Without that entry, Vite returns 403 for the public address.
    preview: {
      host: "127.0.0.1",
      port: 4173,
      strictPort: true,
      allowedHosts: ["kanbanassist.vishnuvardhan.io"],
    },
    vite.config.ts with the preview block and the /api forwarding rule
    The complete vite.config.ts. The /api rule forwards to Spring.
  3. Build and check locally. Run npm run build, then npm run preview. Open 127.0.0.1:4173 and send a chat message. If this fails, the problem is the app, not the tunnel.
    npm run build and npm run preview output with the chat answering on 127.0.0.1 port 4173
    The build, then the preview server on 127.0.0.1:4173.
  4. Confirm Spring listens. In PowerShell, run Test-NetConnection 127.0.0.1 -Port 8090. The result must show TcpTestSucceeded : True. Command Prompt does not have this command.
    PowerShell Test-NetConnection to port 8090 returning TcpTestSucceeded True
    Spring accepts connections on 8090.
  5. Install the connector. Create the tunnel in the dashboard and copy the Windows command. Unblock the downloaded installer in its Properties dialog. Run the service command from an elevated prompt. The token in that command is a password. Keep it out of screenshots, repositories and chats.
    Create a Tunnel page with the Windows install steps, command masked
    The dashboard's Windows steps.
    Create a Tunnel page with identifiers masked
    The same page at another point in the setup.
    Administrator Command Prompt reporting the Cloudflared service installed, with the token removed
    The elevated prompt reports the service installed.
  6. Verify the connector, then add one route. The tunnel should show Healthy with no routes. Add one published route: subdomain kanbanassist, domain vishnuvardhan.io, service HTTP, URL 127.0.0.1:4173.
    Tunnels list with the tunnel Healthy and no routes
    Healthy, with no routes. Visitors reach nothing yet.
    Add a route dialog with Published application selected
    A published application exposes a local service on a public hostname.
    Route fields with subdomain, domain, path, service type and URL
    The field layout. This example uses port 8080; this setup uses 127.0.0.1:4173.
    Tunnel overview with one replica and one route
    After saving, the overview shows one route.
  7. Test from outside. Open the public address on a device outside the home network and send a chat message. Testing from inside the house can hide problems a visitor would meet.
    Public address in a browser showing ranked results and an evidence panel
    The public address, answering through the tunnel.

The frontend build

The React build produces static files: index.html, JavaScript and CSS. Any web server can host them, and no Node.js process runs at runtime. The API address is relative, so /api works on any host that serves both parts. A client-side route such as /board exists only in the browser, so the server must return index.html for it. Otherwise a direct visit returns a 404.

React build output and notes on relative API paths and deep-link fallbacks
The build output. The main bundle is about 1.1 MB, which Vite reports as a warning. Code splitting has not been done yet.

Healthy does not mean reachable

Each symptom points to one layer. Read the source of a message before searching its text, and run the checks in order.

What you see or runWhere it comes fromWhat it means
Tunnel shows HealthyCloudflare dashboardThe session to Cloudflare is open
502 at the public addressCloudflareThe request arrived, but nothing listened at the target
403 at the public addressViteThe hostname is missing from the allowedHosts list
404 JSON from port 8090 at /SpringSpring is running. The API has no root page, so this is expected
ERR_CONNECTION_REFUSED on 4173The local browserNothing listens on 4173 on this machine. The tunnel is not involved
Test-NetConnection to port 8090 on this machinePowerShellTcpTestSucceeded : True means something listens on 8090
curl.exe -v sent to the local portCommand lineThe status line and headers the service returns
nslookup on the public hostname of your siteDNSCloudflare addresses, never the home network
Cloudflare Live logsCloudflare dashboardThe status codes visitors receive

What broke

Most failures were between components, not in the application.

SymptomCauseFix
Installer does nothingWindows blocks downloaded filesUnblock it in Properties
Access denied on service installA standard prompt has no administrator rightsRun Command Prompt as administrator
Test-NetConnection is not found in Command PromptTyped into Command PromptRun it in PowerShell
Route points at port 3000No process listens therePoint the route at 4173, and set strictPort so Vite never moves to another port
TcpTestSucceeded : False on 4173The preview server had stoppedRestart npm run preview
Form refuses to create the tunnelThe name field was emptyEnter a name
Testing on port 5173The development server is not what the tunnel servesTest the preview port, 4173
Create a Tunnel page with a required name error
The form will not continue until it has a tunnel name.

The 403 that names its own fix

Visitors saw a 403 at the public address. Vite produced it, not Cloudflare or Spring, and the message named the setting to change: preview.allowedHosts. Identify which component produced an error before searching for its text.

Response showing 403 Blocked request and naming preview.allowedHosts
Vite's 403. The message names the setting that needs to change.

The preview server that had stopped

The public chat failed, so the checks ran one hop at a time. Ping to the computer succeeded. The TCP test on 4173 failed. Port 8090 returned Spring's JSON 404 at the root, which showed Spring was running. The preview server had stopped. Restarting it fixed the problem.

Test-NetConnection to port 4173 with TcpTestSucceeded False
Ping succeeds and the TCP test fails. The computer responds, but nothing listens on 4173.
Browser reporting a refused connection to 127.0.0.1 port 4173
The local browser's refused connection.
curl refused on port 4173 and a JSON 404 from port 8090
Refused on 4173, while 8090 returns Spring's expected 404 at the root.

The extra route

During testing I created a second route to the API hostname. It sent requests straight to Spring, bypassing Vite's host check and the /api rule. The masked row below is that route. I deleted it. Review the Routes table after every change.

Routes table with the extra API route masked, and the public site route showing an https address
The Routes table. The masked row was the extra route. The remaining row shows an https:// service address for a plain HTTP server, which is covered in the known gaps.

A cause I did not prove

The chat stopped responding on the preview server. My first explanation, a missing proxy rule, was wrong. Vite already applies server.proxy when preview.proxy is not set. I then made the preview block explicit, and the chat works through the tunnel. I did not isolate which change fixed it, so I do not claim a cause.

Development server on localhost port 5173
The development server on 5173 is not what the tunnel serves.

Trade-offs

What it gives you. The router is untouched, and no inbound port is open. A changing home address does not break the site, because the connection is made outward. Cloudflare presents the certificate for the hostname, so nothing renews on the computer. Deleting the route stops public access at once. The setup used Cloudflare's free tier.

What it costs.

Takeaways

References