I Built a Web UI for Mobile Coding. Termux Won Anyway.
I wanted to code from the couch. Not in the dramatic remote-engineering sense. Just a sometimes-thing: read a script on the tablet, kick off a small change, watch the agent grind, accept the diff. Two days later I had a working reverse-proxy stack with Let’s Encrypt TLS, basic-auth, automatic cert rotation, and a one-command password sync. And I still ended up using Termux over SSH like I should have from the start.
This is that story.
The first detour: community oc-web
The obvious-looking path was shuv1337/oc-web, a community fork that exposes the opencode TUI as a web app. I followed the README, got the container running, hit a session-handling bug (#143) that turned out to be load-bearing for how the auth secret got persisted. The shape of the fix was clear but it sat in PR limbo, and the workaround I wrote for myself felt fragile.
I dropped it. The next morning sst (the upstream opencode maintainer) had merged a first-party opencode web mode anyway. Single-origin, baked in, no fork.
The second detour: opencode web behind Caddy
opencode web --hostname 127.0.0.1 --port 8080 was the entire backend. The remote-access problem became a reverse-proxy problem. I had Tailscale running across all my devices already, and a Caddy instance for other services. So:
Phone (Tailscale) → https://<host>.<tailnet>.ts.net:8443
↓
Caddy (TLS + basic-auth, Tailscale-only bind 100.x.y.z)
↓
opencode web on 127.0.0.1:8080
The Caddyfile is short but every line is there for a reason:
<host>.<tailnet>.ts.net:8443 {
bind 100.x.y.z # Tailscale interface only, not 0.0.0.0
tls /data/secrets/opencode-tls/cert.pem /data/secrets/opencode-tls/key.pem
encode zstd gzip # 1.5 MB JS bundle over DERP relay is rough
protocols h1 h2 # no HTTP/3, QUIC over WireGuard is flaky
basicauth {
opencode {file.bcrypt /data/secrets/opencode_server_password}
}
reverse_proxy 127.0.0.1:8080
}
A few decisions worth surfacing:
No mTLS. I evaluated client certificates as the auth path. In theory it is the strongest option. In practice Firefox-on-Android and stock Chrome handle client certs unreliably enough that I would have spent more time debugging the auth than using the tool. Basic-auth over LE-TLS is the boring answer that works.
No HTTP/3. QUIC across Tailscale’s DERP relay (when you are not on the same physical network as your server) has been unstable enough in my testing that I pinned to h1/h2 explicitly. This is the same lesson from running things over Tor: protocols designed for direct internet paths get weird when they ride on top of overlay networks.
Tailscale cert for the LE cert. tailscale cert <host>.<tailnet>.ts.net gets a real Let’s Encrypt cert for the magic DNS name, no port-forwarding, no public exposure. It expires every 90 days, so I wrote opencode-cert-renew.service and a weekly timer that re-runs the cert command and reloads Caddy. The renew is root-free and never touches the systemd unit files.
One-command password rotation. Writing a new password into /data/secrets/opencode_server_password and running bash /data/scripts/ops/opencode-auth-sync.sh derives a fresh bcrypt hash, reloads Caddy, verifies HTTP 200 against the new credentials, and never logs the password. I do not hash by hand. I do not edit Caddyfiles by hand. The path from “I want to change my password” to “the password is changed and verified” is one shell command.
This all worked. The services have been running clean for two days. systemctl --user show opencode-mobile.service caddy-opencode.service reports NRestarts=0 on both. No OOMs, no crashes, no surprises.
So why am I writing about leaving it.
The realization
I am a hobbyist Linux user. I run a Sovereign AI grid because I want my data, my models, my pipeline. I am not a developer in the “I open a project, I want the full agent UI to drive a long coding session” sense. When I sit down at my desktop, the agent-in-an-editor pattern is fine: big screen, real keyboard, focus.
When I am on the couch with a tablet, that whole surface is too much. The opencode web UI is a faithful render of the TUI. That means panes, focus management, streaming output, scrollback, command palette. It is built for people who use it eight hours a day. For my “kick a small change off, look at the diff, say yes” use case it is more interface than I need.
The technical-stability question was answered (services stable, no crashes). What was not answered until I actually used it for a day was: do I want this much UI on a 10-inch screen with my thumbs.
The answer was no.
The Termux answer that was there all along
I have a Termux setup article on this same blog from earlier in the project. SSH from Termux to my server on port 2222, attach to a tmux session, run opencode in the terminal. The end. No reverse proxy, no TLS cert, no basic-auth, no bundle size, no protocol negotiation, no DERP relay round-trip per stream chunk.
I had built a sophisticated thing parallel to a simpler thing that already existed and was already documented and was actually better for me. The opencode TUI in a real terminal emulator is lighter, more responsive on a phone, and uses the keyboard pattern my fingers already know.
The Caddy stack stays installed. It is not wasted. There are scenarios where I might want a browser tab open with the full opencode interface, for instance from a borrowed device where I cannot install Termux. And the password-rotation and cert-renewal plumbing is now part of my standard ops toolkit and reusable. But it is not the daily driver.
The daily driver for mobile coding is Termux plus SSH plus opencode.
The other half of “mobile access”
While I was building the opencode web stack I kept catching myself wanting something else: not “kick off an agent session” but “ask Qwen a question about my own notes.” That is not a coding task. That is a chat-with-my-AI task. Different shape, different latency tolerance, different UI.
OpenWebUI had been running on :3000 since the SGLang days. It used to point at Mistral on port 30000. I had stopped logging into it once I switched the primary stack to Qwen3.6 because the dropdown still said “Mistral Small 4” and that confused me every time. The actual fix was an afternoon of pointing it at the right backend and building a custom model called “Sovereign Qwen” on top of it.
What hangs off Sovereign Qwen now:
- Backend. Qwen3.6-35B-A3B PrismaQuant 4.75-bit on
:30001, served by vLLM. Around 45 tokens per second on decode. - System prompt with
{{CURRENT_DATE}}. OpenWebUI substitutes this on every chat, which fixes the “Mai 2024” hallucination problem that surfaced when I started using a model whose training cutoff predates my actual deployment date. Qwen now knows what day it is. - Knowledge collection. A KB called
sovereign-kbwith ten cross-project markdown files (/data/projects/sovereign-kb/) feeds RAG. When I ask “what did I write about the EAGLE OOM issue”, it cites with sources from my own notes. - Web search. OpenWebUI’s native search, pointed at my already-running SearXNG on
:8888. I setBYPASS_WEB_SEARCH_WEB_LOADER=Trueto use snippets only instead of fetching every URL through the Tor egress (faster, and avoids opencode 0.8.12’s UnboundLocalError in the loader path). - MCP tools. The
sovereign-aiMCP server I run on Floki gets exposed as OpenAPI tools to OpenWebUI through amcpobridge running as a user systemd unit. Tools available:search_blog,get_article,list_tags,diagnose_sglang. So Qwen can answer “did I publish something about Voxtral last week” by actually querying my blog’s MCP server live.
This whole thing is light on the phone. A chat box. A response. A globe icon for web search. A tool icon for MCP. Done. The Sovereign Qwen custom model is the actual answer to “I want to ask my AI a question from the couch.”
Housekeeping that came out of the same week
While I was inside OpenWebUI for the Sovereign Qwen build, I noticed the container image was 7 weeks old. Watchtower is not watching this one (no label), so it does not auto-update. Routine update: backup /data/webui (970 MB tar plus a clean SQLite .backup dump with integrity check), pull ghcr.io/open-webui/open-webui:main (the new digest is sha256:74093dadc9c6…, 9 days old), recreate the container.
Nine Alembic migrations ran on first boot, all clean: tasks and summary columns on chat, automation tables, last-read tracking, pinned notes, shared chats with data migration, calendar tables, memory user-id index. No tracebacks, no migration failures. Two models, two knowledge collections, four chats, forty files, eighty-eight megabytes of vector embeddings, all intact in the bind mount.
In the same pass I caught a pre-existing port mismatch. The compose file still had OPENAI_API_BASE_URL=http://172.17.0.1:30000/v1, the old Mistral port, while Qwen lives on 30001. Sovereign Qwen worked anyway because the custom model overrides the base URL, but the default connection probe was logging a connection error every ten seconds. Changed 30000 to 30001, recreated, logs are clean.
That same change is now committed in the repo with an honest message (“compose: open-webui OPENAI_API_BASE_URL 30000→30001 (Qwen migration, Mistral port idled)”) and the cheatsheet is updated to reflect Qwen as primary and Mistral as standby instead of the other way around.
What stays where
So my mobile stack ended up shaped like this:
- Chat from the couch. OpenWebUI at
http://localhost:3000(via Tailscale for remote), Sovereign Qwen as the default model, RAG and web search and MCP tools attached. This is the daily driver for “what do I think about X.” - Code from the couch. Termux on the phone, SSH on port 2222 into the server, tmux, run
opencodein the terminal. This is the daily driver for “actually drive an agent.” - Code from a browser. opencode web behind Caddy at
https://<host>.<tailnet>.ts.net:8443. Installed, stable, available for the days I want it. Not the default path.
Three answers to three questions that turned out to be subtly different. I built the middle one, then realized the outer two were better fits for me. The work was not wasted. I learned things I needed to learn (Caddy with tailscale cert, password rotation patterns, why HTTP/3 over DERP is a bad time). And the Caddy plumbing is reusable for the next time I need to put a service behind LE-TLS and basic-auth on the tailnet.
The lesson
The honest mistake I made was not technical. It was assuming I needed a web UI because the most-visible community work was on web UIs. Everyone who blogs about opencode is blogging about the web mode, because that is the new thing. The CLI is the old thing, the stable thing, the thing that everyone has been using for years and stopped writing about.
As a non-developer running this stack for my own learning, the old thing was the right thing for me. The new thing is great and I built it well, but I am not the user it is optimized for.
This is going to keep happening. There are dozens of these in the self-hosted AI stack right now: agentic IDE plugins, vibe-coding wrappers, browser-based RAG playgrounds. They are all genuinely valuable for someone. The trap is assuming “valuable for someone” means “valuable for me.”
The next time I find myself two days into building a TLS-fronted reverse-proxy stack for a use case I have not used yet, I am going to stop and ask whether the simpler thing in my toolkit already covers it. The answer often was yes. I just had to be willing to admit I was not the right user for the more-impressive thing I had built.