A service README now describes only its own service: no links to other services or to the root, and no restating of the shared conventions that the root README and CLAUDE.md already carry. Each service is a separate Coolify app on a <service>/** watch path, so a cross-link made editing one service redeploy another. The rule is recorded at the root; the alloy compose comment now points at a heading that exists.
paseo
Paseo — self-hosted daemon and web UI for running coding
agents. Built from a local Dockerfile that adds gh, glab, Python, a C
toolchain, zsh and nano to the
official image, which ships none of it. The
agent CLIs are not baked in; entrypoint.sh installs them into $HOME on
start, see Agents.
Setup
-
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 -
List the agents you want in
AGENTS; the first start installs them. Log in to each — see Agents — plusgh 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. |
AGENTS |
Agent CLIs to install on start if missing, space- or comma-separated. Empty installs none. See Agents. |
SERVICE_HOSTNAME |
Container hostname, shown as the host label in the UI and in the shell prompt. Without it the label is a random container ID. |
GIT_NAME / GIT_EMAIL |
Git author and committer identity for agents and terminals. |
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.
SERVICE_HOSTNAME is used twice: as the container's hostname:, which is
where the daemon takes its host label from, and as the HOST variable inside
it. Coolify injects HOST=0.0.0.0 into every compose app, and zsh seeds $HOST
and the %m/%M prompt escapes from that variable rather than calling
gethostname() — so the prompt would read 0, the first dot-separated field
of 0.0.0.0. Paseo itself never reads HOST — it binds PASEO_LISTEN — so
overriding it only affects the prompt. bash is unaffected; its \h uses the
real hostname.
SHELL=/bin/zsh and TZ=Asia/Ho_Chi_Minh are written into compose.yml
directly rather than read from .env, which is why neither is in the table.
They are properties of this setup, not of whoever deploys it — and every
interactive shell exports SHELL, so a value routed through .env could be
replaced by the deploying terminal's own shell, handing Paseo's terminals a
path the image lacks. Paseo reads $SHELL for its terminals and falls back to
/bin/sh (dash) otherwise; it ignores the user's login shell, so chsh has no
effect. That is what pins zsh here.
Agents
The image installs none of them. entrypoint.sh does, on start, for every name
in AGENTS whose command does not already run. The default is
AGENTS=claude codex; the other four are opt-in.
| Agent | Name in AGENTS |
Installer it runs | Log in with |
|---|---|---|---|
| Claude Code | claude |
claude.ai/install.sh |
claude |
| Codex | codex |
chatgpt.com/codex/install.sh |
codex login |
| opencode | opencode |
opencode.ai/install |
opencode auth login |
| Copilot CLI | copilot |
gh.io/copilot-install |
/login inside copilot |
| Oh My Pi | omp |
omp.sh/install |
omp |
| Pi | pi |
pi.dev/install.sh |
pi |
Each vendor's own installer, run as paseo through gosu, landing in $HOME
— ~/.local/bin for most, ~/.opencode/bin for opencode. $HOME is the
paseo-home volume, so the binaries and the logins both survive a redeploy.
Pi and Oh My Pi are separate projects sharing an ancestor; their commands do
not collide.
First start. These are fat static binaries, a few hundred MB each — Claude
Code around 216 MB, omp around 208 MB — so the default two hold the daemon
back by a minute or more, and all six by several. Later starts skip everything
already present. An agent that fails to install, or a name that is not in the
table, is logged and skipped rather than taking the container with it, so a bad
release or a network blip cannot leave you without a shell.
Presence check. The start asks each command for --version and reinstalls
only on exit codes 126 and 127, the shell's own codes for a binary it could not
execute. A curl | bash cut short — a network drop, or the platform stopping
the container mid-download — leaves a truncated binary on the volume, which a
file-existence check would accept on every later start while the daemon
advertised an agent that fails on every call. An agent that runs but does not
understand --version exits with some other code and counts as present, so
nothing assumes all six support the flag.
Installing by hand. Run the installer in a terminal inside Paseo as
paseo — never under sudo, where it targets root's home and lands outside
the volume, and where Claude Code's refuses outright. Both install directories
are on the image's PATH, so the daemon picks an agent up as soon as its
installer finishes — no redeploy, no restart. 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 on start and not in the Dockerfile. paseo cannot write
/usr/local, so a CLI installed there can never apply its own update — every
one of these ships an updater (claude update, codex update, omp update,
…) that rewrites its own binary, and it fails on permissions; Claude Code nags
about it at startup. And a build-time install into /home/paseo would only
ever reach a new volume: Docker seeds a named volume from the image once, at
creation, and never again. Rebuild with a seventh agent added and nobody with
an existing paseo-home would get it. The start-time check fires on every
fresh volume, and the agents update themselves in place afterwards.
Storage
| Volume | Mount | Holds |
|---|---|---|
paseo-home |
/home/paseo |
Daemon state, agent CLIs and their configs and 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 — the
base image points CLAUDE_CONFIG_DIR, CODEX_HOME and the XDG_* variables
into it — so every login survives a redeploy, as do dotfiles such as a .zshrc
or an oh-my-zsh install. 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 |
Python (PYTHON_VERSION) |
uv python install |
Debian 12 ships 3.11 |
build-essential, sudo, zsh, nano |
apt | — |
Bump a pinned version with a build arg, e.g.
--build-arg PYTHON_VERSION=3.13. 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 and entrypoint.sh only say what each step does. The
reasoning:
- Python lives in
/opt/pythonrather than under$HOME, theuvdefault, because/home/paseois a volume that masks anything the build writes there. - No agent CLIs, and no runtime only they need (
omp's Bun). Under/usr/localthey cannot self-update; in$HOMEthey can, and they persist anyway. See Agents. build-essentialis the C toolchain npm's node-gyp addons and Python C extensions shell out to forgccandmake. No other language toolchain is in the image, because none is wanted often enough to pay for a rebuild — install Go, a JVM or anything else into$HOMEfrom a terminal, where it persists on thepaseo-homevolume like the agent CLIs do.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.
entrypoint.sh, installed as/usr/local/bin/entrypoint, runs before the base image'spaseo-docker-entrypointand then hands over to it: that one ends inexec gosu paseoand never returns, and every job here —chpasswd, andgosu paseofor the agent installs — needs root. - The
paseopassword is set on every start rather than at build, so it never lands in an image layer, and because/etc/shadowis in the image rather than on a volume and reverts on each recreate. It is piped, not passed as an argument, since arguments are visible inps;chpasswdsplits on the first colon, so a colon in the password is fine. A failure there is logged and the start continues, becausechpasswdrejects a multi-line value and underset -ethat would otherwise take the whole service down rather than just the password. An emptyPASEO_PASSWORDleaves the account locked andsudounusable. /home/paseois notchowned before the agent installs. The base image declares it a volume and ships it owned by uid 1000, so a fresh named volume is seeded with that ownership; the base entrypoint also chowns it, along withPASEO_HOME,CLAUDE_CONFIG_DIR,CODEX_HOMEand the XDG directories, whenever one of them is owned by root.chpasswdandgosuare called by absolute path. The agentPATHentries come first in the image'sPATH, including root's, and they live on a volume that anything running aspaseo— an agent, by design — can write to; a file planted there under one of those names would otherwise run as root on the next start.- Globbing is off (
set -f) around theAGENTSloop. The list is split unquoted, so a*in it would otherwise expand against/workspace, the working directory, and report every file in it as an unknown agent. - The agent
PATHentries areENVin the image, not a shell rc entry: the daemon probes for each provider's binary withwhichin its own environment, which never sources an rc file, and would otherwise report every self-installed agent as unavailable while a terminal ran it fine. They are spelled/home/paseo/...becauseENVonly expands variables theDockerfileitself set earlier.