Run Claude Code in a rootless Podman container: read-only inputs, one writable output, persistent named instances
  • Python 72.1%
  • Shell 11.3%
  • Roff 10.4%
  • Dockerfile 6.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ole-Morten Duesund f300185fd2
All checks were successful
CI / check (push) Successful in 24s
CI / release (push) Has been skipped
CI / check (release) Successful in 26s
CI / release (release) Successful in 1m28s
Release 0.2.7
/dev/net/tun for nested Podman, so nested containers can have their own
network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTL9C42xNgsvD5j85k15fD
2026-10-19 18:38:29 +02:00
.forgejo/workflows Fix the image revision label on release builds 2026-10-19 14:45:01 +02:00
packaging Release 0.2.7 2026-10-19 18:38:29 +02:00
src/claudia Give nested Podman /dev/net/tun 2026-10-19 18:37:04 +02:00
tests Give nested Podman /dev/net/tun 2026-10-19 18:37:04 +02:00
.gitignore Add claudia CLI, container images and tests 2026-10-19 14:00:44 +02:00
LICENSE Add claudia CLI, container images and tests 2026-10-19 14:00:44 +02:00
pyproject.toml Release 0.2.7 2026-10-19 18:38:29 +02:00
README.md Give nested Podman /dev/net/tun 2026-10-19 18:37:04 +02:00
uv.lock Release 0.2.7 2026-10-19 18:38:29 +02:00

Claudia

Claudia runs Claude Code in a rootless Podman container where Claude has full freedom (--dangerously-skip-permissions, passwordless sudo), but can only:

  • read the input directories you give it,
  • write to one output directory, which is how results come back out,
  • keep its own state (login, settings, history, auto-updated Claude Code) in a per-instance home that survives between runs.

Instances are named. The first run of an instance asks you to log in to Claude. Later runs of the same instance don't.

Install

Requires Podman (rootless) and Python ≥ 3.11 on the host.

# as a uv tool
uv tool install git+https://kode.naiv.no/olemd/claudia

# or the Debian package from the Forgejo registry
sudo curl https://kode.naiv.no/api/packages/olemd/debian/repository.key \
  -o /etc/apt/keyrings/forgejo-olemd.asc
sudo tee /etc/apt/sources.list.d/claudia.sources > /dev/null <<'SOURCES'
Types: deb
URIs: https://kode.naiv.no/api/packages/olemd/debian/
Suites: trixie
Components: main
Signed-By: /etc/apt/keyrings/forgejo-olemd.asc
Architectures: all
SOURCES
sudo apt update && sudo apt install claudia

The registry only has Architectures: all, because Claudia is a pure Python package. With Architectures: all in the sources file, apt doesn't look for amd64/i386 indexes there, so it doesn't print "Skipping acquire" notices.

The Debian package installs the man page (man claudia) and bash completion. With the uv tool, enable completion yourself:

mkdir -p ~/.local/share/bash-completion/completions
claudia completion bash > ~/.local/share/bash-completion/completions/claudia

The .deb, wheel and sdist are also attached to every release.

Usage

# First run: -o is required. Inputs (-r) are optional and repeatable.
claudia run report -r ~/docs/specs -r ~/src/project -o ~/work/report-out

# Later runs remember the mounts; any -r/-o given replaces the remembered value.
claudia run report

# Forget one input (by basename or host path), or all of them.
claudia run report --remove-ro specs
claudia run report --clear-ro

# Headless: run one task and exit (the instance must be logged in already).
claudia run report -p "Summarise the specs into summary.md"

# Restrict egress to Anthropic's hosts (plus any you allow). Remembered;
# --no-restricted switches back.
claudia run report --restricted --allow-domain pypi.org

# Nested Podman (build and run containers inside) is on by default;
# --no-nested turns it off for the instance, --nested back on.
claudia run report --no-nested

# Use your own LLM instead of Anthropic (remembered; --no-llm switches back).
claudia run local --llm http://localhost:11434 --llm-model qwen3.6:latest

claudia list            # all instances (--names: names only)
claudia show report     # mounts, network mode, paths
claudia rm report       # deletes login/state; never touches input or output
claudia run report --dry-run   # print the podman command(s) instead
claudia build           # build the images locally
claudia completion bash # print the bash completion script

Inside the container:

Path What Mode
/work/input/<basename> each -r DIR read-only
/work/output -o DIR, the working directory read-write
/home/claudia the instance's persistent home read-write

The user is claudia (uid 1000), mapped to your host user, so files Claude writes to the output directory are owned by you. Files it creates with sudo are owned by a subordinate uid instead. Remove those with podman unshare rm.

Nested containers

Claude can use podman build and podman run inside the container, as the rootless user claudia. Nested images and containers are stored in the instance home, so they survive between runs and are deleted by claudia rm. Nested containers share the outer container's network, so --restricted applies to them too. To pull from Docker Hub in restricted mode, allow registry-1.docker.io, auth.docker.io and production.cloudfront.docker.com.

This needs three extra options on the outer container, and all are left out with --no-nested:

  • --cap-add SYS_ADMIN lets newuidmap create the nested user namespace. The capability is scoped to the rootless user namespace and grants nothing on the host. It does widen the kernel attack surface.
  • --security-opt unmask=/proc/* lets the nested runtime mount a fresh /proc, which the kernel refuses while paths in /proc are masked.
  • --device /dev/net/tun lets pasta give a nested container its own network (--network pasta, rootless bridge networks). The tap devices are created in the outer container's network namespace.

No --privileged or seccomp=unconfined is used.

Own LLM

--llm URL points an instance at your own model server instead of Anthropic. The server must speak the Anthropic Messages API (POST /v1/messages), as Ollama, llama.cpp's llama-server, vLLM and LiteLLM do. Claude Code is only supported by Anthropic with Claude models, so expect some features to work less well with other models.

# Ollama on this machine. localhost means the host, not the container.
claudia run local -o ~/work/out --llm http://localhost:11434 --llm-model qwen3.6:latest

# A server elsewhere, with a token.
export CLAUDIA_LLM_TOKEN=sk-...
claudia run remote -o ~/work/out --llm https://llm.example.org --llm-model my-model

# The token in another variable; only the variable's name is remembered.
claudia run remote --llm-token-env MY_LLM_KEY

# Tell Claude Code the model's context window, and send nothing to Anthropic.
claudia run local --llm-context 65536 --llm-only

How it works:

  • Claudia sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN in the container, and ANTHROPIC_MODEL plus the per-tier model variables to --llm-model, so background tasks use the same model. It also sets CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1, because many non-Anthropic servers reject pre-release request fields. No claude.ai login is needed, also for headless (-p) runs.
  • --llm-context TOKENS sets CLAUDE_CODE_MAX_CONTEXT_TOKENS. Claude Code assumes 200k tokens for model names it doesn't know and compacts the conversation by that window. This only tells Claude Code the size; the server decides how much the model really reads. Give the model at least that window on the server. Claude Code's very first request (system prompt and tool definitions) is about 15k tokens. Ollama's default can be as low as 4k, and Ollama then cuts the prompt without an error ("truncating input prompt" in its log), so Claude ignores your instructions. For Ollama, set OLLAMA_CONTEXT_LENGTH for the server or PARAMETER num_ctx in a Modelfile, and check ollama ps or /api/ps for the context in use.
  • --llm-only sends nothing to Anthropic. It sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 (telemetry, version checks, release notes) and passes --settings '{"skipWebFetchPreflight": true}' (WebFetch's domain check). This also turns off auto-updates, so the instance keeps the Claude Code version it has. With --restricted, the Anthropic hosts are left out of the allowlist, so only the LLM host and --allow-domain hosts are reachable.
  • The token is read from $CLAUDIA_LLM_TOKEN (or --llm-token-env VAR) on each run and handed to Podman through the environment. It is never written to the instance config or put on a command line, so it doesn't show in --dry-run or the process list. If the variable is unset, the placeholder none is sent, which servers without authentication accept.
  • localhost, 127.0.0.1 and ::1 mean the host. In full network mode Claudia forwards only the LLM's port from the container's loopback to the host's (pasta -T PORT), so a server that only listens on 127.0.0.1, as Ollama does by default, is reachable, and no other service on the host's loopback is. localhost is sent as 127.0.0.1, because Claude Code would try ::1 first; give [::1] explicitly for a server that only listens on IPv6. (Claudia 0.2.3 used --map-host-loopback, which exposed all of them.)
  • With --restricted, the LLM host is added to the proxy's allowlist, and a port other than 443 is allowed for HTTPS tunnels. The proxy can't reach the host's loopback, so a model server on this machine has to listen on another address, such as a bridge address (for example OLLAMA_HOST=192.168.122.1) given in --llm. The proxy filters by host name only, so every port on the allowed LLM host is reachable over plain HTTP.

Work log

Every session is started with an appended system prompt (baked into the image at /usr/share/claudia/log-instructions.md) that requires Claude to keep a high-level log of what it does and why in /work/output/CLAUDIA-LOG.md. The log is append-only, written at architecture level, and ends each task with a summary and open questions. The same prompt also tells Claude to suggest a dynamic workflow (the Workflow tool, which fans the work out to several subagents) when a task is large and splits into independent parts; it only runs one after you opt in.

Where things live on the host

Path Content Mounted?
$XDG_CONFIG_HOME/claudia/instances/NAME.json start-up options (mounts, network, allowlist) never
$XDG_DATA_HOME/claudia/NAME/home/ Claude's login, settings, history, Claude Code itself as /home/claudia

claudia run refuses any -r/-o that is, contains or lies inside either Claudia tree. That way Claude can never see or change how it will be started next time, and can't read other instances' credentials.

Security model

  • The container is rootless, so root inside (via sudo) is your own host user at most. Read-only bind mounts stay read-only even for root.
  • The instance home is writable by Claude by design. That is what keeps login and auto-updates. Claude can therefore change its own settings or Claude Code binary for that instance, but never the mounts or network mode, which are chosen by the host CLI.
  • The work-log instructions and the entrypoint live in the image. Each run uses --rm, so changes made inside the container don't last.
  • --restricted puts the container on an internal Podman network with no route out. A per-run claudia-proxy container (tinyproxy, deny by default) forwards only to api.anthropic.com, claude.ai, claude.com, platform.claude.com, downloads.claude.ai, any --allow-domain hosts and the --llm host (with --llm-only, only the last two).
  • Connectors from your Claude account come along. Once an instance is logged in with a claude.ai account, Claude Code inside the container can use every MCP connector you have connected to that account on the web (Gmail, Google Drive, calendars and so on). It can do so with your credentials, and these connectors reach outside the container. Mount restrictions don't apply to them. In --restricted mode they are blocked, because the connector proxy mcp-proxy.anthropic.com is not on the allowlist (add it with --allow-domain if you want them). To turn them off in full network mode as well, you have three options:
    • disconnect them in your claude.ai settings (this affects every client),
    • log the instance in with an account that has no connectors, or
    • set ENABLE_CLAUDEAI_MCP_SERVERS=false for the instance. Add "env": {"ENABLE_CLAUDEAI_MCP_SERVERS": "false"} to $XDG_DATA_HOME/claudia/NAME/home/.claude/settings.json. That file is in the instance home, which Claude can write to, so Claude can undo this. Treat it as a convenience, not a security boundary.
  • Nested Podman (on by default) adds SYS_ADMIN inside the user namespace, unmasks /proc and adds /dev/net/tun. See Nested containers. Use --no-nested for the tightest sandbox.

Images

claudia run uses kode.naiv.no/olemd/claudia:<version> (and claudia-proxy:<version> for --restricted). If the image isn't present locally, it is pulled. If the pull fails, it is built from the files shipped inside the package. Claude Code updates itself inside each instance home, so the image only needs rebuilding for changes to the base system.

Development

uv sync
uv run ruff format --check . && uv run ruff check .
uv run pytest
shellcheck src/claudia/assets/entrypoint.sh src/claudia/assets/proxy/entrypoint.sh packaging/*.sh packaging/claudia.launcher
shellcheck -s bash src/claudia/assets/completion/claudia.bash
groff -man -ww -z packaging/claudia.1
uv build                       # dist/*.whl, dist/*.tar.gz
packaging/build-deb.sh         # dist/claudia_<version>_all.deb

CI (.forgejo/workflows/ci.yml) runs the checks on every push and pull request. Publishing a release (tag v<version> matching pyproject.toml) also runs the release job, which builds and publishes the wheel, sdist, .deb and both container images. Add an entry for the new version at the top of packaging/changelog (Debian changelog format) first: it becomes the .deb's changelog, and build-deb.sh refuses to build when its newest entry does not match pyproject.toml. The release job needs the repository secret PACKAGES_TOKEN, an access token of the repository owner (olemd) with write:package and write:repository (repository access "Public only"). The first is for the registries, the second for attaching files to the release.

Forgejo cannot limit write:package to a single repository. To keep repository write access off the package token, give it only write:package and add a second secret RELEASE_TOKEN with write:repository limited to olemd/claudia. The release job uses RELEASE_TOKEN for attachments when it is set.

License

MIT, see LICENSE.