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=obsidianinstead 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:
- Cloudflare edge
- Cloudflare tunnel
cloudflareddaemon- Kubernetes service
- 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.