Skip to main content
Back to Journal
user@argobox:~/journal/2025-04-03-the-obsidian-container-that-wouldnt-connect
$ cat entry.md

Obsidian Container Tunnel Integration

○ NOT REVIEWED

Obsidian Container Tunnel Integration

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, route it through the protected browser path, and keep local installation optional.


The Setup

The container was running. XPRA was active, and the Kubernetes service was reachable through an internal test path.

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 command to use the right XPRA startup mode for the containerized browser session.

Key changes:

  • HTML timeout disabled for long-running browser sessions
  • --start=obsidian instead of --start-child=obsidian --no-sandbox — cleaner process spawning
  • Authentication remains handled by the protected access layer in front of the tunnel
  • --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 adjusted the tunnel configuration so WebSocket traffic reached the internal service through the protected route without publishing raw internal addresses or ports.

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.