Why bother?

AI agents like Claude Code are going to change the face of software development; whether you like it or not, they are going to become a standard tool in the software engineer’s arsenal. But that doesn’t mean you have to love them - and if definitely doesn’t mean you should trust them.

The idea of an AI agent running rm -rf or kubectl delete on my actual workstation is enough to keep me up at night. Or at least making me double-check every command it runs.

The solution, as with many modern problems, is Docker. Put Claude Code in a container, mount only the source code it needs, and let it work in a sandbox. If it goes rogue — which at some point it absolutely will — you’ve got a single docker rm to clean up after it.

Here’s how I set it up.

The approach

The idea is simple: build a Docker image with Claude Code installed, mount your source code as a volume, and run the CLI inside the container. The container gets access to your code but nothing else — no system directories, no browser history, no SSH keys lying around.

There are three pieces to this:

  1. A Dockerfile that installs Claude Code
  2. An entrypoint script that creates a matching user, navigates to the right directory, and runs as your host user (not root)
  3. A shell function that resolves your current working directory, passes your UID/GID, and launches the container with the right mounts

Let’s get this show on the road.

The Dockerfile

FROM node:20-slim

ARG CLAUDE_VERSION
LABEL claude-code.version="${CLAUDE_VERSION}"

RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    pkg-config \
    nodejs npm \
    && rm -rf /var/lib/apt/lists/*

RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_VERSION}

COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

For the example I’m using node:20-slim as a clean base. Your mileage may vary — my own setup uses an internal Rust CI image from our private registry, since it already has the toolchain I need. The key thing is that you need Node.js and npm for the Claude Code install, plus whatever else your projects require.

The CLAUDE_VERSION build arg lets us tag each image with a specific version, so we’re not surprised by breaking changes when Anthropic ships an update. [^1]

Running as your user, not root

By default, everything in a Docker container runs as root. That means any files Claude Code creates — commits it makes, files it writes — are owned by root. When you step away from the container, you’re staring at a mess of chown commands to fix.

Worse, it means the container process has full root privileges inside its namespace. Container escape is rare but not impossible, and root is always more dangerous than a regular user.

The fix is to pass your host UID and GID into the container, create a matching user on the fly, and switch to it before running Claude Code. Files the assistant creates will have the correct ownership outside as well as inside the container.

The entrypoint

The container needs to know where to work, what to run, and as which user. The entrypoint handles all three:

#!/bin/bash
set -euo pipefail

if [ $# -lt 1 ]; then
    echo "Usage: entrypoint.sh <directory> [command] [args...]"
    echo "  directory: path to cd to before running the command"
    echo "  command:   command to run (default: claude)"
    echo "  args:      arguments passed to the command"
    exit 1
fi

WORK_DIR="$1"
shift

if [ $# -ge 1 ] && [[ ! "$1" =~ ^- ]]; then
    CMD="$1"
    shift
else
    CMD="claude"
fi

cd "/development/$WORK_DIR"

export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1

# Run as the host user if UID/GID were provided (by safeclaude wrapper)
if [ -n "${HOST_UID:-}" ] && [ -n "${HOST_GID:-}" ]; then
    groupadd -g "$HOST_GID" hostuser 2>/dev/null || true
    useradd -u "$HOST_UID" -g "$HOST_GID" -s /bin/bash -M -d /tmp/home hostuser 2>/dev/null || true

    export HOME=/tmp/home

    export ANTHROPIC_BASE_URL=https://your-proxy.example.com/
    export ANTHROPIC_AUTH_TOKEN=your-token

    exec su -p - hostuser -c "export HOME=/tmp/home; export ANTHROPIC_BASE_URL=$ANTHROPIC_BASE_URL; export ANTHROPIC_AUTH_TOKEN=$ANTHROPIC_AUTH_TOKEN; export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1; cd /development/$WORK_DIR; exec $CMD $*"
fi

exec "$CMD" "$@"

The first argument is a relative path (from /development/) telling the container which project we’re working on. Any remaining arguments are passed through to Claude Code itself.

The interesting part is the user-switching block near the bottom. If HOST_UID and HOST_GID environment variables are set (which the shell function provides), it:

  1. Creates a hostuser group matching your GID
  2. Creates a hostuser user matching your UID
  3. Sets HOME to /tmp/home (where our .claude config and .gitconfig are mounted)
  4. Switches to that user via su and runs the command

The su -p flag preserves the environment variables so they’re available to the child process. We re-export them in the su -c command because su starts a fresh shell.

Note: the ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN lines are for my internal proxy setup. If you’re calling Anthropic’s APIs directly, you don’t need those — Claude Code will pick up your credentials from the mounted .claude.json file.

The CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 environment variable is worth including. It disables telemetry and other phone-home traffic from the CLI, which is good practice when you’re already sandboxing the thing for privacy.

The shell function

This is the piece that ties everything together. Rather than typing a long docker run command every time, we wrap it in a zsh function. (Other shells are available…):

safeclaude() {
    local CLAUDE_VERSION
    CLAUDE_VERSION=$(npm view @anthropic-ai/claude-code version)

    local cwd
    cwd=$(realpath "$PWD")
    local dev_root
    dev_root=$(realpath "$HOME/development")

    # Security boundary — only allow projects under ~/development
    if [[ "$cwd" != "$dev_root"* ]]; then
        echo "AI assistants are restricted to ~/development/**"
        return 1
    fi

    # Compute relative path from ~/development
    local rel_path
    rel_path="${cwd#$dev_root/}"
    if [[ "$rel_path" == "$cwd" ]]; then
        rel_path="."
    fi

    docker run -it --rm \
        -e "HOST_UID=$UID" \
        -e "HOST_GID=$GID" \
        -v "$HOME/.claude.json:/tmp/home/.claude.json" \
        -v "$HOME/.claude:/tmp/home/.claude" \
        -v "$HOME/.gitconfig:/tmp/home/.gitconfig:ro" \
        -v "$HOME/development:/development" \
        -w "/development/$rel_path" \
        your-registry.example.com/claude-code:claude-${CLAUDE_VERSION} \
        "$rel_path" "$@"
}

Let’s walk through what this does:

Version lookup — it queries npm for the latest Claude Code version and uses that as the image tag. This means you’ll know when a new version is available - ‘cos it won’t find the docker image - but you won’t be /silently/ upgraded. When a new version is available, build a new docker image!

Path resolution — it checks that you’re currently inside ~/development/ (or wherever your projects live) and computes the relative path. This is the relative path that gets passed to the container’s entrypoint.

Important: the security check is the whole point of this exercise. Claude Code can only run when you’re inside your development directory. Try to run it from / or /etc and the function refuses. This means the container can never see directories outside what you’ve explicitly mounted.

User identity — your host UID and GID are passed as environment variables so the entrypoint can create a matching user inside the container.

Volume mounts — four things get mounted into the container:

  • ~/.claude.json — your Claude Code API credentials, mounted at /tmp/home/.claude.json
  • ~/.claude/ — session data, memory, settings (so conversations persist across runs), mounted at /tmp/home/.claude
  • ~/.gitconfig — your git config, mounted read-only at /tmp/home/.gitconfig.
  • ~/development/ — your entire source code tree, mounted at /development

Worth noting that yes, I do mount the entire ~/development directory, and not just the current project directory; that’s because I fairly frequently refer to other projects when I’m developing something else. Everything in ~/development is recoverable from gitlab, so this is a risk I’m willing to take - but you could lock it down more tightly.

That’s it. The container sees your code and your Claude config, and nothing else on your filesystem.

Add this function to your ~/.zshrc (or ~/.bash_profile with the bash equivalent), and you’ve got a safeclaude command available in every terminal.

Building the image

Here’s a justfile target that builds and pushes the image:

CLAUDE_VERSION := `npm view @anthropic-ai/claude-code version`
TAG := 'claude-' + CLAUDE_VERSION
REPO := 'your-registry.example.com'
IMAGE := 'claude-code'

build:
    docker build --build-arg CLAUDE_VERSION="{{CLAUDE_VERSION}}" -t {{REPO}}/{{IMAGE}}:{{TAG}} .
    docker push {{REPO}}/{{IMAGE}}:{{TAG}}

If you don’t have a private registry, just push to Docker Hub or whatever you use. The important thing is that the tag matches the version so the shell function can find it.

Pro-Tip: if you’re the only user, you can skip the registry entirely and reference the local image by tag. Just build it once and update the image name in the shell function.

Let’s try it

Navigate to a project under ~/development/ and run:

$ safeclaude

It launches Claude Code inside the container, right in your current directory. You should see the familiar Claude Code welcome screen. Try asking it to read a file:

> Read src/main.py

If it returns the file contents, the volume mount is working. Try something slightly more involved:

> What tests exist for the authentication module?

If it searches your codebase and gives you a useful answer, everything’s wired up correctly.

You can also pass arguments through to Claude Code:

$ safeclaude --model sonnet
$ safeclaude /loop "check the build every 5 minutes"

These get passed as the remaining arguments after the directory path, so the entrypoint forwards them straight to the claude command.

What about read-only mounts?

If you want extra safety and don’t mind Claude Code not being able to write changes, you can mount the source code as read-only:

-v "$HOME/development:/development:ro"

This is great for exploration and code review, but obviously defeats the purpose if you want Claude to write code. For that use case, you’ll want the writable mount and rely on git to track (and revert) any changes you don’t like.

The complete picture

Here’s what the project structure looks like:

assistant-container/
  Dockerfile        # installs Claude Code
  entrypoint.sh     # creates host user, navigates to project, runs claude
  justfile          # build and push the image
  zsh-function.sh   # the safeclaude shell function

Source the zsh function from your shell config, build the image once per Claude Code version, and you’ve got a sandboxed AI assistant that runs as your own user, with correct file ownership, and no ability to nuke your filesystem.

Wrapping up

This setup gives you the best of both worlds: Claude Code has full access to your source code for reading and editing, but it’s locked inside a container running as a non-root user that can’t touch the rest of your system. The files it creates are owned by you, not root. The security boundary is enforced by the shell function, not the container — Docker volumes are only as safe as what you mount into them. So the check that you’re inside ~/development/ matters more than you might think.

I write these things mostly for my own memory, but if this saves someone else from running an AI agent with unrestricted access to their workstation, that’s a bonus.

Taking it further

Once you have the thing set up inside a container, it then becomes easy to start giving it more controlled access. For example, we can load a .kubeconfig into the container that will give /read-only/ access to Kubernetes environments - so we can debug, but not break, running K8s deployments, and so on.

[^1] You could skip the version tag and always pull latest, but that’s the sort of thing that works perfectly until it doesn’t — and then you’re wondering why Claude Code suddenly refuses to run commands it was happy with yesterday.