Hardening Docker Containers for Autonomous Claude Code

Where We Left Off

In part one, we migrated from OS-level sandboxing to Docker isolation for running Claude Code in headless mode. Docker solved the friction: no PATH hacks, no global settings, no sandbox diagnostics. Each Claude invocation runs inside a container with pre-installed tools, a non-root user, and persistent state across pipeline stages.

But “runs inside a container” is a starting point, not an endpoint. Our containers were running with Docker’s defaults: full Linux capabilities, writable root filesystem, unrestricted network access. A prompt injection in a GitHub issue could tell Claude to exfiltrate source code, install a reverse shell, or consume all available resources.

This post covers the hardening journey: what we locked down, what broke, and the surprises we found along the way.

Layer 1: Container Security Flags

Docker containers inherit all Linux capabilities by default. That means your container can do things like load kernel modules, change file ownership, and manipulate network interfaces. Claude needs none of that. It needs to read and write files, run build tools, and make API calls.

Three flags tighten the defaults:

1docker run \
2  --cap-drop=ALL \
3  --security-opt=no-new-privileges \
4  --read-only \
5  ...

--cap-drop=ALL removes all Linux capabilities. Claude only needs basic file I/O and network access, which work without any capabilities.

--security-opt=no-new-privileges prevents privilege escalation via setuid binaries. Even if the container image has a setuid binary, the claude user can’t use it to gain root.

--read-only makes the root filesystem immutable. Claude can’t tamper with system binaries, install packages, or modify configuration outside of explicitly writable paths.

The Writable Paths Problem

A read-only root filesystem immediately breaks things. Build tools need /tmp. Claude Code needs ~/.claude/ for session files, caches, and credentials. The fix: tmpfs mounts for specific directories.

1--tmpfs /tmp:size=512m
2--tmpfs /home/claude:size=512m,uid=1001,gid=1001

That uid=1001,gid=1001 took us a while to figure out. Without it, the tmpfs is owned by root, and the claude user (uid 1001) gets “Permission denied” on its own home directory. The error was silent: Claude would start, read the prompt, begin working, but fail to write session files. No error message, just degraded behavior.

The Mount Ordering Trap

Here’s a subtle one. Our containers mount credentials at /home/claude/.claude/.credentials.json via a bind mount. But the tmpfs at /home/claude creates a fresh empty directory, which hides the bind mount.

Docker processes mounts in declaration order. If the tmpfs comes after the bind mount, the tmpfs wins and the credentials disappear. The fix: declare the tmpfs before the bind mount, so the bind mount overlays the tmpfs.

1# Wrong: tmpfs hides the credential file
2docker run -v creds.json:/home/claude/.claude/.credentials.json --tmpfs /home/claude ...
3
4# Right: bind mount overlays the tmpfs
5docker run --tmpfs /home/claude -v creds.json:/home/claude/.claude/.credentials.json ...

We eventually went further: instead of mounting individual files, we create a temp directory on the host with the settings and credentials, then mount the whole directory. This gives Claude Code a writable ~/.claude/ where it can create whatever session files it needs:

1CLAUDE_DIR=$(mktemp -d)
2cp settings.json "$CLAUDE_DIR/settings.json"
3cp credentials.json "$CLAUDE_DIR/.credentials.json"
4docker run --tmpfs /home/claude -v "$CLAUDE_DIR:/home/claude/.claude" ...

Resource Limits

Per-container resource limits prevent a runaway Claude process from consuming all host resources:

1--memory=4g
2--cpus=2
3--pids-limit=256

The pids limit is particularly important. Without it, a prompt injection could trigger a fork bomb that brings down the host. With --pids-limit=256, the container can run its build tools and tests but can’t spawn thousands of processes.

All of these settings are configurable via config.yaml with secure defaults:

 1isolation:
 2  docker:
 3    security:
 4      cap_drop_all: true
 5      no_new_privileges: true
 6      read_only: true
 7    resources:
 8      memory: "4g"
 9      cpus: "2"
10      pids_limit: 256

Layer 2: Network Egress Filtering

Container security flags prevent filesystem and privilege abuse, but they don’t restrict network access. A compromised Claude container can still reach any IP address: exfiltrate code to an external server, download malware, or phone home to a C2 server.

The Squid Forward Proxy

We added a Squid forward proxy as a Docker Compose sidecar. It’s the only container with external internet access. All Claude containers route HTTP/HTTPS through it:

 1services:
 2  squid:
 3    image: ubuntu/squid:latest
 4    volumes:
 5      - ./docker/squid.conf:/etc/squid/squid.conf:ro
 6      - ./docker/allowlist.txt:/etc/squid/allowlist.txt:ro
 7    networks:
 8      - internal
 9      - external
10
11  claudomator:
12    environment:
13      - HTTP_PROXY=http://squid:3128
14      - HTTPS_PROXY=http://squid:3128
15    networks:
16      - internal  # no external access
17
18networks:
19  internal:
20    internal: true
21  external:

The allowlist is minimal: package registries (npm, Maven Central), GitHub (for git operations and API), and Anthropic (for the Claude API). Everything else gets a 403.

registry.npmjs.org
repo1.maven.org
github.com
api.github.com
.githubusercontent.com
api.anthropic.com

Why Squid over iptables? Forward proxies work at the domain level, not the IP level. CDN IPs change constantly, so IP-based rules would need constant updates. Squid handles HTTPS domain filtering via the CONNECT method, is portable across macOS and Linux, and the allowlist is a simple text file.

To add a domain: edit docker/allowlist.txt and restart the Squid service. No Claude container restarts needed.

Layer 3: Docker API Access

This one came from a real need. Some of our target repos use Testcontainers for integration tests. Testcontainers needs Docker API access to spin up test dependencies (databases, message brokers). Without it, mvn verify fails.

The naive solution, mounting /var/run/docker.sock, gives the agent unrestricted control over the Docker daemon. It can create privileged containers, mount the host filesystem, exec into other containers. This completely undermines the hardening.

The Socket Proxy

We added a Tecnativa docker-socket-proxy that sits between Claude containers and the Docker daemon. It exposes a filtered Docker API over TCP:

 1socket-proxy:
 2  image: tecnativa/docker-socket-proxy:0.3
 3  volumes:
 4    - /var/run/docker.sock:/var/run/docker.sock:ro
 5  environment:
 6    CONTAINERS: 1    # allow
 7    IMAGES: 1        # allow
 8    NETWORKS: 1      # allow
 9    VOLUMES: 1       # allow
10    # everything else denied by default: exec, privileged, swarm, secrets

Claude containers connect via DOCKER_HOST=tcp://socket-proxy:2375. They can create test containers, pull images, and manage networks, but they can’t exec into containers, create privileged containers, or access swarm/secrets endpoints.

This is opt-in via configuration:

1isolation:
2  docker:
3    socket_proxy:
4      enabled: true

For bare-metal deployments (no Docker Compose), we provide socket-proxy-start.sh and socket-proxy-stop.sh scripts that create a standalone Docker network and proxy container. The dashboard’s start.sh auto-starts the proxy when enabled, and stop.sh tears it down.

The Hooks Saga

While testing the hardened containers, we noticed the dashboard wasn’t showing tool usage (Read, Grep, Bash calls) for Docker-isolated runs. We had PostToolUse hooks configured to stream tool events to the dashboard, and they worked before hardening.

The Private IP Block

After adding --debug "hooks" to the Claude command, the debug log revealed:

[ERROR] HTTP hook blocked: host.docker.internal resolves to 192.168.65.254
        (private/link-local address). Loopback (127.0.0.1, ::1) is allowed
        for local dev.

Claude Code has a security restriction on HTTP hooks: they can only POST to loopback addresses (127.0.0.1, ::1). Inside a Docker container, the dashboard runs on the host, reachable via host.docker.internal, which resolves to a private IP (192.168.65.254 on Docker Desktop). Claude Code blocks it.

This is a reasonable security measure: it prevents a compromised project’s .claude/settings.json from exfiltrating data via hooks to arbitrary servers. But it also blocks legitimate hooks from inside containers.

The Command Hook Workaround

HTTP hooks are blocked, but command hooks are not. Command hooks run a shell command that receives the tool event on stdin. We write a small Node.js script that reads stdin and POSTs to the dashboard:

 1// hook-forwarder.js
 2const http = require("http");
 3const d = [];
 4process.stdin.on("data", c => d.push(c));
 5process.stdin.on("end", () => {
 6  const body = Buffer.concat(d).toString();
 7  const req = http.request(process.env.HOOK_URL, {
 8    method: "POST",
 9    headers: { "Content-Type": "application/json" }
10  });
11  req.on("error", () => {});
12  req.end(body);
13});

The script is mounted read-only at /opt/hook-forwarder.js, and the dashboard URL is passed via HOOK_URL environment variable. The settings file configures it as a command hook:

 1{
 2  "hooks": {
 3    "PostToolUse": [{
 4      "hooks": [{
 5        "type": "command",
 6        "command": "node /opt/hook-forwarder.js",
 7        "timeout": 5
 8      }]
 9    }]
10  }
11}

The command hook calls host.docker.internal via Node’s http.request, which is just a regular HTTP call from the container, no Claude Code restriction applies.

The ~/.claude Write Requirement

Even with the command hook in place, hooks initially didn’t fire. The debug log showed “Found 0 hook matchers in settings.” The settings file existed and was readable, but Claude Code wasn’t loading it.

The root cause: Claude Code needs to write to ~/.claude/ at startup. It creates directories like backups/, plans/, plugins/, projects/, and session-env/. With the read-only filesystem, the tmpfs at /home/claude provided writable space, but we were mounting credentials as individual read-only files. The ~/.claude/ directory itself was owned by root (from the bind mount parent), and Claude Code couldn’t create its subdirectories.

The fix was mounting a writable temp directory instead of individual files:

1CLAUDE_DIR=$(mktemp -d)
2cp settings.json "$CLAUDE_DIR/settings.json"
3cp credentials.json "$CLAUDE_DIR/.credentials.json"
4docker run -v "$CLAUDE_DIR:/home/claude/.claude" ...

Claude Code creates its session directories, reads the settings, finds the hooks, and starts firing tool events.

The Threat Model

Here’s what the hardened setup addresses:

ThreatMitigation
Prompt injection exfiltrates source codeSquid proxy allowlist blocks outbound connections to unknown domains
Agent gains kernel privileges--cap-drop=ALL removes all capabilities
Agent escalates via setuid--security-opt=no-new-privileges blocks it
Agent tampers with system binaries--read-only root filesystem
Agent fork bombs the host--pids-limit=256 caps process count
Agent exhausts memory/CPU--memory=4g --cpus=2 per container
Agent creates privileged containers via Docker APISocket proxy denies privileged and exec endpoints
Agent leaks internal IPs via HTTP headersSquid suppresses forwarded-for header

What it doesn’t address (accepted risks):

  • Sibling container resource consumption: Containers created via the socket proxy (for Testcontainers) don’t inherit the resource limits of the Claude container. Docker Desktop’s VM provides an upper bound, but individual test containers aren’t capped.
  • Data leakage to allowed domains: The allowlist permits GitHub and npm registry. A sufficiently creative prompt injection could exfiltrate data via git push to a public repo or npm publish. The API tokens limit this in practice (scoped permissions), but it’s theoretically possible.

What We Learned

1. Mount ordering matters. Docker processes mounts in the order they appear in the command. Tmpfs at a parent path hides bind mounts at child paths unless the bind mount comes after. This is documented behavior, but easy to miss.

2. Claude Code needs writable ~/.claude/. Even in headless mode, Claude Code creates session directories at startup. A read-only home directory doesn’t crash it, but it silently degrades: settings don’t load, hooks don’t fire, and the debug log is the only way to find out.

3. Claude Code blocks HTTP hooks to private IPs. This is a security feature, not a bug. But it means HTTP hooks from inside Docker containers to the host don’t work. Command hooks are the workaround.

4. --debug "hooks" is your friend. Without it, we would have spent much longer guessing why hooks weren’t firing. The debug output clearly showed “HTTP hook blocked” and “Found 0 hook matchers.”

5. Defense in depth works because each layer catches different things. Capability dropping stops privilege escalation. Read-only filesystems stop binary tampering. The network proxy stops exfiltration. The socket proxy stops Docker API abuse. Resource limits stop denial of service. No single layer is sufficient, but together they cover the realistic threat surface.

6. Make hardening configurable with secure defaults. Every security flag can be disabled via config.yaml for repos that need it (e.g., a build tool that requires a writable root filesystem). But the defaults are restrictive: you opt out of security, not into it.

References