Skip to content

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:

# .env
WORKSPACE=/Users/me/code/some-repo
PAINAPPLE_HOST_PORT=18765   # optional: custom host port

By default the compose file mounts ~/.painapple-code/claude-home/ as the container's .claude. To seed it with your existing login:

mkdir -p ~/.painapple-code/claude-home
cp ~/.claude/.credentials.json ~/.painapple-code/claude-home/

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/subuid and bind-mounted files appear as nobody. Note that plain keep-id maps your host user to its own id, which still isn't the image's app user — 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=1000 to land on app instead. painapple start works this out for you.
  • :Z mount 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:

FROM wrotek/painapple-code
RUN npm install -g @anthropic-ai/claude-code@2 @openai/codex@latest

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:

  1. Isolated .claude home (default) — seed it once with cp ~/.claude/.credentials.json <claude-home>/ (the setup wizard offers this), or run painapple claude-login [NAME] to log in inside the container.
  2. Share the host's ~/.claude — mount it directly. Easiest, but the container can mutate your host state.
  3. API key — set ANTHROPIC_API_KEY and remove the .claude mount. Cleanest for headless servers and CI.

Next step

Continue to First run & login to get the bootstrap URL and open your first session.