captain-arni.org

2 October 2026

Self-Hosting on a Raspberry Pi Without Opening a Port: A Cloudflare Tunnel and Access Guide

Everything on captain-arni.org runs on a Raspberry Pi at home: this site, its games, and a few private apps for my family. My router has no open ports, I don’t run a VPN, and none of the private apps has a login page I wrote myself.

This guide shows how to build the same setup, step by step, and ends with the gotchas that cost me the most time. You need a domain on Cloudflare (the free plan is enough) and a machine that is always on. A Raspberry Pi is ideal.

The idea in one picture

flowchart TB
    visitor(["Browser"])

    subgraph cf["Cloudflare"]
        access{"Access<br/>login for private<br/>apps and paths"}
        edge["Tunnel endpoint"]
    end

    subgraph home["At home: Raspberry Pi"]
        cloudflared["cloudflared<br/><i>connects outwards</i>"]
        nginx["nginx<br/>example.com"]
        site["Static site<br/>public"]
        private["One private path"]
        app1["App A<br/>127.0.0.1"]
        app2["App B<br/>127.0.0.1"]
    end

    visitor -- HTTPS --> access
    access -- allowed --> edge
    edge <-. "one outbound connection" .-> cloudflared
    cloudflared --> nginx
    nginx --> site
    nginx --> private
    cloudflared -- "a.example.com" --> app1
    cloudflared -- "b.example.com" --> app2

Three ideas carry the whole design:

  1. Nothing at home listens to the internet. cloudflared runs on the Pi and opens an outbound connection to Cloudflare. Requests for your hostnames come back down that connection. There is no port forwarding and no dynamic DNS, and your home IP address never appears in DNS.
  2. Private apps get a login you didn’t write. Cloudflare Access asks for a login at Cloudflare’s edge, so only allowed accounts ever reach the Pi. Your apps can skip authentication entirely, as long as the tunnel is the only way to reach them.
  3. One hostname per app. Each app gets its own subdomain and serves its frontend and API from one port. The tunnel routes straight to it, so there is no reverse proxy and no path rewriting. Only the bare domain, which hosts several things, needs nginx to split requests by path.

Step 1: Create the tunnel

In the Cloudflare dashboard, open Zero Trust and pick a team name if this is your first time. The free plan covers up to 50 users.

Then go to Networks → Tunnels (Cloudflare moves these menus around now and then), create a tunnel of type Cloudflared, and give it a name. The dashboard shows install commands for your platform. On Raspberry Pi OS that means adding Cloudflare’s apt repository, installing cloudflared, and running:

sudo cloudflared service install <token-from-the-dashboard>

This installs cloudflared as a service with your tunnel’s credentials. From now on you manage the tunnel’s routes in the dashboard, and cloudflared picks up changes within seconds, without a restart.

sudo systemctl status cloudflared     # should be active

Step 2: Put the login in place before anything is reachable

Do this before adding any route. If you add the route first, the app is on the internet without a login for as long as the gap lasts.

In Zero Trust → Access controls → Applications, add a self-hosted application:

  • Destinations: the hostnames of your private apps, for example a.example.com. You can also protect a single path on a hostname, like example.com/private. Protecting a path keeps the rest of that hostname public, which a hostname-level rule can’t do.
  • Policy: Allow, with an Emails rule listing the people who may use it.
  • Login method: the one-time PIN by email works out of the box. Google or GitHub login can be added later.

Put all your family apps in one application with one policy. Adding a new app is then just another destination, and adding a person is one edit for every app at once.

Step 3: Add the routes

Back in your tunnel, add a published application route per hostname:

Hostname Service
example.com http://localhost:80 (nginx)
a.example.com http://localhost:9001
b.example.com http://localhost:9002

Cloudflare creates the matching DNS records for you. If a record with that name already exists, the form refuses with “A DNS record with this name already exists”, even when the record points at the same tunnel. Delete the record and add the route again.

Now check every private hostname from outside, without cookies:

curl -s -o /dev/null -w '%{http_code}\n' https://a.example.com/
Answer Meaning
302 Healthy: a redirect to the Cloudflare login.
200 Not gated: the app answers without a login. Fix this first.
404 The tunnel has no route for this hostname.
502 The route exists, but nothing answers on that port.

Step 4: Make the tunnel the only way in

Since your apps have no login of their own, they must only be reachable through the tunnel. Bind every app to 127.0.0.1. An app on 0.0.0.0 is reachable, without a login, from every device on your home network.

ss -ltnp     # anything on 0.0.0.0 that shouldn't be?

Step 5: nginx for the bare domain

If the bare domain hosts several things, a small nginx config splits them by path:

server {
    listen 80;
    server_name _;

    # nginx only sees plain HTTP from the tunnel. Without this, redirects
    # (like /blog -> /blog/) would send browsers to http://.
    absolute_redirect off;

    # The public site: a folder of static files.
    location / {
        root /var/www/site;
        index index.html;
        try_files $uri $uri/ =404;
    }
    error_page 404 /404.html;

    # A private page, protected by an Access *path* rule (step 2).
    location /private/ {
        alias /var/www/private/;
        add_header Cache-Control "no-cache";
    }
}

Back up the live file before each edit, and run sudo nginx -t before sudo systemctl reload nginx.

Step 6: Keep the public side static

This site is built with Astro on my PC, and the Pi only receives a folder of HTML, CSS and images. There is no backend, no database, no forms and no cookies. Everything else on the Pi is behind a login; the public site is the one part strangers reach, so it should have the least to attack.

Static files also cache well. A single Cloudflare cache rule caches every file with a content hash in its name (Astro puts those under /_astro/) for a month at the edge and a year in the browser. A new build means new file names, so you never need to purge. HTML stays uncached, so a deploy shows up immediately.

To deploy without serving half-copied files, unpack the new build next to the live one and swap it in:

tar -C dist -czf - . | ssh pi '
  cd /var/www && sudo rm -rf site.new site.prev && sudo mkdir site.new &&
  sudo tar -xzf - -C site.new &&
  { [ -d site ] && sudo mv site site.prev || true; } && sudo mv site.new site'

The previous version stays as site.prev, so a rollback is two renames.

The gotchas

An expired session looks like success

Access sessions expire. When that happens, your app’s fetch() doesn’t fail. It gets Cloudflare’s login page, with status 200. A frontend that only checks res.ok then tries to parse HTML as JSON, or quietly shows an empty result.

const res = await fetch('/api/items');
if (!res.headers.get('content-type')?.includes('application/json')) {
  throw new Error('Your session has expired. Reload the page to sign in again.');
}

Subresources get a 503, not a redirect

A navigation without a valid session is redirected to the login. A subresource request is not: a font, image, script or fetch() from a gated host gets a 503.

sequenceDiagram
    participant B as Browser
    participant A as Access
    participant P as Pi
    B->>A: GET /private/ (navigation)
    A-->>B: 302 to the login
    Note over B,A: log in, cookie set
    B->>A: GET /private/ + cookie
    A->>P: forwarded
    P-->>B: 200 the page
    B->>A: GET font.woff2, no cookie
    A-->>B: 503, no redirect

For most pages this never happens, because the cookie is sent along. It matters for a page that must work when everything else is broken. My private page on the bare domain is one self-contained HTML file with its fonts and icon inlined as base64, so there is nothing to fetch separately.

PWA manifests need credentials

Chrome fetches a web app’s manifest by itself, without cookies. Access refuses it, and you get a bookmark with a letter icon instead of an installable app. One attribute fixes it:

<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">

Check it in the network panel, not with fetch() in the console: the console sends your cookie, and the browser’s own request doesn’t.

The local config file is ignored

With a dashboard-managed tunnel, the ingress: list in /etc/cloudflared/config.yml is ignored. If an old list is still in there, it looks like something to fix, but editing it changes nothing. Change routes in the dashboard.

What it costs

A Raspberry Pi, a domain, and Cloudflare’s free plan, which covers the tunnel, Access for a handful of people, a cache rule and cookieless analytics.

Checklist

  • Tunnel created, cloudflared running as a service on the Pi.
  • Access application with your private hostnames and paths, before the routes.
  • Routes added; every private hostname answers 302 from outside.
  • Every app binds 127.0.0.1.
  • nginx with absolute_redirect off, and nginx -t before every reload.
  • Public side static, hashed assets cached, HTML not.
  • Frontends check the content type of API responses, not just res.ok.