vibeapp.win

How it works

One machine, many apps

Portal runs a park of small apps on one Windows machine: one shared tunnel, Cloudflare Access in front of everything, Start/Stop buttons and a Claude terminal in the browser. Here is how it works, and what broke on the way.

Most of the apps Henning and Claude have built run in Docker on a single Windows machine. In front of them sits Portal: a home page with one card per app, live container status, Start and Stop buttons, and a panel where you can run Claude Code in a real terminal from a browser, a phone included.

This is how that works, and what broke while it was being built.

The size of it

By early September Portal had about twenty apps behind Cloudflare Access: the changelog for version 1.27.1 changes the session length on “all twenty” Access applications at once. Git history starts on 12 August 2026 at version 1.11.0; before that the repository was not under git, so earlier versions exist only in the changelog. On 3 October there were 30 commits.

One tunnel, Access in front of everything

Each app used to have its own Cloudflare tunnel. On 11 August six apps were moved onto Portal’s shared tunnel, and tunnel connector containers went from nine to four. The removed ones had been holding connections to Cloudflare with no traffic, because no DNS record pointed at them anymore.

Every app sits behind Cloudflare Access, tied to a directory group. Portal sets up DNS, the tunnel route and the Access policy from one button, and always shows the plan before it writes anything. One detail matters more than it looks: Cloudflare’s API replaces the whole list of tunnel routes on every write, so Portal reads the list first and swaps only its own rule.

That button had a hole. With no group chosen, it silently skipped the Access step, wrote DNS and the route anyway, and reported success. One app ended up answering on the internet with nothing in front of it. Since 24 August Portal refuses to publish a hostname that has no Access application in front.

The page answered. The identity was gone.

When one app moved to the shared tunnel, the tunnel was pointed at the wrong of its two listeners. The app has two on purpose. One faces the local network and strips Cloudflare’s identity headers, so nobody on the LAN can forge them. The other sits inside the app’s Docker network, which only the tunnel can reach, and lets the headers through.

The tunnel hit the stripping listener. Pages loaded, Access asked for a login as usual, and the tunnel log was clean. But every user landed as the app’s default user, with that user’s write access. The app’s own diagnostic endpoint confirmed it, and the fix was to join the tunnel container to the app’s network. The rule: a redirect from outside “only says that Access answers.” Test that identity survives, not just that the page loads.

Start and Stop without giving Portal Docker

The Claude terminal in Portal can run with permission prompts turned off. That is why Portal itself never gets the Docker socket: whoever can talk to the socket is effectively root on the host.

  • Status comes from a small separate service that holds the socket, offers one read-only path, and copies out only the fields Portal needs. Docker’s raw answers contain far more than Portal needs to see.
  • Start, Restart, Stop and Log work by Portal dropping a request file in a folder. A watcher on the host picks it up and runs docker compose in the app’s folder. There are four fixed verbs, and the folder comes from the config, not from the request. Rebuilding is on by default; without it, Start quietly runs yesterday’s image.
  • The terminal container got a full build toolchain, which grew the image from 1.04 to 1.5 GB. Docker stays out of it on purpose.

Things that went wrong here: two watcher instances pulled the same job apart and reported “invalid JSON” on a file that was fine, so the watcher now takes a lock. Stop on Portal’s own card shut Portal down, and with it the button that would start it again. The only way back was physical access to the machine. The server now refuses that request. And the port picker once suggested a port held by a Windows service that neither the config nor Docker knew about, so Portal now probes the host first.

The login that cost a working day

Claude Code on the host and in the containers shared one subscription login. Refresh tokens rotate: when one instance renews, the others’ tokens become invalid and their credentials get blanked. The symptoms never looked like the cause. The terminal showed the login wizard, and apps that call claude -p just said “exited with code 1”. Henning lost a working day to it on 12–13 August.

The first fix, copying the host login into the containers, made it worse, because it spread one identity to several independent renewers. A watcher that synced from the host and kept backups helped. The changelog calls it “prevention, not a guarantee”. The real fix came on 14 August: a long-lived token from claude setup-token, which does not rotate, so the containers stopped touching the shared credentials. That command is interactive, and Henning had to run it himself.

Sessions that survive, until they don’t

Terminal sessions live on the server, so you can close the tab or reload and come back to a conversation that kept working. But they live inside the Portal container, and replacing that container ends all of them. The session notes now treat a rebuild as something to announce first.

Docs that nobody read

Portal writes documents into each app’s repository with that app’s actual values: how it is published, how it is built, and why it should call claude -p (subscription) instead of the SDK (billed per token). The documents existed but were not being read, because Claude Code reads CLAUDE.md in the repository root and does not look in docs/ by itself. On 16 August Portal started maintaining a pointer block in each app’s CLAUDE.md. Ten of eleven apps were missing it.

What the review found

On 11 August a review of the path from the internet inwards found that Access held the perimeter, but Portal itself accepted anyone who could reach it. Two attacks were demonstrated before the fix. An unrelated container on the same machine got an interactive Claude Code terminal over all the project folders. A web page on another origin got the same thing streamed into it in a real browser, because WebSockets are not covered by CORS. Now the browser’s Origin header has to match the host, and every write needs a token that is set when the page loads.

Some failures were in the verifying. In a changelog entry about copying text out of the terminal, Claude wrote: “three rounds of fixes before I measured”. Its added check could never confirm anything, because the browser denies clipboard reads without permission.