docs(paseo): add setup steps and tighten the README

The pairing screen needs an explicit port, which is the least obvious
part of getting connected. Lead with the setup steps and cut the
explanation around them down to what a reader has to act on.
This commit is contained in:
tiennm99 committed 2026-09-16 15:49:30 +07:00
1 parent 5edb865597
commit 341469ff43
1 file changed
+52 -42
+52 -42
View File
@@ -1,66 +1,76 @@
# paseo
[Paseo](https://paseo.sh) — a self-hosted daemon and web UI for running coding
agents, from the [official image](https://paseo.sh/docs/docker).
[Paseo](https://paseo.sh) — self-hosted daemon and web UI for running coding
agents. Built from a local `Dockerfile` that adds Claude Code to the
[official image](https://paseo.sh/docs/docker), which ships no agent CLIs.
Built from a local `Dockerfile` rather than the upstream image directly: the
official image ships no provider CLIs, so this one layers Claude Code on top
via `npm install -g @anthropic-ai/claude-code`. Add other providers
(`@openai/codex`, `opencode-ai`) to that same line if you need them.
## Setup
npm installs the same native binary as Anthropic's standalone installer, so
there is nothing to gain by switching. The `curl | bash` installer is in fact
the wrong choice here — it writes to `$HOME/.local`, and `$HOME` is
`/home/paseo`, a volume mount that hides anything baked in at build time.
1. Set the three variables from `.env.example` in Coolify or Dokploy.
2. Point the domain at port `6767` and deploy.
3. Open the domain. At the pairing screen enter the host **with the port**:
Claude Code cannot auto-update, since `paseo` can't write `/usr/local`; expect
a one-time notice at startup. Rebuild the image to pick up a new version.
```
paseo.example.com:443
```
The image intentionally keeps running as root — its entrypoint chowns the
mounted volumes and then drops to the unprivileged `paseo` user with `gosu`.
Then the `PASEO_PASSWORD` value.
Coolify and Dokploy build the image themselves from the compose `build:`
stanza; there is nothing to push.
The port is required — the UI rejects a bare hostname. You must type the
address yourself: the daemon builds its auto-connect hint from the `Host`
header, browsers drop the default `:443`, and the UI discards a hint with no
port. It then shows its built-in `localhost:6767` placeholder, which in a
browser means your own machine.
Still stuck on `localhost:6767` after entering the address? Clear site data —
the old entry is cached in `localStorage`.
## Environment
| Variable | Purpose |
| --- | --- |
| `PASEO_PASSWORD` | Auth for the daemon API and WebSocket |
| `PASEO_HOSTNAMES` | Comma-separated DNS names allowed to reach the daemon, e.g. `paseo.example.com,.lan`. IPs and localhost always pass. |
| `PASEO_TRUSTED_PROXIES` | Proxy addresses whose `X-Forwarded-*` headers the daemon believes. Required behind a TLS-terminating proxy — see below. |
| `PASEO_PASSWORD` | Web UI and API login. Generate with `openssl rand -base64 24`. |
| `PASEO_HOSTNAMES` | Domains allowed to reach the daemon, comma-separated. Your domain must be listed. |
| `PASEO_TRUSTED_PROXIES` | Set to `uniquelocal`, or the UI loads but never connects. |
Generate a password with `openssl rand -base64 24`. The proxied domain must
appear in `PASEO_HOSTNAMES` or requests are rejected.
`PASEO_TRUSTED_PROXIES` matches the *source IP* of the proxy, so hostnames are
rejected. By default the daemon believes `X-Forwarded-Proto` only from
loopback, but Coolify's Traefik reaches it from the Docker bridge network. It
therefore reads the request as plain HTTP, tells the UI to use `ws://` on an
`https://` page, and the browser blocks that as mixed content. `uniquelocal`
covers the private ranges Docker uses; an exact CIDR works too, but Coolify
assigns a fresh subnet per project.
## Networking
Listens on `6767`. No ports are published — point the domain at that port in
Coolify or Dokploy. See the [root README](../README.md) for why. `localhost:6767`
on the host will refuse connections; reach the daemon through its domain.
`PASEO_TRUSTED_PROXIES` must be set, or the web UI loads but never connects.
The daemon trusts `X-Forwarded-Proto` from loopback only by default. Coolify's
Traefik reaches it from the Docker bridge network instead, so the daemon
concludes the request was plain HTTP and hands the UI `useTls: false`. The UI
then builds a `ws://` URL from an `https://` page, the browser blocks it as
mixed content, and the UI falls back to its built-in `localhost:6767` default —
which in a browser means the viewer's own machine, not the server.
`uniquelocal` covers the private ranges Docker uses. A specific CIDR works too,
but Coolify assigns a fresh subnet per project, so it will not survive a move.
Listens on `6767`, published nowhere — the platform maps the domain to it, so
`localhost:6767` on the host refuses connections. See the
[root README](../README.md) for why.
## Storage
Two named volumes:
| Volume | Mount | Holds |
| --- | --- | --- |
| `paseo-home` | `/home/paseo` | Daemon state, agent configs, credentials (`.codex`, `.claude`) |
| `paseo-home` | `/home/paseo` | Daemon state, agent configs, credentials (`.claude`, `.codex`) |
| `paseo-workspace` | `/workspace` | Code the agents work on |
Claude Code's own config lives under `/home/paseo/.claude`, so it persists in
`paseo-home` — credentials survive a redeploy.
Claude Code's config lives in `/home/paseo/.claude`, so logins survive a
redeploy.
The daemon runs as uid/gid `1000:1000`; anything bind-mounted in its place must
be writable by that user.
## Image
Add other providers to the `npm install` line in the `Dockerfile`:
```dockerfile
RUN npm install -g @anthropic-ai/claude-code @openai/codex opencode-ai
```
Notes for anyone tempted to change it:
- npm installs the same native binary as Anthropic's standalone installer.
Don't swap in `curl | bash` — it writes to `$HOME/.local`, and `$HOME` is
`/home/paseo`, a volume mount that hides anything baked in at build time.
- Claude Code can't auto-update (`paseo` can't write `/usr/local`), so it shows
a notice at startup. Rebuild to update.
- The image stays root on purpose: the entrypoint chowns the volumes, then
drops to the `paseo` user (uid 1000) with `gosu`.