Self-Hosted Terminal Sharing on One Docker Box: the shell.online Relay Without a Cloudflare Account

Self-Hosted Terminal Sharing on One Docker Box: the shell.online Relay Without a Cloudflare Account

Cloudflare's status page logged an incident titled "Cloudflare Tunnel Availability Issues" that opened at 21:55 UTC on September 11 and was marked resolved at 08:57 UTC on September 12, roughly eleven hours later, with the impact rated minor and the affected component listed simply as Tunnel. Cloudflare Tunnel is a common way to reach a home lab or a dev box without opening an inbound port: the cloudflared daemon makes outbound-only connections to Cloudflare's network and traffic to your origin rides through that tunnel. It is a good design, and it has one obvious property. When the relay in the middle has a bad night, so does your access path. That is the honest framing for the feature shell.online shipped today in v0.12.2: self-hosted terminal sharing through a relay you run yourself, on an ordinary Docker host, with no Cloudflare account involved.

To be clear about what this post is not claiming: we have no evidence the hosted shell.online relay was affected by that incident, and Cloudflare's status page publishes no detail beyond the title, timing, and component. The point is narrower. A relay is a dependency. The other self-hosting path in the repo is our Cloudflare Workers build deployed under your own account, which still means Cloudflare. The standalone relay needs only Docker, a domain name, and two open ports.

What the relay does, and what it never sees

A quick recap for anyone new to the tool. You run one command, for example shell claude, and the CLI prints a URL, a password, and a QR code. Anyone holding both the URL and the password opens the same live terminal in a phone or desktop browser. The process and its pseudo-terminal stay on your machine. The relay's only job is to move frames between the CLI and the browser and to enforce access rules. By default the CLI encrypts every terminal frame before it leaves the machine and the browser decrypts it locally, so the relay carries ciphertext. Our security model page spells out what the hosted relay can still observe: frame type, encrypted size, timing, connection IP, access mode, the command label, and lifecycle events. Terminal contents are not on that list. The URL's #salt= fragment never reaches the relay either, because browsers do not send fragments, and the CLI and browser derive the same AES-256-GCM key locally from the salt and the password with PBKDF2-HMAC-SHA256 at 600,000 iterations.

The self-hosted relay does not change any of that. The docs put it plainly: terminal frames remain opaque to the relay when the CLI's default end-to-end encryption is used. What changes is who operates the box that sees the metadata, and whose uptime you depend on.

Self-hosted terminal sharing: bringing up the standalone relay

The standalone deployment lives in the standalone directory of the shell.online repository. It is a single Node.js service speaking WebSockets, keeping session metadata in a local state file, fronted by Caddy for TLS. The published image is ghcr.io/teoslayer/shell.online-relay, built for amd64 and arm64. Requirements from the self-hosting document: Docker Engine with Compose, a public domain, and ports 80 and 443. Point the domain's A or AAAA record at the host first, then:

git clone https://github.com/TeoSlayer/shell.online.git
cd shell.online/standalone

SHELL_ONLINE_PUBLIC_URL=https://relay.example.com \
SHELL_ONLINE_SITE=relay.example.com \
docker compose up -d --build

Two variables, two jobs. SHELL_ONLINE_PUBLIC_URL is the exact public origin the relay writes into share links and checks browser origins against. SHELL_ONLINE_SITE is the Caddy site address; giving it a hostname is what turns on automatic HTTPS. Caddy's documentation is specific about why the ports matter: the HTTP challenge needs port 80 reachable from outside, the TLS-ALPN challenge needs port 443, and Caddy enables both by default. Leave it at the default of http://localhost and Caddy serves plain HTTP, which is what the local smoke test in the docs relies on:

docker compose up -d --build
curl http://localhost/api/health

The compose file is worth reading before you trust it, and it is short. The relay container runs with a read-only root filesystem, drops all capabilities, sets no-new-privileges, runs as a non-root user created in the Dockerfile, and mounts a small tmpfs at /tmp. Caddy publishes 80, 443, and 443/udp and proxies to the relay on port 8080 through a four-line Caddyfile. The health check hits /api/health every thirty seconds, and Caddy waits for the relay to report healthy before it starts.

Pointing the CLI at your relay

You do not reinstall or rebuild the CLI. The CLI reference lists SHELL_ONLINE_SERVER as the default relay URL and a per-invocation --server flag that overrides it:

SHELL_ONLINE_SERVER=https://relay.example.com shell claude
shell --server https://relay.example.com claude

Everything else behaves as it does against the hosted service. The share URL now points at your domain, the browser page is served by your relay (the Docker build bundles the web client into the image), and read-only mode, --auto-close, --persistent, and the rest all work because the standalone server enforces the same protocol rules. The docs list them: authentication, same-origin browser policy, read-only mode, the input lease that stops two people typing over each other, viewer and frame and traffic limits, slow-client protection, the stable terminal grid, and task-bound expiry. One small tell that this was done properly rather than bolted on: the --no-e2ee output now refers to whatever relay you configured instead of assuming it is Cloudflare.

The access rule is unchanged too, and it needs saying every time. The URL plus the password is a bearer credential. Anyone who has both can watch the session, and unless you started it with --read-only, can type with the permissions of the wrapped process. Running your own relay does not soften that. What it does give you is a domain you control, so a leaked link at least points at infrastructure you can shut off, and shell password rotate followed by the session ID swaps the password and salt on a running session without killing the process.

What survives a relay restart, and what the state volume holds

The relay keeps a state file, by default at /var/lib/shell-online/relay.json inside the container, on a named volume called relay-state. According to the self-hosting docs it stores session metadata and host-token hashes so that a client started with --persistent can recover the same identity after the relay restarts. It does not store terminal contents, end-to-end encryption keys, or browser passwords. Back the volume up if you care about persistent links surviving a rebuild, and run exactly one relay replica per volume; this is a single-node deployment by design. Live sockets reconnect after a restart, which matches the behavior documented on our reliability page: the CLI and browser both reconnect with bounded backoff, and a temporary failure does not stop the wrapped process.

Two things the standalone relay does not include: the optional accounts app (organizations, session vault, browser-started sessions) and the hosted analytics dashboard. Accounts are a separate service that never participates in terminal transport, so a self-hosted relay works without them. If you want the org features against your own relay you deploy the app in the repo's app directory separately; it uses Firebase Authentication and PostgreSQL and is documented in its own README.

When this is the right call

Most people should keep using the hosted relay. It is already up, it needs no setup, and the encryption boundary is the same. Self-hosting makes sense in a few specific situations. Your compliance regime wants the relay's metadata (IPs, timing, command labels) inside your own perimeter. Your team already runs a Docker host with a domain and you would rather own one more small service than depend on someone else's status page. Or you were reminded this week that a tunnel provider can have an eleven-hour incident, and you want the remote path to a training box or an unattended coding agent to fail on your terms rather than theirs. In each of those cases the deployment is a clone, two environment variables, and a compose up, and the CLI you already have on every laptop and server keeps working with one variable changed.

For the background on how the browser link itself works, the earlier post on shell.online as a live terminal browser link covers the session model. We build shell.online at Pilot Protocol, it is MIT licensed, and the standalone server is one readable TypeScript file, so you can check every claim in this post against the source before you run it.

Run the relay where you want it

The hosted service works out of the box. When you need the relay on your own domain, the standalone Docker deployment is in the repo, and the CLI switches with one environment variable.

Try shell.online
About this article

Published by the Pilot Protocol team. Product claims are scoped to the availability labels and technical references linked in the article; deployment behavior can vary by version and environment.

How we publish · Suggest a correction · Technical references