Lab Notes
Project

FREE ART — Network Architecture of a Gallery Kiosk

2026-09-24Cloudflare Workers · Tunnel · Tailscale · Raspberry Pi
projectcloudflaretunneltailscalewireguardtlsraspberry-piworkers

What it is

A generative art kiosk that runs unattended in a public gallery. A visitor walks up, a sensor notices, and the machine prints them an original image on thermal paper. Every piece is archived and retrievable later by unit number. Anyone on the internet can trigger the same pipeline from a browser and land in the same archive.

Live at whippingstar.net. Running since August 2026.

The interesting part isn't the art. It's that a Raspberry Pi in a museum, on wifi nobody controls, drives a GPU on a residential connection three miles away — with no inbound ports open on either end, no port forwarding, no static IP, and no VPN appliance.


The constraint that shaped everything

The render happens on a desktop with a discrete GPU. The kiosk is a Raspberry Pi. They are in different buildings on different networks, and the museum's network is not mine to configure — no firewall rules, no port forwards, no DHCP reservations, and the SSID can change without notice.

That rules out the obvious approaches:

Approach Why it fails here
Port forward to the desktop No control over the museum's edge; residential IP is dynamic
Expose the Pi's web service publicly Requires inbound access at the museum; none available
Site-to-site VPN Needs configuration on both edges
Cloud-host the model Cost, and the GPU already exists

Both problems — Pi→desktop, and internet→Pi — have the same shape: reach a host behind a NAT you don't control. The solution is the same in both cases, applied twice with different tools.


Architecture

                        ┌─────────────────────────────────┐
  visitor's browser ───▶│   Cloudflare edge               │
                        │   whippingstar.net    (attract) │
                        │   art.whippingstar.net (archive)│
                        │   gen.whippingstar.net (web UI) │
                        │   gen-api.whippingstar.net ─────┼──┐
                        └─────────────────────────────────┘  │
                                                             │ Cloudflare Tunnel
                                                             │ (outbound from Pi)
                        ┌────────────────────────────────────▼──┐
                        │  Raspberry Pi — gallery               │
                        │  kiosk app · generator API :8081      │
                        │  bound to 127.0.0.1 only              │
                        └────────────────┬──────────────────────┘
                                         │ Tailscale (WireGuard)
                        ┌────────────────▼──────────────────────┐
                        │  Windows desktop — home               │
                        │  ComfyUI :8188, FLUX + LoRA           │
                        └────────────────┬──────────────────────┘
                                         │
                        ┌────────────────▼──────────────────────┐
                        │  Cloudflare R2 — archive bucket       │
                        └───────────────────────────────────────┘

Three Cloudflare Workers serve the public surfaces. A fourth hostname is tunnel ingress. Neither origin accepts an inbound connection from anywhere.


Two tunnels, two jobs

Internet → Pi: Cloudflare Tunnel

cloudflared runs on the Pi and establishes an outbound connection to Cloudflare. Requests to gen-api.whippingstar.net arrive at the edge and are handed down that existing connection.

The origin's firewall needs no inbound rule, because there is no inbound connection. From the museum network's perspective the Pi is making ordinary outbound HTTPS — indistinguishable from any other client, and unaffected by whatever the network blocks.

The generator API binds 127.0.0.1:8081. Not 0.0.0.0. The tunnel is its only route in, which means the service is unreachable even from the museum's own LAN — a machine on the same wifi cannot connect to it. Access control by bind address, enforced before any application logic runs.

Pi → desktop: Tailscale

Tailscale builds a WireGuard mesh between the Pi and the desktop. Both dial out to a coordination service; neither listens. NAT traversal is handled for me, and the two hosts get stable addresses on a private range that persist across network changes, reboots, and the museum's DHCP doing whatever it likes.

The kiosk's own control endpoints bind the Tailscale address specifically — never 0.0.0.0:

curl <tailnet-ip>:8080/wake                       # wake it as if someone walked up
curl -d "YOU LOOK LONELY" http://<tailnet-ip>:8080/print

On museum wifi, binding 0.0.0.0 would expose the paper roll to the entire building. Anyone on the guest network could print. The bind address is the whole access control mechanism here, and it was a deliberate decision rather than a default.


Layered exposure

Each surface is exposed exactly as much as it needs to be, and no more:

Surface Reachable from Mechanism
Attract screen, archive, generator UI Public internet Cloudflare Workers, edge-cached
Generator API Public internet, via edge only Cloudflare Tunnel; loopback bind
Kiosk wake/print control Tailnet only Bound to tailnet address
ComfyUI Tailnet only Bound to tailnet address
Everything else on both hosts Nothing No listeners

There is no host on this system with a port open to the internet. The attack surface that remains is the Cloudflare edge and one HTTP API reachable through it.


Controls at the edge

Because every public request lands on a Worker first, the edge is where rate limiting and abuse handling live — not in application code on a Pi that can barely afford the cycles.

Per-IP rate limiting keys on the CF-Connecting-IP header, which is the real client address; the origin sees only the tunnel, so REMOTE_ADDR is useless for this. 120-second cooldown.

Refund on rejection. If the render queue is busy the request gets a 503 and the caller's rate-limit slot is returned. Otherwise a user who hit a busy backend would be penalized with a two-minute lockout for a request that was never served — punishing people for the system's state rather than their behavior.

Asynchronous by design. POST /generate returns 202 with a job id; the browser polls GET /status/<id>. A render takes a minute or more and holding an HTTP connection open that long across a tunnel is a good way to collect timeouts.


TLS: a certificate detail that cost real time

Cloudflare's universal wildcard certificate covers exactly one label. *.whippingstar.net matches gen.whippingstar.net. It does not match api.gen.whippingstar.net — that's two labels deep, and a wildcard is not recursive.

The failure mode is unhelpful: the TLS handshake fails outright. No HTTP status, no application error, nothing in any log that points at certificates. It reads like the tunnel is broken.

The fix is naming. The tunnel hostname is gen-api.whippingstar.net — one label, covered by the wildcard — rather than a nested subdomain. Advanced Certificate Manager would also cover it, but renaming is free.

This generalizes: if a service is unreachable over HTTPS and nothing is logging an error, suspect the layer below HTTP before you start reading application code.


Failure modes in the field

The tailnet drops and it looks like the GPU died. ComfyUI can be perfectly healthy on the desktop while the Pi can't reach it — museum wifi hiccups, the tunnel to Cloudflare survives because it's a different path, so the public site stays up while generation silently fails. The symptom appears at the wrong layer.

First render after a restart is minutes, not seconds. The model and LoRA load into VRAM on first use. The timeout is deliberately generous to cover exactly this; shortening it to something that "feels right" would break every cold start.

Errors that never reach the log. The generation worker puts failures on an in-app queue for the kiosk display, not stdout — so journalctl shows a clean log while renders are failing. The reliable signal is a gap between the unit counter and the newest file on disk: consumed numbers with nothing to show for them.

That last one is a monitoring lesson more than a networking one. A clean log is not evidence of a healthy system; it's evidence that nothing wrote to that log.


Deployment

Three Workers, deployed from the laptop with wrangler. Authentication is an API token in the shell profile rather than the interactive OAuth flow, which kept expiring mid-task and demanding a browser round-trip.

Custom domains are configured in each Worker's config, so Cloudflare provisions DNS and certificates on deploy — no manual records. Worth knowing: a custom domain route takes the bare hostname. gen.whippingstar.net/* is rejected; the wildcard-and-path form belongs to the older Routes model.


What it demonstrates

Built between June and September 2026. Runs unattended.