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 composein 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.