Two days of reverse-proxy work, a full Caddy stack with Let's Encrypt TLS and basic-auth in front of opencode web, all working. Then I realized I am not the right user for it. The actual mobile answer was already on my phone, and OpenWebUI quietly took over the other half of the use case.

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:

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:

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.

Illustration: I Built a Web UI for Mobile Coding. Termux Won Anyway.