Claude Code in a Docker container, with a non-root user that has the same
UID/GID as the host user. State (login, settings, history) is kept on the
host in ~/.claude-container, so it survives a container.
- Docker
- A Linux host (the scripts use
id -u/id -gand bind mounts)
Build the image with build-image.sh:
./build-image.shThis builds the image claude-container:latest with --build-arg UID/GID
matching the current host user, so files the container creates in the
workspace are simply owned by you (no chown needed).
Every version the image installs is pinned in a block at the top of the
Dockerfile (base image, Node, JDK, Maven, Helm, Android SDK, Claude Code), so a rebuild
produces the same image rather than whatever is current upstream.
Upgrading means editing those ARGs. The script uses --no-cache, so each
build re-fetches those pinned downloads rather than reusing layers.
Debian 13 with Node.js (for Claude Code itself) and OpenJDK 21, so
the agent can compile and run Java in the container: javac, java and the
rest of the JDK tooling are on the PATH, and JAVA_HOME points at
/usr/lib/jvm/default-java. Maven is in the image as well (/opt/maven,
with mvn on the PATH and MAVEN_HOME set), from the upstream release and
pinned to a checksum in the Dockerfile. Gradle is not installed: the projects
this image is used for ship the Gradle wrapper. A project that ships a wrapper
(./mvnw, ./gradlew) uses its own version, which downloads on first run and
is then kept in the cache described below.
Python 3 comes from Debian (3.13 on trixie, fixed by the pinned base image),
with python, python3, pip3 and venv available. Debian treats the system
interpreter as externally managed, so install packages into a virtualenv
(python -m venv .venv) rather than with a bare pip install. pandas is
preinstalled on the system interpreter (also from Debian); a virtualenv only
sees it when created with --system-site-packages.
The Android SDK is in /opt/android-sdk (ANDROID_HOME), so Android apps can
be built, linted and unit-tested with the project's own ./gradlew: the
command-line tools (sdkmanager, lint, apkanalyzer, ...), platform-tools
(adb), one platform and one build-tools version, all pinned in the
Dockerfile, with the SDK licenses accepted at build time. The SDK belongs to
the container user, so a project that needs another compileSdk or build-tools
version has the Android Gradle plugin download it, though only for that run;
add a version a project keeps needing to the Dockerfile. ANDROID_USER_HOME
points at ~/etc/android (see Persistent storage), so the
debug keystore stays the same from one run to the next and debug builds can
replace each other on a device. There is no emulator: it needs /dev/kvm. A
phone with wireless debugging can be reached with adb connect. Google ships
the SDK binaries for x86_64 only, so an image built on another architecture
leaves the SDK out.
For quick work from the shell, jq, bc and /usr/bin/time are installed too.
Go to the directory of the project you want to work on and run:
/path/to/claude-container/claude-container.shThis mounts the current working directory read-write into the container at
the same path, and starts claude in it. Arguments replace that command,
so you can run something else in the container (for example to poke
around):
claude-container.sh bashTo pass arguments to Claude Code, name it explicitly:
claude-container.sh claude --helpIf the working directory contains a claude-container-init.sh, it is sourced
in the container (with bash, from the workspace) before the command runs.
That is the place for per-project setup, such as putting a tool on the PATH:
# claude-container-init.sh
export PATH="$PWD/tools/bin:$PATH"Because it is sourced, exported variables reach Claude Code (or whatever command you passed). If the script fails, the container stops without starting the command.
The script refuses to start if the working directory coincides with a
system directory or with the container user's home — that would break the
state mounts or the container itself. It also refuses to start in your own
home directory (or anything above it): mounting that would hand the
container ~/.ssh, ~/.aws, browser profiles and its own state directory
in one go.
Resource ceilings can be overridden per run; the defaults are 8g of memory, 4 CPUs and 1024 processes:
CLAUDE_MEMORY=16g CLAUDE_CPUS=8 claude-container.shMaven and the Gradle wrapper keep their local repository under the user's home
directory, and that home lives inside the container, which is thrown away after
every run. So ~/.claude-container/m2/<workspace path> and
~/.claude-container/gradle/<workspace path> are mounted on ~/.m2 and
~/.gradle in the container: the local repository, the downloaded wrapper
distributions (the Gradle distribution itself among them) and anything else
these tools cache survive a run, and only the first build pays for the
download.
One pair per workspace, mirroring the workspace path. A local repository is not only a download cache: mvn install
and Gradle write a project's own artifacts into it, so a shared one would let
one project's snapshots be resolved by every other. The price is that each
project downloads its dependencies once.
Deliberately not your own ~/.m2 and ~/.gradle: those sit inside your home
directory, which this setup keeps out of the container — a settings.xml with
repository credentials among them. The cost is one cold start, and a second
copy of the artifacts you already had on disk. Throwing the caches away is
rm -rf ~/.claude-container/m2 ~/.claude-container/gradle, or just one
project's with rm -rf ~/.claude-container/m2/<workspace path>.
The container's home directory is thrown away after every run, and the
workspace belongs to the project. For anything else that should survive a
restart (notes, scratch data, tool configuration), ~/etc in the container is
mounted from ~/.claude-container/etc/<workspace path> on the host. There is
one per workspace, like the caches, so one project's files do not show up in
another project's container. Removing it is
rm -rf ~/.claude-container/etc/<workspace path>.
The container runs as a non-root user with all capabilities dropped
(--cap-drop=ALL), no-new-privileges set and limits on memory, CPU and process count.
Two things the container does have, by design: the workspace is mounted
read-write, so anything Claude Code runs can change every file in the
project you started it from, and ~/.claude-container/claude holds your
login token.
To be able to call claude-container.sh from anywhere without typing the
full path, create a symlink in a directory that's on your PATH, for
example /usr/local/bin:
sudo ln -s /path/to/claude-container/claude-container.sh /usr/local/bin/claude-containerReplace /path/to/claude-container with the absolute path to this
repository. After that, you can simply do, from any project:
cd /path/to/another/project
claude-containerBecause it's a symlink (not a copy), you automatically pick up changes to
claude-container.sh as soon as you commit them in this repo. Remember to
rebuild the image with ./build-image.sh after every change to
Dockerfile or build-image.sh.