The paseo user cannot write /usr/local, so a baked-in agent CLI can never apply its own update — every one of them ships an updater that expects to rewrite its own binary, and Claude Code nags about the failure at startup. Installed under $HOME they update themselves and still persist, since that is the paseo-home volume. The README now lists each vendor's installer. Bun goes with them: it was only there because omp is compiled against it. glab arrives as the .deb from GitLab's releases page, pinned by GLAB_VERSION because the URL carries the version. GitLab runs no apt repository and calls Homebrew its only officially supported Linux package manager. The Dockerfile drops its explanations along the way; they are in the README.
7.4 KiB
paseo
Paseo — self-hosted daemon and web UI for running coding
agents. Built from a local Dockerfile that adds gh, glab, Go, Python,
a C toolchain, and shell tooling to the
official image, which ships none of it. The
agent CLIs are not baked in — install them into $HOME yourself, see
Agents.
Setup
-
Set the variables from
.env.examplein Coolify or Dokploy. -
Point the domain at port
6767and deploy. -
Open the domain. At the pairing screen enter the host with the port, then the
PASEO_PASSWORDvalue:paseo.example.com:443 -
In a terminal inside Paseo, install and log in to the agents you use — see Agents — plus
gh auth loginandglab auth login.
The port must be typed by hand. The UI rejects a bare hostname, and the
auto-connect hint does not help: the daemon builds it from the Host header,
browsers drop the default :443, and the UI discards a hint with no port. What
you see instead is its localhost:6767 placeholder, which in a browser means
your own machine.
If it stays on localhost:6767 after you enter the address, clear site data —
the old entry is cached in localStorage.
Environment
| Variable | Purpose |
|---|---|
PASEO_PASSWORD |
Web UI and API login, and the paseo user's sudo password. 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. |
PASEO_LABEL |
Container hostname, shown as the host label in the UI. Without it the label is a random container ID. |
GIT_NAME / GIT_EMAIL |
Git author and committer identity for agents and terminals. |
TZ |
Timezone for logs and agent shells. |
SHELL |
Shell for Paseo's terminals. Paseo reads $SHELL and falls back to /bin/sh, ignoring the login shell, so chsh has no effect. |
PASEO_TRUSTED_PROXIES matches the proxy's source IP, so hostnames are
rejected. The daemon trusts X-Forwarded-Proto from loopback only, but
Coolify's Traefik reaches it from the Docker bridge network — so it 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, published nowhere — the platform maps the domain to it, so
localhost:6767 on the host refuses connections. See the
root README for why.
Agents
The image installs none of them. Run each vendor's own installer once, in a
terminal inside Paseo, as the paseo user — never with sudo:
| Agent | Command | Install with | Log in with |
|---|---|---|---|
| Claude Code | claude |
curl -fsSL https://claude.ai/install.sh | bash |
claude |
| Codex | codex |
curl -fsSL https://chatgpt.com/codex/install.sh | sh |
codex login |
| opencode | opencode |
curl -fsSL https://opencode.ai/install | bash |
opencode auth login |
| Copilot CLI | copilot |
curl -fsSL https://gh.io/copilot-install | bash |
/login inside copilot |
| Oh My Pi | omp |
curl -fsSL https://omp.sh/install | sh |
omp |
| Pi | pi |
curl -fsSL https://pi.dev/install.sh | sh |
pi |
Each lands in $HOME — ~/.local/bin for most, ~/.opencode/bin for
opencode — and appends that directory to your shell rc, so open a new terminal
afterwards. $HOME is the paseo-home volume, so both the binaries and the
logins survive a redeploy.
The installers pull in any runtime they need, into $HOME as well. omp is
the one to know about: it is Bun-compiled, and with no Bun on PATH its
installer takes the prebuilt binary. Ask for the source build (--source) and
it installs Bun to ~/.bun first.
Why not bake them into the image: paseo cannot write /usr/local, so a
preinstalled CLI can never apply its own update. Every one of these ships an
updater (claude update, codex update, omp update, …) that expects to
rewrite its own binary, and under /usr/local it fails on permissions — Claude
Code nags about it at startup. Installed in $HOME they update themselves, and
you are never waiting on an image rebuild for a release that shipped that
morning.
Run the installers unprivileged. Under sudo they target root's home and land
outside the volume; Claude Code's refuses outright.
Pi and Oh My Pi are separate projects sharing an ancestor; their commands do not collide.
Storage
| Volume | Mount | Holds |
|---|---|---|
paseo-home |
/home/paseo |
Daemon state, agent configs, credentials (.claude, .codex, .config/*) |
paseo-workspace |
/workspace |
Code the agents work on |
The agent CLIs, gh and glab all keep their config under /home/paseo, so
every login survives a redeploy — the base image points CLAUDE_CONFIG_DIR,
CODEX_HOME and the XDG_* variables into it. The agent binaries live there
too, since you install them yourself. Dotfiles as well, so a .zshrc or
oh-my-zsh install persists. Anything written outside $HOME (chsh,
apt install) is lost on rebuild.
Image
| Tool | Source | Why not apt |
|---|---|---|
gh |
GitHub's signed apt repo | Debian does not package it |
glab (GLAB_VERSION) |
The .deb on GitLab's releases page |
Debian does not package it, and GitLab runs no apt repo |
Go (GO_VERSION) |
Official go.dev tarball | Debian 12 ships 1.19 |
Python (PYTHON_VERSION) |
uv python install |
Debian 12 ships 3.11 |
less nano jq unzip zip lsof psmisc ugrep bfs zsh sudo |
apt | — |
build-essential |
apt | — |
Bump a pinned version with a build arg, e.g. --build-arg GO_VERSION=1.27.1.
uv itself is installed too. GLAB_VERSION is pinned rather than tracking the
latest because GitLab's download URL carries the version in the path.
The Dockerfile itself only says what each layer installs. The reasoning is
all here:
$HOMEis/home/paseo, a volume that masks anything the build writes there. Hence/opt/pythonand/usr/local/gorather than the defaults.- Do not add agent CLIs here, or a runtime only they need. Under
/usr/localthey cannot self-update; in$HOMEthey can, and they persist anyway. Bun used to be here forompand went the same way. See Agents. - One concern per layer, cheapest and least-changing first, so bumping a version rebuilds as little as possible.
build-essentialis the C toolchain the language layers assume but do not ship: cgo, npm's node-gyp addons and Python C extensions all shell out togccandmake. It rides along in the apt layer so there is oneapt-get update.gitandcurlare already in the base image.sudois not, despite Debian'sbase-passwdshipping an emptysudogroup, so the apt layer adds it and putspaseoin the group.- The image stays root: the entrypoint chowns the volumes, then drops to the
paseouser (uid 1000) withgosu. entrypoint.shsets thepaseopassword fromPASEO_PASSWORDon every start, while still root./etc/shadowis not on a volume, so it reverts on each recreate. An emptyPASEO_PASSWORDleaves the account locked andsudounusable.sudoresetsPATHto itssecure_path, which excludes/usr/local/go/bin. Usesudo env PATH="$PATH" go ...or the full path.