Harden Claude Code with Docker
Run Claude Code inside a hardened Docker container with a network firewall.
Introduction
30 Second Summary
When you give an AI coding agent access to your terminal and filesystem, you're trusting it not to reach beyond what it needs. Software guardrails help, but a determined prompt injection could theoretically bypass them at the application layer.
In this project, you will build a hardened Docker dev container that locks Claude Code inside kernel-level isolation. You'll configure a network firewall, managed settings at the highest enforcement precedence, and filesystem boundaries that exist below the application layer.
What You'll Build
A portable .devcontainer/ folder that automatically launches Claude Code inside a locked-down Linux container with iptables network filtering and highest-precedence permission enforcement.
By the end of this project, you'll have:
- A Dockerfile that installs Claude Code with firewall tools and managed settings baked into the image.
- An iptables firewall script that blocks all outbound network traffic except Anthropic's API and required services.
- Managed settings at the highest enforcement precedence that Claude cannot self-modify or override.
- Secret Mission: Layer your hooks from Guardrails Part 1 inside the container and test the full defense-in-depth stack end-to-end.
Are there any prerequisites?
This project builds on two earlier projects. You should have completed "Set Up Claude Code Guardrails" (permissions and hooks in place) and "Create a Docker Container using Cursor" (Docker Desktop installed and familiar with compose).
You also need Claude Code installed and working with an active Claude Pro or Max subscription.
Before We Start
You already have Claude Code running with permission rules and hooks from Guardrails Part 1. Those software-level guardrails catch most problems, but they run inside the same environment Claude can access.
In this step, you'll prepare a fresh project folder and verify that your tools are ready. This folder will hold a Docker dev container configuration that wraps Claude Code in kernel-level isolation.
In this step, get ready to:
- Install the Dev Containers extension in Cursor.
- Verify Docker Desktop is running.
- Create the project folder with a .devcontainer/ subdirectory.
Install the Dev Containers extension in Cursor
The Dev Containers extension lets Cursor open your project inside a Docker container instead of running everything on your Mac directly.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Press Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows) to open the Extensions panel.
- Type Dev Containers in the search bar at the top of the panel.
- Find the result published by Microsoft (identifier: ms-vscode-remote.remote-containers).
- Click Install.
What is Dev Containers?
Dev Containers is an open specification for running development environments inside Docker containers. When you open a folder that contains a .devcontainer/ directory, the extension automatically builds and launches the container for you.
- Verify the extension appears in your Installed list in the Extensions panel.
Don't see the extension?
Make sure you're searching in the Extensions panel (the sidebar icon that looks like four squares), not the editor search. If you still can't find it, try searching for the full identifier ms-vscode-remote.remote-containers instead.
Help me find and install the Dev Containers extension in Cursor
Verify Docker Desktop is running
Your hardened container needs Docker Desktop active in the background. A steady whale icon in the menu bar means the Docker engine is ready.
- Look for the Docker whale icon in your Mac menu bar (top-right of your screen). It should be steady, not animated.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open a terminal window.
- Confirm Docker is accessible by running this command:
docker --version
- Confirm the Docker daemon is responding by running:
docker ps
You should see a version number from the first command and an empty table (with column headers) from the second.
Docker commands not responding?
If you see Cannot connect to the Docker daemon, Docker Desktop is installed but not running. Open Docker Desktop from your Applications folder or Spotlight search and wait for the whale icon to become steady.
Help me get Docker Desktop running on my Mac
Create the project folder structure
You need a dedicated folder on your Desktop for the hardened container configuration. The .devcontainer/ subdirectory is where the Dev Containers extension looks for build instructions.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and the devcontainer directory by running these commands:
mkdir hardened-claude
cd hardened-claude
mkdir -p .devcontainer
- Confirm the directory exists by running:
ls -a
You should see .devcontainer listed in the output.
- Open the folder in Cursor by running:
cursor .
A new Cursor window opens with your hardened-claude project. You should see the .devcontainer folder in the Explorer sidebar on the left.
Show hidden files in the Explorer
Files and folders starting with a dot (like .devcontainer) are hidden by default on macOS. Cursor's Explorer shows them automatically, so you should see it without any extra configuration.
Don't see .devcontainer in Cursor?
Make sure the cursor . command ran from inside the hardened-claude folder (not from Desktop). If Cursor opened the wrong folder, click File then Open Folder and select ~/Desktop/hardened-claude.
Help me open my hardened-claude project correctly in Cursor
Your workspace is ready. Next up, you'll build the Dockerfile and managed settings that lock down Claude Code's permissions at the highest enforcement level.
Build the Dockerfile and Managed Settings
Your environment is ready with Docker running and Cursor open. Now it is time to define what your hardened container actually looks like.
In this step, you will create two key files. The first is a managed settings file that locks down Claude Code's permissions at the highest enforcement level. The second is a Dockerfile that builds a Linux container image with all the tools needed for network isolation.
In this step, get ready to:
- Create managed settings that enforce permission deny rules at the highest precedence.
- Write a Dockerfile that installs Claude Code and firewall tools inside a locked-down container.
- Verify the Docker image builds successfully.
Create the managed settings file
Claude Code reads settings from multiple locations. Project-level settings live in .claude/settings.json, and user-level settings live in ~/.claude/settings.json. But both of those can be modified by anyone with file access.
Managed settings sit at the highest precedence. They live at /etc/claude-code/managed-settings.json inside the container and are owned by root. Claude Code cannot self-modify them, and no lower-level settings file can override them.
- Create a new file called managed-settings.json inside the .devcontainer/ folder in Cursor by right-clicking the folder in the Explorer sidebar and selecting New File.
- Paste the following content into the file:
{
"permissions": {
"deny": [
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/secrets/**)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(rm -rf *)"
],
"allowManagedPermissionRulesOnly": true
},
"sandbox": {
"enabled": false
}
}
What does this configuration do?
- The deny array blocks Claude from reading .env files, anything inside secrets/ directories, and from running curl, wget, or rm -rf commands.
- Setting allowManagedPermissionRulesOnly to true prevents any project-level or user-level settings from adding their own allow rules that could widen the attack surface.
- The sandbox.enabled: false disables the built-in bubblewrap sandbox. Running bubblewrap inside an unprivileged container considerably weakens security. Docker itself provides the outer isolation boundary instead.
- Save managed-settings.json with Cmd+S (macOS) or Ctrl+S (Windows).
- Confirm the file appears in Cursor's Explorer sidebar under .devcontainer/managed-settings.json.
File not appearing in the sidebar?
Make sure you created the file inside the .devcontainer/ folder, not in the project root. The folder name starts with a dot, so double-check you right-clicked the correct directory.
help me find where my managed-settings.json file ended up
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
{
"permissions": {
"deny": [
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/secrets/**)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(rm -rf *)"
],
"allowManagedPermissionRulesOnly": true
},
"sandbox": {
"enabled": false
}
}
Write the Dockerfile
A Dockerfile is a recipe for building a container image. Your Dockerfile starts from a node:20 base image, installs the system packages needed for the firewall, copies in your managed settings, and installs Claude Code as a global npm package.
- Create a new file called Dockerfile inside the .devcontainer/ folder (right-click the folder, select New File).
- Paste the following into the file:
FROM node:20
ARG TZ
ENV TZ="$TZ"
ARG CLAUDE_CODE_VERSION=latest
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
git \
procps \
sudo \
jq \
curl \
iptables \
ipset \
iproute2 \
dnsutils \
aggregate \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /usr/local/share/npm-global && \
chown -R node:node /usr/local/share
RUN mkdir -p /workspace /home/node/.claude && \
chown -R node:node /workspace /home/node/.claude
RUN mkdir -p /etc/claude-code
COPY managed-settings.json /etc/claude-code/managed-settings.json
WORKDIR /workspace
USER node
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
COPY init-firewall.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/init-firewall.sh && \
echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
chmod 0440 /etc/sudoers.d/node-firewall
USER node
What does this Dockerfile do?
The Dockerfile has four major sections:
- System packages: installs iptables, ipset, iproute2, and dnsutils for the network firewall, plus developer tools like git and jq.
- Directory setup: creates the workspace and Claude config directories, owned by the non-root node user.
- Managed settings: copies your managed-settings.json to /etc/claude-code/. Because the COPY happens while USER is still root, the file is owned by root and cannot be modified by the node user.
- Claude Code install: switches to the node user, then installs Claude Code globally via npm. The firewall script is copied and a sudoers entry lets the node user run only that one script as root.
Why run as a non-root user?
Running Claude Code as the node user (not root) means Claude cannot modify system files like /etc/claude-code/managed-settings.json. Even if a prompt injection tried to override the permission rules, the OS blocks the write.
- Save Dockerfile with Cmd+S (macOS) or Ctrl+S (Windows).
- You won't see a visible change yet. The next substep verifies the image builds correctly.
Seeing red squiggles in the Dockerfile?
Cursor may show warnings for Dockerfile syntax if you do not have a Docker extension installed. These do not affect the build. You can safely ignore them.
help me understand the warnings in my Dockerfile
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
FROM node:20
ARG TZ
ENV TZ="$TZ"
ARG CLAUDE_CODE_VERSION=latest
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
git \
procps \
sudo \
jq \
curl \
iptables \
ipset \
iproute2 \
dnsutils \
aggregate \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /usr/local/share/npm-global && \
chown -R node:node /usr/local/share
RUN mkdir -p /workspace /home/node/.claude && \
chown -R node:node /workspace /home/node/.claude
RUN mkdir -p /etc/claude-code
COPY managed-settings.json /etc/claude-code/managed-settings.json
WORKDIR /workspace
USER node
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
COPY init-firewall.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/init-firewall.sh && \
echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
chmod 0440 /etc/sudoers.d/node-firewall
USER node
Verify the Dockerfile builds
The Dockerfile references init-firewall.sh via a COPY instruction. You will write the full script in Step 3, but Docker needs the file to exist at build time. You will create an empty placeholder for now.
- Create the placeholder firewall script by running this command in Cursor's integrated terminal (Ctrl+` to open it):
touch .devcontainer/init-firewall.sh
- Build the Docker image to confirm everything compiles correctly by running:
docker build -f .devcontainer/Dockerfile .devcontainer/
The build takes 1-2 minutes on the first run. You should see output from each layer being processed, ending with a line that includes the word FINISHED.
Build failing with an error?
- If you see COPY failed mentioning init-firewall.sh: make sure you ran the touch command above. The file must exist in .devcontainer/ even if it is empty.
- If you see COPY failed mentioning managed-settings.json: confirm managed-settings.json is saved inside .devcontainer/, not in the project root.
- If you see a JSON parse error: open managed-settings.json and check for missing commas or extra trailing commas after the last item in arrays.
help me debug my Docker build error
Your container image is built and ready. Next up, you will write the firewall script that locks down all network traffic except the domains Claude Code needs.
Create the Network Firewall
Your Dockerfile and managed settings lock down Claude Code's permissions at the software layer. But software-level rules can theoretically be bypassed if Claude finds a way to modify its own configuration files or make network requests through unexpected channels.
The fix is a kernel-level firewall that operates below the application. In this step, you will write an iptables firewall script that blocks all outbound traffic from the container except for a specific allowlist of domains. Even if Claude bypassed every software guardrail, it physically cannot reach the internet outside the allowlist.
In this step, get ready to:
- Write a firewall script that blocks all outbound traffic except Anthropic's API and required services.
- Make the script executable so the container can run it on startup.
- Understand how kernel-level network isolation prevents data exfiltration.
Write the firewall initialization script
The firewall script runs once each time the container starts. It configures the Linux kernel's packet filtering using iptables and ipset to create a strict allowlist of domains Claude Code needs to function.
- In Cursor's Explorer sidebar, right-click the .devcontainer folder and select New File.
- Name the file init-firewall.sh.
- Add the script header and Docker DNS preservation section by pasting this code:
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
# Extract Docker DNS info BEFORE any flushing
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
# Flush existing rules and delete existing ipsets
iptables -F
iptables -X
iptables -t nat -F
iptables -t nat -X
iptables -t mangle -F
iptables -t mangle -X
ipset destroy allowed-domains 2>/dev/null || true
# Restore Docker DNS rules
if [ -n "$DOCKER_DNS_RULES" ]; then
echo "Restoring Docker DNS rules..."
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
else
echo "No Docker DNS rules to restore"
fi
What does this code do?
This first section handles a critical edge case. Docker Desktop uses an internal DNS server at 127.0.0.11 for container name resolution. If you flush iptables without saving those rules first, DNS breaks completely inside the container.
The script captures Docker's NAT rules before wiping the slate clean, then restores them immediately after flushing. This ensures the container can still resolve domain names while the rest of the firewall is configured.
- Below the Docker DNS section, add the allowlist configuration that defines which traffic is permitted:
# Allow DNS and localhost before any restrictions
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A INPUT -p udp --sport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
# Create ipset with CIDR support
ipset create allowed-domains hash:net
# Resolve and add allowed domains
for domain in \
"api.anthropic.com" \
"claude.ai" \
"registry.npmjs.org" \
"sentry.io" \
"statsig.anthropic.com" \
"statsig.com"; do
echo "Resolving $domain..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then
echo "ERROR: Failed to resolve $domain"
exit 1
fi
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "ERROR: Invalid IP from DNS for $domain: $ip"
exit 1
fi
echo "Adding $ip for $domain"
ipset add allowed-domains "$ip"
done < <(echo "$ips")
done
What does this code do?
First, it allows DNS (UDP port 53), SSH (TCP port 22), and localhost traffic. These must be whitelisted before the default DROP policies take effect, otherwise the script itself cannot resolve the allowed domains.
Then it creates an ipset called allowed-domains and resolves each domain in the allowlist to its current IP addresses using dig. Each IP is validated and added to the set. The allowlist includes api.anthropic.com (Claude API), claude.ai (authentication), registry.npmjs.org (npm packages), and Anthropic's telemetry endpoints.
- Finally, add the enforcement section that locks down all other traffic and verifies the firewall works:
# Get host IP from default route
HOST_IP=$(ip route | grep default | cut -d" " -f3)
if [ -z "$HOST_IP" ]; then
echo "ERROR: Failed to detect host IP"
exit 1
fi
HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
echo "Host network detected as: $HOST_NETWORK"
# Allow host network communication (required for Docker Desktop)
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
# Set default policies to DROP
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
# Allow established connections
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow only traffic to allowed domains
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# Reject all other outbound for immediate feedback
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall configuration complete"
echo "Verifying firewall rules..."
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - was able to reach https://example.com"
exit 1
else
echo "Firewall verification passed - unable to reach https://example.com as expected"
fi
What does this code do?
This section detects the Docker host network (required for Docker Desktop communication between your Mac and the container) and allows traffic to it.
Then it sets the default policy for all chains (INPUT, FORWARD, OUTPUT) to DROP. This means any traffic not explicitly allowed by a rule above is blocked. The script then allows established connections (so responses to allowed requests can come back) and traffic to the allowed-domains ipset.
The final REJECT rule uses icmp-admin-prohibited so that blocked connections fail immediately with a clear error instead of timing out silently. The verification at the end confirms the firewall is working by attempting to reach example.com (which should be blocked).
- Save init-firewall.sh.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
# Extract Docker DNS info BEFORE any flushing
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
# Flush existing rules and delete existing ipsets
iptables -F
iptables -X
iptables -t nat -F
iptables -t nat -X
iptables -t mangle -F
iptables -t mangle -X
ipset destroy allowed-domains 2>/dev/null || true
# Restore Docker DNS rules
if [ -n "$DOCKER_DNS_RULES" ]; then
echo "Restoring Docker DNS rules..."
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
else
echo "No Docker DNS rules to restore"
fi
# Allow DNS and localhost before any restrictions
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A INPUT -p udp --sport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
# Create ipset with CIDR support
ipset create allowed-domains hash:net
# Resolve and add allowed domains
for domain in \
"api.anthropic.com" \
"claude.ai" \
"registry.npmjs.org" \
"sentry.io" \
"statsig.anthropic.com" \
"statsig.com"; do
echo "Resolving $domain..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then
echo "ERROR: Failed to resolve $domain"
exit 1
fi
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "ERROR: Invalid IP from DNS for $domain: $ip"
exit 1
fi
echo "Adding $ip for $domain"
ipset add allowed-domains "$ip"
done < <(echo "$ips")
done
# Get host IP from default route
HOST_IP=$(ip route | grep default | cut -d" " -f3)
if [ -z "$HOST_IP" ]; then
echo "ERROR: Failed to detect host IP"
exit 1
fi
HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
echo "Host network detected as: $HOST_NETWORK"
# Allow host network communication (required for Docker Desktop)
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
# Set default policies to DROP
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
# Allow established connections
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow only traffic to allowed domains
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# Reject all other outbound for immediate feedback
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall configuration complete"
echo "Verifying firewall rules..."
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - was able to reach https://example.com"
exit 1
else
echo "Firewall verification passed - unable to reach https://example.com as expected"
fi
Seeing syntax errors in the script?
Make sure the for domain in \ loop uses backslashes at the end of each line (no trailing spaces after the backslash). A space after \ breaks the line continuation.
Also check that the shebang line #!/bin/bash is the very first line with no blank lines above it.
help me fix a syntax error in my init-firewall.sh bash script
Make the script executable
Linux requires files to have the executable permission bit set before they can run as scripts. Without this, the container's postStartCommand will fail with a permission denied error.
- Mark the script as executable by running this command in Cursor's terminal:
chmod +x .devcontainer/init-firewall.sh
- Confirm the permission was set by running:
ls -la .devcontainer/init-firewall.sh
You should see -rwxr-xr-x at the start of the output. The x characters confirm the file is executable.
Still seeing -rw-r--r-- without the x?
Make sure you are running chmod from the hardened-claude project root folder, not from inside .devcontainer/. If you are already inside .devcontainer/, use chmod +x init-firewall.sh instead (without the folder prefix).
help me set executable permissions on my init-firewall.sh script
Understand what the firewall blocks
With this script in place, any attempt by Claude Code to open network connections to domains not in the allowlist gets rejected at the Linux kernel level. This is fundamentally different from the software-level deny rules in your managed settings.
Why is kernel-level blocking stronger than managed settings?
Managed settings tell Claude Code's application layer to refuse certain actions. But the application is the thing being constrained. If a prompt injection found a way to manipulate Claude's behavior, it could theoretically bypass application-level checks.
The iptables firewall runs in the Linux kernel, completely outside Claude Code's process. Claude cannot modify, disable, or bypass it because it does not have root access and the firewall operates at a lower level of the system stack. Even if Claude ran curl https://evil.com directly, the kernel would reject the packet before it ever left the container.
Why these specific domains?
- api.anthropic.com and claude.ai are required for Claude Code to communicate with Anthropic's API and authenticate.
- registry.npmjs.org is needed if Claude installs npm packages during development.
- sentry.io, statsig.anthropic.com, and statsig.com handle error reporting and feature flags that Claude Code expects to reach.
Your firewall script is ready. Next up, you will wire all three files together in devcontainer.json and launch the hardened container for the first time.
Configure devcontainer.json
Your Dockerfile, firewall script, and managed settings are all ready. Now you need the orchestration file that ties them together.
The devcontainer.json file tells Cursor exactly how to build the image, what Linux capabilities to grant, which volumes to mount, and what to run after the container starts. Once this file exists, a single command palette action launches the entire hardened environment.
In this step, get ready to:
- Create devcontainer.json with build config, network capabilities, and environment variables.
- Launch the dev container from Cursor's command palette.
- Sign in to Claude Code inside the running container.
Create the devcontainer.json
The Dev Containers spec uses a single JSON file to define the entire container lifecycle. This is where you declare the capabilities your firewall needs, mount your project files into the container, and tell Cursor to activate the firewall after startup.
- Create a new file called devcontainer.json inside the .devcontainer folder.
- Paste the following configuration into the file:
{
"name": "Hardened Claude Code",
"build": {
"dockerfile": "Dockerfile",
"args": {
"TZ": "${localEnv:TZ:America/Los_Angeles}",
"CLAUDE_CODE_VERSION": "latest"
}
},
"runArgs": [
"--cap-add=NET_ADMIN",
"--cap-add=NET_RAW"
],
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code"
]
}
},
"remoteUser": "node",
"mounts": [
"source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume"
],
"containerEnv": {
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"DISABLE_AUTOUPDATER": "1",
"CLAUDE_CONFIG_DIR": "/home/node/.claude"
},
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
"waitFor": "postStartCommand"
}
What does this configuration do?
- runArgs grants NET_ADMIN and NET_RAW capabilities so iptables can configure the kernel firewall inside the container.
- mounts creates a named volume for /home/node/.claude so your Claude Code authentication persists across container rebuilds.
- workspaceMount bind-mounts your local project folder into /workspace so edits inside the container appear on your Mac.
- containerEnv disables telemetry and auto-updates, reducing unnecessary network traffic that the firewall would block anyway.
- postStartCommand runs your firewall script with sudo after the container boots. waitFor ensures Cursor waits for the firewall to finish before showing you a terminal.
- Save devcontainer.json.
- Confirm you now have four files in the .devcontainer folder by running:
ls .devcontainer
You should see Dockerfile, devcontainer.json, init-firewall.sh, and managed-settings.json listed.
Missing a file?
Make sure all four files are directly inside .devcontainer/ (not nested in a subfolder). If one is missing, create it now before launching the container.
Help me figure out which .devcontainer file is missing
Launch the dev container from Cursor
With all the configuration in place, you can now tell Cursor to build the Docker image and reopen your project inside the container. The first build takes 2-3 minutes because Docker needs to download the base image and install all packages.
- Press Cmd+Shift+P to open the Command Palette.
- Type Dev Containers: Reopen in Container and press Enter.
- Cursor will reload and begin building the container. Watch the terminal output in the lower panel.
What happens during the build?
Cursor pulls the node:20 base image, installs your firewall packages, installs Claude Code globally, and copies your managed settings and firewall script into the image. After the image is built, the container starts and runs init-firewall.sh automatically.
- Wait for the build to complete. In the terminal log, look for the line:
Firewall verification passed - unable to reach https://example.com as expected
This confirms your firewall is active. The container blocked an outbound request to a domain not in your allowlist.
Build failed or stuck?
Make sure Docker Desktop is running (look for the whale icon in your menu bar, steady and not animating). If the build fails with a permissions error on init-firewall.sh, confirm you ran chmod +x .devcontainer/init-firewall.sh in the previous step.
If the firewall verification fails (says it was able to reach example.com), check that your runArgs include both --cap-add=NET_ADMIN and --cap-add=NET_RAW in devcontainer.json.
Help me debug my dev container build failure
Sign in to Claude Code
Your terminal is now running inside the container (you can confirm by the green remote indicator in the bottom-left corner of Cursor). The last step is to authenticate Claude Code so it can reach Anthropic's API through the firewall.
- Open the Cursor terminal (it is already running inside the container).
- Launch Claude Code by running:
claude
- Follow the authentication prompt that appears. This opens a browser window for you to sign in with your Anthropic account.
- After authenticating, return to the Cursor terminal. You should see the Claude Code REPL ready for input.
Why does auth work through the firewall?
Your firewall allowlist includes api.anthropic.com and claude.ai. These are the domains Claude Code needs for authentication and API requests. Everything else is blocked at the kernel level.
Authentication not completing?
If the auth flow times out, your firewall may be blocking the authentication domain. Double-check that claude.ai is listed in the for domain in loop inside init-firewall.sh.
Help me fix Claude Code authentication inside my dev container
✔️ Awesome, I've got everything!
Great. Double check you have saved devcontainer.json and that Claude Code is authenticated inside the container.
ⓧ I'd like to double check the full code
Here is the complete devcontainer.json file:
{
"name": "Hardened Claude Code",
"build": {
"dockerfile": "Dockerfile",
"args": {
"TZ": "${localEnv:TZ:America/Los_Angeles}",
"CLAUDE_CODE_VERSION": "latest"
}
},
"runArgs": [
"--cap-add=NET_ADMIN",
"--cap-add=NET_RAW"
],
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code"
]
}
},
"remoteUser": "node",
"mounts": [
"source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume"
],
"containerEnv": {
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"DISABLE_AUTOUPDATER": "1",
"CLAUDE_CONFIG_DIR": "/home/node/.claude"
},
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
"waitFor": "postStartCommand"
}
Claude Code is now running inside a hardened container with a live firewall. Next up, you will put the entire defense stack to the test by trying to break out of each layer.
Test Your Hardened Environment
Your container is running, the firewall is active, and Claude Code is authenticated. Now it is time to prove each security layer actually works.
In this step, you will systematically test all three boundaries: the network firewall, the managed settings, and the filesystem isolation. If any of these fail silently, the hardening is just decoration.
In this step, get ready to:
- Verify the iptables firewall blocks outbound traffic to non-allowlisted domains.
- Confirm managed settings deny rules prevent Claude from reading protected files.
- Test filesystem boundaries restrict access to only the container's own filesystem.
Test network isolation
The iptables firewall you configured drops all outbound traffic except to the domains in your allowlist. The fastest way to confirm this is to attempt a request to a domain that is NOT on the list.
- In the Cursor terminal (running inside the container), attempt to reach a blocked domain by running this command:
curl https://example.com
The command should fail quickly. You will see an error indicating the connection was rejected or that there is no route to the host.
Why does it fail immediately?
Your firewall's last rule uses REJECT --reject-with icmp-admin-prohibited instead of a silent DROP. This sends an immediate ICMP rejection back to the requesting process, so curl fails instantly rather than hanging until a timeout.
- Now verify that allowlisted traffic still works by running:
curl https://api.anthropic.com
This command should succeed and return a response (likely an HTML page or JSON error from the API since you are not sending a valid request). The key point is that the connection is established successfully.
What does this prove?
The first command failing proves the firewall blocks arbitrary outbound connections. The second command succeeding proves the allowlist works correctly for api.anthropic.com.
Together, this means even if a prompt injection tricked Claude into running curl to exfiltrate data, the kernel-level firewall would block it before the packet leaves the container.
Both commands succeed?
If curl https://example.com returns content instead of failing, the firewall script did not run successfully. Check the terminal output from when the container started for errors during postStartCommand.
You can manually re-run the firewall script to see its output:
sudo /usr/local/bin/init-firewall.sh
Look for DNS resolution failures or ipset errors. If it reports a domain failed to resolve, your container may not have DNS connectivity.
help me debug why my iptables firewall is not blocking traffic inside the dev container
Test managed settings enforcement
Your managed settings file at /etc/claude-code/managed-settings.json denies reading .env files and blocks commands like curl and rm -rf. Because allowManagedPermissionRulesOnly is set to true, no project-level or user-level settings can override these rules.
- Start Claude Code by running:
claude
- Once Claude Code starts, send this prompt:
- read the .env file
Claude should refuse this request. The managed deny rule Read(**/.env) blocks any attempt to read files matching that pattern.
- Next, send this prompt to Claude:
- show me your settings file at /etc/claude-code/managed-settings.json
Claude can read the managed settings file (it is a regular file inside the container). However, it cannot modify it because the file is owned by root, and Claude runs as the node user.
- Test that Claude cannot overwrite the managed settings by sending:
- create a file at /etc/claude-code/managed-settings.json with empty content
This should fail with a permission denied error at the OS level. The managed settings are immune to self-modification.
Why does this matter?
Without managed settings, Claude could theoretically modify its own .claude/settings.json to remove deny rules. Managed settings live outside Claude's writable paths and enforce rules at the highest precedence in the settings hierarchy.
The allowManagedPermissionRulesOnly flag goes further. It prevents any lower-precedence settings from adding their own allow or deny rules, locking the permission surface to exactly what you defined.
Claude reads the .env file anyway?
If Claude does not refuse the read request, the managed settings may not be in the correct path inside the container. Verify the file exists by running:
cat /etc/claude-code/managed-settings.json
If the file is missing, your Dockerfile's COPY managed-settings.json /etc/claude-code/managed-settings.json line may not have executed during the build. Rebuild the container with Cmd+Shift+P and select Dev Containers: Rebuild Container.
help me figure out why managed settings are not being enforced in my Claude Code dev container
Test filesystem boundaries
The container provides a filesystem boundary between Claude and your Mac. Only the /workspace directory (your project folder) is bind-mounted from your local machine. Everything else inside the container is isolated Linux filesystem.
- In your Claude Code session, send this prompt:
- read /etc/passwd
Claude can read this file. It is the container's own /etc/passwd (a standard Linux file listing system users), not your Mac's password file.
- Now send this prompt:
- list files in /Users/
This path does not exist inside the container. On your Mac, /Users/ contains your home directory and all your personal files. Inside the container, that entire directory tree is simply absent.
What does this prove?
Claude can only see what is explicitly mounted into the container. Your workspaceMount in devcontainer.json binds only your project folder to /workspace. Claude cannot access your SSH keys, browser data, other projects, or any file on your Mac outside the project.
Combined with the network firewall, this means Claude cannot read sensitive files AND cannot exfiltrate them. Both the source and the channel are blocked.
Claude can see /Users/ or other Mac paths?
If /Users/ exists inside the container, an additional volume mount may be leaking your Mac's filesystem in. Check your devcontainer.json and confirm there are no extra entries in the mounts array beyond the claude-code-config volume.
Also confirm you are running inside the container (your terminal prompt should show a container hostname, not your Mac's name).
help me check if my dev container is accidentally mounting my Mac filesystem
Secret mission
Bring Your Hooks Inside the Container
Your container has kernel-level network isolation and managed settings enforcement. But what about the fine-grained command blocking you built in Guardrails Part 1? Layer your hooks inside the container to complete the full defense-in-depth stack.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker Desktop, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker image (stored locally on your Mac).
- Named volume claude-code-config-* (stores Claude Code authentication, persists on your Mac).
- hardened-claude project folder on your Desktop (contains .devcontainer/ and .claude/ directories).
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment more with your hardened container.
- The container stays active in Cursor. Close it anytime by closing the Cursor window.
- The Dev Containers extension and Docker Desktop remain installed on your Mac.
- Your .devcontainer/ folder is portable. You can copy it into any other project to get the same hardened environment.
Pause - I'll come back to this later
Shut down running processes to free up memory while keeping your files intact.
- Close the Cursor window. The container stops automatically when Cursor disconnects.
- Optionally quit Docker Desktop from the whale icon in your menu bar to reclaim memory.
- To resume later, open Docker Desktop, then open the hardened-claude folder in Cursor and select Dev Containers: Reopen in Container from the Command Palette.
Delete - I don't want to use this again
Remove all project resources and start fresh. Make sure you are in a local terminal on your Mac (not inside the container).
- Close the Cursor window to stop the running container.
- Open a new terminal on your Mac and remove all unused Docker images and stopped containers by running:
- Find your auth volume name by running:
- Look for a volume starting with claude-code-config- and remove it by running:
- Delete the project folder from your Desktop by running:
Nice Work!
Nice Work!
You just built a production-grade hardened environment for Claude Code. Your AI coding assistant now operates inside a locked-down Docker container with kernel-level network isolation and tamper-proof permission rules.
You've learned how to:
- Build a .devcontainer/ configuration with a Dockerfile, iptables firewall script, and managed settings that launches a hardened container from Cursor.
- Configure an iptables firewall with an ipset allowlist that blocks all network egress except Anthropic's API and required services.
- Elevate permission deny rules to managed settings at the highest enforcement precedence, making them immune to self-modification by Claude Code or any project-level override.
- Secret Mission: Layered your Part 1 hooks inside the container for full defense-in-depth (permissions + hooks + managed settings + network firewall + container isolation).
Ready to quiz yourself?