user@argobox:~/journal/2025-04-03-the-obsidian-container-that-wouldnt-connect
$ cat entry.md

The Obsidian Container That Wouldn't Connect

○ NOT REVIEWED

The Obsidian Container That Wouldn’t Connect

Date: 2025-04-03 Duration: About 30 minutes Issue: Obsidian container accessible internally, 502 externally Root Cause: XPRA configuration + WebSocket handling through Cloudflare tunnel


The Goal

Run Obsidian in a container. Access it from anywhere via browser. No local installation needed.

XPRA handles this — it streams X11 applications over HTML5. Wrap Obsidian in XPRA, expose port 8080, route through Cloudflare tunnel. Should be straightforward.


The Setup

The container was running. XPRA was active:

kubectl exec -it obsidian-xpra-pod -- ps aux | grep xpra
obsidian 7 /usr/bin/python3 /usr/bin/xpra start --bind-tcp=0.0.0.0:8080 --html=on --start-child=obsidian --no-sandbox --exit-with-children --daemon=no

The Kubernetes service mapped port 5800 externally to 8080 internally. The Cloudflare tunnel pointed obsidian.argobox.com to the service.

Internal test via port-forward: worked fine.

External access via Cloudflare: 502 Bad Gateway.


The Clue

The process list showed something unexpected:

obsidian 54 xmessage -center -file -

xmessage is X11’s way of displaying error dialogs. Something was showing an error message instead of actually running Obsidian. But the XPRA session was alive, and internally it worked.

The issue wasn’t Obsidian itself. It was how XPRA handled connections coming through the Cloudflare tunnel.


The Problem

XPRA’s HTML5 interface uses WebSockets. Cloudflare tunnels support WebSockets, but they need specific handling. The default XPRA configuration wasn’t playing nice with the tunnel’s connection proxying.

Also, the --start-child flag was subtly wrong. XPRA 3.x uses --start for some configurations. The distinction matters for how the child process inherits the display.


The Fix

Updated the Dockerfile CMD:

CMD xpra start --bind-tcp=0.0.0.0:8080 --html=on --html-timeout=0 --start=obsidian --no-sandbox --exit-with-children --daemon=no --auth=none --no-pulseaudio --notifications=no

Key changes:

  • --html-timeout=0 — prevents XPRA from timing out the HTML connection
  • --start=obsidian instead of --start-child=obsidian --no-sandbox — cleaner process spawning
  • --auth=none — removes authentication (Cloudflare handles access control)
  • --no-pulseaudio — audio isn’t needed and reduces complexity
  • --notifications=no — disables desktop notifications that can cause issues in containers

Rebuilt and redeployed:

docker build -t obsidian-xpra:latest .
docker save obsidian-xpra:latest | sudo ctr -n k8s.io images import -
kubectl rollout restart deployment obsidian-xpra

The Cloudflare Side

Also added WebSocket handling to the tunnel config:

- hostname: obsidian.argobox.com
  service: http://10.43.60.48:5800
  originRequest:
    noTLSVerify: true

The noTLSVerify: true is safe here because we’re connecting to an internal service. TLS terminates at Cloudflare; internal traffic doesn’t need it again.

Restarted the tunnel:

kubectl rollout restart deployment cloudflared

The Test

kubectl port-forward svc/obsidian-xpra-service 5800:5800
curl http://localhost:5800

Got HTML back. Internal: working.

Visited https://obsidian.argobox.com.

Got the XPRA HTML5 client. Obsidian rendered in the browser. Full application, accessible from anywhere.


Why Internal Worked But External Didn’t

Internal port-forwarding goes through kubectl, which handles the raw TCP connection directly. No proxying, no WebSocket negotiation, no tunnel.

External access goes through:

  1. Cloudflare edge
  2. Cloudflare tunnel
  3. cloudflared daemon
  4. Kubernetes service
  5. Pod

Each hop can interpret or modify the WebSocket connection. XPRA’s default settings weren’t tolerant enough of these intermediaries. The --html-timeout=0 and explicit flags made it more resilient.


The Result

Obsidian in a browser. Vault synced via the container’s volume mount. Accessible from any device with a browser and Cloudflare access.

Is it practical? Maybe not for everyday use — there’s latency. But for accessing notes from a phone or a machine where I can’t install Obsidian? Works fine.


What I Learned

XPRA flags matter. The difference between --start and --start-child isn’t just syntax. The process hierarchy affects how displays are attached.

WebSocket apps need tunnel awareness. Anything that relies on WebSockets through a reverse proxy or tunnel needs explicit timeout and connection handling.

Disable what you don’t need. Audio, notifications, authentication — each one is a potential failure point in a containerized environment. Strip it down to essentials.

Test through the full path. Internal port-forward testing doesn’t catch proxy issues. Always test the actual production path.


Obsidian in a container, streamed over the internet. Because sometimes “just install the app” isn’t an option.