Using a Raspberry Pi as a Server — [Part 2]

Publishing a rootless Nginx container through Cloudflare Tunnel.
devops
hosting
tutorial
Author

spriyansh29

Published

July 20, 2026

Where we left off

In Part 1, I turned a Raspberry Pi into a small containerised web server on my home network. Nginx runs in a rootless Podman container, with port 8081 on the Pi mapped to port 80 inside the container. Quadlet defines the service, systemd handles restarts, and lingering lets the user service run without an active SSH session.

That gives me a server that starts after a reboot, but it is still only available locally. In this part, I’ll publish it through Cloudflare Tunnel and connect it to a domain name.

The Pi will establish an outbound connection to Cloudflare. Visitors will reach Cloudflare over HTTPS, and requests will travel through the tunnel to Nginx. There is no router port forwarding involved.

flowchart LR
    visitor[Visitor] -->|HTTPS| edge[Cloudflare]
    subgraph pi[Raspberry Pi — rootless Podman]
        tunnel[cloudflared]
        nginx[Nginx]
        tunnel -->|HTTP port 80| nginx
    end
    edge -->|Through established tunnel| tunnel

    style visitor fill:#dbeafe,stroke:#2563eb,color:#172554
    style edge fill:#ffedd5,stroke:#ea580c,color:#7c2d12
    style tunnel fill:#fef3c7,stroke:#d97706,color:#78350f
    style nginx fill:#dcfce7,stroke:#16a34a,color:#14532d
    style pi fill:#f1f5f9,stroke:#64748b,color:#0f172a

    linkStyle default stroke:#64748b,stroke-width:2px

Both containers will share a Podman network. Once this is working, Nginx will no longer need a published host port. The website will still be public through Cloudflare.

Creating the container network

I’ll use the same Linux user, shuttle, and Quadlet directory as in Part 1. Since these are rootless containers, I run the Podman and systemctl --user commands as that user, without sudo.

mkdir -p ~/.config/containers/systemd

I then create ~/.config/containers/systemd/cloudflare.network with the following configuration:

[Unit]
Description=Network for Nginx and Cloudflare Tunnel

[Network]
NetworkName=cloudflare

Referencing Network=cloudflare.network in each container’s Quadlet makes systemd create the network before starting the container. The shared network also lets cloudflared resolve the Nginx container by its network alias. This behaviour is covered in the Quadlet documentation.

Preparing the website and Nginx configuration

Before changing the running container, I’ll prepare the directories that it will mount:

mkdir -p ~/.config/nginx/conf.d
mkdir -p ~/.config/nginx/sites/biodevops.in

Next, I copy the built website into ~/.config/nginx/sites/biodevops.in/, including index.html and any CSS, JavaScript, images, and other pages it needs. These files must be readable by Nginx inside the container.

I’m using biodevops.in for this setup. If you are following along, substitute your own domain in the directory names, Nginx configuration, and Cloudflare routes.

Choosing which site to serve

Nginx uses the request’s Host header to select a matching server_name. A default server handles requests that do not match a configured hostname. See how Nginx processes a request for the details.

I start with a simple fallback in ~/.config/nginx/conf.d/default.conf:

server {
    listen 80 default_server;
    server_name _;
    return 404;
}

I then add the configuration for my domain in ~/.config/nginx/conf.d/biodevops.in.conf:

server {
    listen 80;
    server_name biodevops.in www.biodevops.in blog.biodevops.in;

    root /var/www/biodevops.in;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

For now, this configuration serves the same files for all three hostnames. If I want to serve a separate blog, I can give it its own server block and document root. Since this is a static site, I return a 404 when a requested file does not exist.

Updating the Nginx Quadlet

With the files in place, I update ~/.config/containers/systemd/nginx.container to join the new network and mount the directories:

[Unit]
Description=Nginx Service

[Container]
Image=docker.io/library/nginx:alpine
ContainerName=nginx
Network=cloudflare.network
NetworkAlias=nginx
Volume=%h/.config/nginx/conf.d:/etc/nginx/conf.d:ro
Volume=%h/.config/nginx/sites:/var/www:ro

[Service]
TimeoutStartSec=120
Restart=always
RestartSec=3
MemoryMax=512M

[Install]
WantedBy=default.target

The two mounts give Nginx read-only access to its configuration and website files. %h expands to the user’s home directory. NetworkAlias=nginx gives the tunnel a stable name to contact on the shared network.

There is no PublishPort entry now, so the old address on port 8081 will stop working after the restart. The site will be available publicly once the tunnel and hostname route are configured.

After saving the Quadlet, I reload the unit definitions and restart Nginx to apply the network and mount changes:

systemctl --user daemon-reload
systemctl --user restart nginx.service
systemctl --user status nginx.service
podman exec nginx nginx -t

The configuration check should report that the syntax is OK and the test is successful. If the container cannot start, I can inspect its logs:

journalctl --user -u nginx.service -n 50 --no-pager

For later edits to the mounted Nginx configuration, I can test and reload it without recreating the container:

podman exec nginx nginx -t && podman exec nginx nginx -s reload

Setting up Cloudflare Tunnel

Connecting the domain

For this setup, I need the domain to use Cloudflare DNS. If it is not already configured there, the first step is to add the domain, review the imported DNS records, and update the nameservers at the registrar to the pair Cloudflare assigns. I need to preserve any existing mail and other service records, then wait for the zone to become active. Cloudflare’s full DNS setup guide covers this process, including DNSSEC considerations.

Creating the tunnel

Next, I open Networking → Tunnels in the Cloudflare dashboard and create a tunnel. The official setup guide covers obtaining its connector token. I’ll use that token to run the connector in Podman using Quadlet.

Storing the token as a Podman secret

The connector token authorises cloudflared to connect to my tunnel. I’ll store it as a Podman secret and mount it as a file inside the container. This keeps the token value out of the Quadlet, command arguments, and container environment.

With my connector token already stored in the shell variable tunnel_token, I create the secret on the Pi as the same user that runs the rootless containers, without sudo:

printf '%s' "$tunnel_token" | podman secret create cloudflare_token -
unset tunnel_token

printf passes the token to Podman through standard input without adding a newline and the final - tells Podman to read from that input. unset removes the temporary shell variable afterwards. Before running these commands, I need to make sure tunnel_token contains the connector token. I also keep shell tracing (set -x) disabled so the expanded value is not printed.

The secret belongs to this user’s Podman storage. It keeps credentials separate from configuration, but it is not an encrypted vault: the host and the account running Podman still need to be trusted. I keep the token out of the published website and its repository.

Mounting the secret in the connector

I can now create ~/.config/containers/systemd/cloudflare.container:

[Unit]
Description=Cloudflare Tunnel
Wants=nginx.service
After=nginx.service

[Container]
Image=docker.io/cloudflare/cloudflared:latest
ContainerName=cloudflare
Secret=cloudflare_token,type=mount,target=/run/secrets/cloudflare_token
Exec=tunnel --no-autoupdate run --token-file /run/secrets/cloudflare_token
Network=cloudflare.network

[Service]
TimeoutStartSec=120
Restart=always
RestartSec=3

[Install]
WantedBy=default.target

Secret= makes the existing Podman secret available at /run/secrets/cloudflare_token inside the container. The Exec= line passes only that path to cloudflared, which reads the token from the file. The secret must exist before the container starts, and its name must match exactly: cloudflare_token and cloudflare-token are different names.

I ran into a small mistake here: I initially used --token /run/secrets/cloudflare_token. The --token option expects the token value itself, so cloudflared tried to interpret the file path as a token and reported Provided Tunnel token is not valid. For a file path, I need --token-file, as shown above. This is worth checking before replacing the stored secret.

Cloudflared’s --token-file option requires version 2025.4.0 or later. See the Cloudflare run parameters and Quadlet secret documentation for the supported options.

After saving the file, I reload the unit definitions and start the connector. I use restart so the same commands also apply changes to an existing service:

systemctl --user daemon-reload
systemctl --user restart cloudflare.service
systemctl --user status cloudflare.service

The tunnel should appear as Healthy in Cloudflare. If it does not, I can check the connector logs:

journalctl --user -u cloudflare.service -n 50 --no-pager

On my Pi, this reported No journal files were found. I could read the entries from the system journal with elevated permissions, filtering for my user and service:

sudo journalctl _SYSTEMD_USER_UNIT=cloudflare.service \
  _UID="$(id -u)" --since "5 minutes ago" --no-pager

I use sudo here only to read the logs. Podman and systemctl --user still run as my regular user.

The [Install] sections tell Quadlet to arrange startup through the user’s default.target. With lingering already enabled in Part 1, both services can start at boot. There is no separate systemctl enable step for these generated services.

Using an environment variable instead

Cloudflared can also read the token from TUNNEL_TOKEN. To use that option, I would replace the Secret= and Exec= lines under [Container] with:

Secret=cloudflare_token,type=env,target=TUNNEL_TOKEN
Exec=tunnel --no-autoupdate run

Podman injects the same stored secret into the container’s environment, and cloudflared reads it automatically. There is no need to add $TUNNEL_TOKEN to Exec= or write the token into an Environment= setting. The same daemon-reload and restart commands apply this change.

Both options keep the value out of the Quadlet. I’m using the file mount for this setup because the application supports it and the credential does not need to be in its environment. The environment-variable configuration is an alternative, so I only need one set of these lines.

Routing the hostname to Nginx

In the tunnel’s Routes section, I add a Published application route with my public hostname and the service URL http://nginx:80.

For this example, the mappings are:

Public hostname Service URL
biodevops.in http://nginx:80
www.biodevops.in http://nginx:80
blog.biodevops.in http://nginx:80

I add only the hostnames I intend to publish, then check that their DNS records point to the tunnel and resolve any conflicting records for those names. They do not need to point to my home’s public IP address.

The service address is nginx:80 because the connector and web server share a container network. localhost inside the connector would refer to the connector itself. I leave the HTTP Host Header override unset so Nginx can match the visitor’s hostname. Cloudflare documents this mapping in its routing guide.

The HTTP hop here stays within the Pi’s container network. Visitors use HTTPS to Cloudflare, and the connection between Cloudflare and the tunnel connector is encrypted.

Verifying the complete setup

At this point, I can test the public URL in a browser. Using another connection, such as mobile data, helps confirm that the site is reachable from outside my home network. As a quick sanity check, I can also inspect the response headers:

curl -I https://biodevops.in

A successful response and the expected page confirm that DNS, the tunnel route, and Nginx are working together. If you are following along, use your own hostname in this check.

If something fails, I can narrow it down with a few checks:

  • Tunnel is offline: check cloudflare.service and its journal for token or connectivity errors.
  • 502 Bad Gateway: check that Nginx is running, both containers use the cloudflare network, and the route points to http://nginx:80.
  • 404 or the wrong site: check server_name, the hostname route, and the files under the mounted document root.

As in Part 1, the final check is whether the services recover after a reboot. When convenient, I can reboot the Pi, test the public URL, and then check both services:

systemctl --user status nginx.service cloudflare.service

With this configuration in place, I have a path from a public hostname to the rootless Nginx container, with systemd managing both services. It still depends on the home internet connection and power supply, but I no longer need a router port-forwarding rule to serve the website.


Have thoughts, questions, or suggestions? I’d love to hear them.