The workspace moves off the config volume onto code-server-workspace, so
wiping editor state and wiping code are separate acts.
The image only ever chowns the literal path /config/workspace, and reads
DEFAULT_WORKSPACE to pick the folder to open, so a named volume on /workspace
would come up root-owned and unwritable. Creating the directory in a local
Dockerfile seeds the volume with the right ownership instead.
Compose materialises a configs: entry with inline content by writing it into
the container and refuses to do so on a read-only service: "cannot create
config ... : `file` is the sole supported option". The container was created
without /etc/alloy/config.alloy and the deployment failed at start.
Keeping the config inline matters more than the read-only rootfs, so the flag
and its tmpfs go. Every other control stays: no privileged, cap_drop ALL with
only DAC_OVERRIDE added, no-new-privileges, the socket proxy, and the limits.
Replace privileged: true with cap_drop ALL plus DAC_OVERRIDE, no-new-privileges,
a read-only rootfs and memory/pid limits. DAC_OVERRIDE is what lets the uid-0
entrypoint create its storage directory and read the journal and /rootfs; every
other capability stays dropped.
Route prometheus.exporter.cadvisor, discovery.docker and loki.source.docker
through a docker-socket-proxy sidecar on 127.0.0.1:2375 instead of bind-mounting
the socket. POST is refused there, so container create and exec are no longer
reachable. NETWORKS is granted because Docker SD resolves network names per
container and returns no targets without it.
Add an alloy validate step to CI and boot the test container with the shipped
capability set, read-only rootfs and proxy rather than --privileged.
openhands runs each agent session in a container it spawns through the host
docker socket, so the socket is mounted read-write and host.docker.internal is
resolved. No host workspace is exposed.
opencode-web serves the opencode agent as a browser UI. The vendor image ships
only the opencode binary on bare Alpine, so a local Dockerfile adds bash, git,
curl and an ssh client.
The base image ships /home/paseo owned by uid 1000 and declares it a
volume, so a fresh named volume is seeded with that ownership, and the
base entrypoint chowns it and the agent config directories when they are
not.
The image installs python, gh, glab, build-essential, sudo, zsh and nano
only; go or a jvm goes into $HOME from a terminal instead.
SHELL and TZ are written into compose.yml rather than read from .env, so
the deploying shell's own values can no longer win over them.
The entrypoint calls chpasswd, chown and gosu by absolute path, tolerates
a chpasswd failure instead of taking the start down with it, turns
globbing off around the agent loop, and counts an agent as installed only
when its binary actually runs.
environment: blocks were in no particular order. They now run must-have ->
should-have -> optional, with related variables kept adjacent as a group that
takes the tier of its most important member: PUID/PGID, PASSWORD with
SUDO_PASSWORD, DOCKER_MODS ahead of the INSTALL_PACKAGES and
NODEJS_MOD_VERSION that configure it, the four GIT_* entries, the PASEO_*
daemon settings.
Each .env.example is reordered to match its compose file. The names do not map
one to one -- PASSWORD feeds both PASSWORD and SUDO_PASSWORD, SERVICE_HOSTNAME
feeds HOST -- so an entry sits where the first compose entry reading it sits.
The HOST comment in both compose files is dropped; the READMEs already carry
that explanation in full. CLAUDE.md records the ordering convention.
alloy and gitea-mirror-local are untouched: every variable there is required,
so the tiers collapse and the existing grouping is the better one.
pnpm is not used anywhere -- npm is the package manager everywhere -- so the
mod that installs it is dead weight on every container start.
INSTALL_PACKAGES loses apache2-utils, bfs, ffmpeg, imagemagick, librsvg2-bin,
lsof, psmisc and ugrep, and gains glab. Each entry is re-resolved by apt on
every start, so the list is kept to what is actually reached for.
Record the convention in CLAUDE.md: .env.example is a template, so its values
stay generic and the real ones are set per deployment in Coolify or Dokploy.
Note the naming trap alongside it -- Compose interpolation reads the deploying
shell's environment before the .env file, so a variable must not collide with
one the shell already exports.
The alloy README's example host is made generic to match.
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().
The prompt read "0", the first dot-separated field of 0.0.0.0, even though the
container hostname itself was set correctly.
Pass the hostname in as HOST alongside the hostname: key, both from a single
SERVICE_HOSTNAME variable. Neither service reads HOST itself -- code-server
binds [::]:8443, Paseo binds PASEO_LISTEN -- so this only affects the prompt.
Not named HOSTNAME: Compose interpolation lets the deploying shell's
environment win over the .env file, and HOSTNAME is set in every container,
including the one Coolify runs in.
PASEO_LABEL is renamed to SERVICE_HOSTNAME; it was never a Paseo variable.
The example git identity is blanked out along with it.
SDKMAN goes into ~/.sdkman whenever that directory is missing, the same way
the agent CLIs arrive: as paseo, through gosu, onto the volume, where `sdk
install` can write and the candidates persist. No variable gates it.
The chown of /home/paseo moves out of the AGENT_CLIS guard, since SDKMAN now
needs it even when no agent is named, and the agent loop reads AGENT_CLIS into
a local first so `set -u` does not trip on it being unset.
SDKMAN_DIR is set in the image, and the current/bin of java, scala, gradle,
maven and sbt joins PATH: `sdk` itself is a shell function from the rc hook and
so exists in terminals only, while the daemon and an agent's non-interactive
commands read no rc file and need the binaries on PATH.
paseo-sudo-entrypoint was named for the one thing it used to do. It is
/usr/local/bin/entrypoint now, matching the source file.
Bind-mount /var/run/docker.sock so the universal-docker mod's CLI has a
daemon to talk to, and document the sibling-container and permission
caveats in the service README.
The entrypoint runs each vendor's installer through gosu paseo for every name
in AGENT_CLIS whose command is not already on PATH, so a fresh paseo-home
volume comes up with agents ready. Defaults to claude and codex; the other
four are opt-in.
On start rather than in the Dockerfile: Docker seeds a named volume from the
image once, at creation, so a build-time install into /home/paseo would only
ever reach a volume that did not exist yet. The check also chowns /home/paseo
first, since a freshly created volume can arrive owned by root and the base
entrypoint has not run its own chown by then.
Unknown names and failed installs are logged and skipped rather than taking
the container down with them.
entrypoint.sh loses its explanations to the README along the way.
The daemon probes each provider's binary with `which` in its own
environment, which comes from the image and never sources a shell rc, so
agents installed into $HOME were reported unavailable.
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.
Comments say what a section installs or configures. Why not the distro
package, why that directory, why a version is pinned — that belongs in the
service README, where it can be read in full.
Replaces the line under "Installing software in an image" that asked for the
opposite.
The image had no compiler at all -- gcc, g++, make, cc and ld were all
missing -- so cgo, npm's node-gyp addons and Python C extensions could
not build. Goes in the existing apt layer to keep a single apt-get
update.
Drop the history and roadmap asides: which services predate the collection's
conventions, the unwired Open Web UI plan, the generic clone-and-troubleshoot
boilerplate. Services that publish ports or set `restart:` now simply say so.
couchbase, openvpn-as and traffmonetizer had two-line READMEs; give them the
ports, variables and storage the root README promises. traffmonetizer reads
${TOKEN} and had no .env.example, so add one.
Six agents now ship in the image. All come from npm; omp additionally needs
Bun, since its bin is Bun-compiled and opens with `#!/usr/bin/env bun`.
BUN_INSTALL puts Bun in /usr/local, clear of the paseo-home volume.
GIT_NAME and GIT_EMAIL expand into the four GIT_AUTHOR_*/GIT_COMMITTER_*
variables, the same shape code-server already uses. Passing the identity as
environment rather than running `git config` means agents and terminals commit
correctly with no setup step, and it does not depend on ~/.gitconfig surviving
in the /home/paseo volume.
The base image installs no sudo and leaves paseo out of the sudo group, so
add both. The password cannot be baked in at build time -- it is a secret and
would land in a layer -- and it cannot be set after the base entrypoint, which
ends in `exec gosu paseo` and never returns. Wrap that entrypoint instead and
set the password while still root, on every start: /etc/shadow lives in the
image, not on the /home/paseo volume, so it reverts on each recreate.
Paseo spawns terminals with process.env.SHELL and falls back to /bin/sh,
which is dash. It never consults the login shell, so chsh has no effect --
and would be reverted by the next rebuild regardless.
System packages, Python, Go, gh and the agent CLIs each get their own
block. Ordered least-changing first, so a Claude Code bump no longer
re-runs the toolchain installs.
Go 1.26.8 from the official tarball and Python 3.12 via uv, since Debian
12 carries 1.19 and 3.11. Both land outside $HOME, which the paseo-home
volume would otherwise mask.
Hostname and timezone come from the environment rather than being fixed in
the compose file. Paseo uses the container hostname as the host label in
its web UI, so without one the UI shows a random container ID.
Debian does not package gh, so use GitHub's signed apt repo. Config
lands in /home/paseo/.config/gh, inside the paseo-home volume, so the
login survives a redeploy.
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.
The daemon trusts X-Forwarded-Proto from loopback only by default, but
Coolify's Traefik reaches it from the Docker bridge network. It therefore
reported the request as plain HTTP and handed the UI useTls: false, so the
UI built a ws:// URL on an https:// page. The browser blocked it as mixed
content and the UI fell back to its built-in localhost:6767 default.
uniquelocal covers the private ranges Docker uses. An exact CIDR is
tighter but Coolify assigns a fresh subnet per project.
The upstream image ships no agent CLIs, so build from a local Dockerfile
that layers Claude Code on top. npm delivers the same native binary as
the standalone installer, which cannot be used here: it writes to
$HOME/.local, and $HOME is /home/paseo, a volume mount that masks
anything baked in at build time.
Runs as root by design -- the entrypoint chowns the mounted volumes and
then drops to the unprivileged paseo user with gosu.
Set up the per-service layout: each service directory holds compose.yml
with a committed .env.example and a gitignored .env.
Add code-server as the first service, plus a README and CLAUDE.md
documenting that these files target Coolify/Dokploy and deliberately
omit ports and restart policies.