Run it in a container (Docker / Podman)¶
Containers are the recommended way to run pAInapple Code — they give you the isolation the security notes call for. This is a run mode, not a separate install: the usual path is to install with pipx on the host and then add --in-docker, so one host install manages as many sandboxed instances as you like. Option A below is that path; Options B and C exist for when you'd rather drive the runtime yourself.
The image ships Python 3.13, Node 20, git, and a baseline dev toolkit (ripgrep, fd, jq, tmux, vim, and more). The agent CLIs — @anthropic-ai/claude-code and @openai/codex — are not baked into the image; the entrypoint installs them from npm into the /data volume on first start, so the download happens under your own agreement with the vendor. That costs a few seconds once and needs npm-registry access on that first boot; see Agent CLIs below to change or skip it. Application state persists in named volumes; your project and an isolated Claude CLI home are bind-mounted from the host. The Dockerfile is OCI-compliant, so it works with Docker, Podman, nerdctl, or any other OCI runtime.
Option A — built-in container mode (pipx, no clone needed)¶
Docker is a run mode of the unified painapple CLI — no separate command group. Two shapes:
pipx install painapple-code
# The image is pulled automatically on the first containerized run.
# `painapple pull` re-fetches wrotek/painapple-code:latest and prints the
# version it landed on. `painapple pull rc` tracks the newest pre-release;
# `painapple pull v1.0.0` pins an exact one.
# Ad-hoc: sandbox the current directory, foreground, Ctrl-C stops
cd ~/code/my-project
painapple --in-docker
# Durable: a named docker-mode profile
painapple setup myapp # interactive TUI wizard — pick "Docker" as the run mode
painapple start myapp # detached (--restart unless-stopped), prints the login URL
The wizard has arrow-key menus, a browsable directory picker (type to filter, Left / Right to climb or enter folders), back navigation on every step, and a final review screen that jumps back into any section. It configures an isolated .claude home by default — the container doesn't share state with your host CLI — and offers to seed it from your host login once.
Manage a sandbox with the same verbs as any deployment: stop, restart, status, logs, password (reveal the login URL), plus the docker-only shell, claude-login, extract, and the scripted painapple profile get/set. Full reference: Profiles & container mode.
Named profiles are selected by name (painapple password myapp). The ad-hoc run has no name, so it takes the mode flag instead — painapple password --in-docker, painapple logs --in-docker — otherwise those verbs would report on your host deployment. The container's login page shows the exact command for its own deployment.
Docker and Podman are auto-detected (pick one — or a custom binary path — in painapple setup). Container mode is pull-only: building the image from source needs a repo checkout (Option C below).
Option B — raw docker run (one-liner, no clone needed)¶
# Podman won't create a missing bind-mount source (Docker will) — make it first:
mkdir -p "$HOME/.painapple-code/.claude"
docker run -d --name painapple-code \
-p 127.0.0.1:8765:8765 \
-v "$PWD:/workspace" \
-v "$HOME/.painapple-code/.claude:/home/app/.claude" \
-v painapple-data:/data \
wrotek/painapple-code:latest
# Bootstrap URL — the container hides credentials from its own logs, so
# read the derived API token from its config instead (?tkn= links never
# carry the password):
echo "https://localhost:8765/?tkn=$(docker exec painapple-code \
awk '/^api_token:/ {print $2}' /home/app/.config/painapple-code/config.yaml)"
Why https:// here, but http:// with painapple --in-docker
The server inside the container always binds 0.0.0.0 — it has to, or
the published port would have nothing to forward to. So it cannot tell
whether you published on loopback or on the LAN, and --tls auto
resolves to on: you get HTTPS with a self-signed certificate and a
one-time browser warning. That is the safe default for a command whose
reachability is unknown.
painapple --in-docker knows the publish interface, so it resolves TLS
on the host side and turns it off for a loopback-only run.
You can append --tls off to the docker run command for plain HTTP
on a loopback publish — but then remember to remove it if you ever widen
-p 127.0.0.1:… to another interface, or the token and chat traffic go
out unencrypted.
Image tags:
| Tag | Meaning |
|---|---|
:latest |
Newest stable release |
:vX.Y.Z |
Pinned to a specific release |
:edge |
Manual builds off main |
Published image and UIDs (Linux)
The published image bakes in USER_UID=1000, but it doesn't have to stay there: started through painapple (or with PAINAPPLE_UID/PAINAPPLE_GID set), the entrypoint re-stamps its app user to whoever owns your mounts and drops privileges before the server starts, and Podman gets the host user remapped straight onto that UID. So a pulled image works on any host UID without a rebuild. Building locally (Option C) skips the step entirely by baking your own UID in.
Option C — build from source with painapple-docker.sh (clone the repo)¶
The Bash wrapper is the build companion — local image builds, including personalized builds layered from your own devcontainer.json or Dockerfile. Running and managing the container is Option A's job (the unified CLI picks the locally-built image up automatically — it uses the same painapple-code:latest tag).
git clone https://github.com/wrotek/painapple-code.git
cd painapple-code
./painapple-docker.sh build # build the image locally (~2-3 min, once)
painapple --in-docker # then run it like any other sandbox
The wrapper auto-detects the OCI runtime (force this build with RUNTIME=docker / RUNTIME=podman) and passes your USER_UID/USER_GID build args automatically so bind mounts stay writable. The runtime side of the CLI auto-applies the Podman-specific flags (--userns=keep-id, SELinux :Z).
The RUNTIME environment variable is the wrapper's lever only. The unified CLI reads its runtime from config instead — set it globally with painapple setup, or per deployment with painapple profile set NAME RUNTIME=podman (a value of docker, podman, an absolute path to a custom binary, or empty to auto-detect).
To layer your own tooling on top of the base image:
# Layer Dev Container Features from a devcontainer.json
./painapple-docker.sh build --devcontainer ~/my-project/.devcontainer
# Or append your project's existing Dockerfile
./painapple-docker.sh build --dockerfile ~/my-project/Dockerfile
Open https://localhost:8765/ in a browser (self-signed certificate — accept the one-time warning). The first run generates an auth password — the container keeps it out of docker logs (they persist), so reveal the bootstrap URL with painapple password (pip CLI) or the docker exec … awk one-liner above, and open it once; the cookie keeps you logged in. See First run & login.
Manual Compose / Podman (no wrapper)¶
docker compose build --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g)
WORKSPACE=/absolute/path/to/your/project docker compose up
The WORKSPACE env var is required — compose refuses to start without it. For convenience, drop it into a .env file next to docker-compose.yml:
By default the compose file mounts ~/.painapple-code/claude-home/ as the container's .claude. To seed it with your existing login:
Podman is daemonless, rootless-by-default, and CLI-compatible with Docker:
podman build --build-arg USER_UID=$(id -u) -t painapple-code:latest .
podman run --rm -it --userns=keep-id \
-p 8765:8765 \
-v painapple-data:/data \
-v "$HOME/.painapple-code/claude-home:/home/app/.claude:Z" \
-v "/absolute/path/to/your/project:/workspace:Z" \
painapple-code:latest
Podman-specific flags:
--userns=keep-id— maps your host UID directly into the container. Without it, rootless Podman remaps UIDs through/etc/subuidand bind-mounted files appear asnobody. Note that plainkeep-idmaps your host user to its own id, which still isn't the image'sappuser — so unless your host UID is 1000 (or you built with--build-arg USER_UID=$(id -u), as above), use--userns=keep-id:uid=1000,gid=1000to land onappinstead.painapple startworks this out for you.:Zmount suffix — applies a private SELinux label so the container can read/write the bind mount. Required on Fedora/RHEL/CentOS/Rocky; a harmless no-op on Debian/Ubuntu/Arch. Use:z(lowercase) if the same volume is shared between containers.
Volumes¶
| Mount target in container | Purpose |
|---|---|
/data |
Server state (sessions, shadow DB, logs, presets, uploads) via PAINAPPLE_CODE_HOME=/data. Back up this one volume to back up everything. |
/home/app/.config/painapple-code |
Auth config — the generated password. Persist it so stop/start keeps the same login. |
/home/app/.claude |
Container-local Claude CLI state (OAuth, history). Defaults to an isolated host path so the container never writes into your host's ~/.claude. Mount $HOME/.claude instead to share state with your host CLI, or drop the mount and set ANTHROPIC_API_KEY for headless deploys. |
/workspace |
Required. Your project directory — where Claude reads and edits files. Mount a single repo or a parent directory of many. |
The workspace mount is mandatory
The entrypoint checks that a real host directory is mounted at /workspace and exits with a configuration error (exit code 78) if it isn't. Compose refuses to start with WORKSPACE unset. This catches the common "I forgot to mount my project" mistake.
Agent CLIs¶
The image deliberately ships no agent CLI. @anthropic-ai/claude-code is proprietary — its licence reads "© Anthropic PBC. All rights reserved" — and nothing in Anthropic's terms grants the right to redistribute it inside a published image. So the entrypoint installs it on first boot instead, which makes the download yours under your own agreement with Anthropic, exactly as npm i -g on your laptop would be. @openai/codex is Apache-2.0 and could legally have been baked in; it takes the same path so there's one mechanism rather than two.
They land in /data/npm-global, on the persistent volume, owned by the unprivileged app user — so only the very first start pays for it. This also means pulling a newer image no longer updates the CLIs; to upgrade one, run npm install -g --prefix /data/npm-global @anthropic-ai/claude-code@2 from the container's terminal.
| Variable | Effect |
|---|---|
PAINAPPLE_SKIP_AGENT_CLI=1 |
Install nothing — for a UI/terminal-only instance, or when you bring your own. |
PAINAPPLE_AGENT_CLIS |
Override the set: space-separated binary=npm-spec pairs. Default claude=@anthropic-ai/claude-code@2 codex=@openai/codex@latest. Drop one to skip it, or pin an exact version. |
PAINAPPLE_AGENT_CLI_PREFIX |
Install location. Default /data/npm-global. |
A CLI already on PATH is left alone, checked per binary — so a baked-in claude doesn't suppress the codex install. A failed install is not fatal: the server still starts and serves the UI, terminal, git panel and history; only prompting that provider fails, with an explanation in the container log.
Air-gapped hosts
Bake the CLIs into a derived image — the already-on-PATH check then leaves them alone:
Don't end a derived image with USER app: the entrypoint needs root to align UIDs before it drops privileges.
Authenticating the in-container Claude CLI¶
The claude subprocess inside the container needs to authenticate to Anthropic. Three paths:
- Isolated
.claudehome (default) — seed it once withcp ~/.claude/.credentials.json <claude-home>/(the setup wizard offers this), or runpainapple claude-login [NAME]to log in inside the container. - Share the host's
~/.claude— mount it directly. Easiest, but the container can mutate your host state. - API key — set
ANTHROPIC_API_KEYand remove the.claudemount. Cleanest for headless servers and CI.
Next step¶
Continue to First run & login to get the bootstrap URL and open your first session.