Claude Code: Dev Containers and How-to Work Securely with AI Agents
This post is from the "Claude Code Setup" series
- Dev Containers and How-to Work Securely with AI Agents
- System prompting and commands
- Spec-Driven Development (SDD)
Intro
I’ve been using AI for 3 years now, but most recently I’ve been coding almost exclusively with Claude Code. This blog is one of the first projects that I completed using it.
I built my blog in a day using a very raw approach to software development. Basically, I had a conversation with the bot and asked him to change various things as I progressed.
That’s good and all for experiments and prototyping, but as your projects grow bigger, certain patterns begin to emerge and it’s hard to describe everything that one has to do in order to have a steady, viable development flow with AI agents.
This article is about setting up devcontainers in order to have a secure dev environment while working with AI agents like Claude Code.
Basic environment setup for AI assisted development
Here’re several things that I do nowadays (about a month after I’ve created the blog and I’m more than actively using Claude Code to produce production ready applications. At the time of writing this, the blog itself has a rather “outdated” codebase, in terms of what one can refer to as “AI derrived technical debt”.),
When setting up a new project now, I’d go through the following checklist:
-
I create a general
.devcontainerfor my application. Since I’m building from scratch, I begin with monolithic approach and I split out code when absolutely necessary for scalability. So my.devcontainerhas the capacity to run all the applications I’m working with, plus whatever is required for Claude Code to run steadily.I do this to isolate my PC from Claude to achieve security and data integrity. I can restore missing code with version control and I can live with 1 commit being deleted accidentally, but the rest of my system is exposed, since Claude is running with the same privileges, as my local user is and it runs
shell commands. This is not just something to be careful about, it’s an active vector for malicious attacks and command injection. -
After, my environment is secure and capable of running all my apps in dev mode, I would try to setup my primary agent entry -
CLAUDE.md(AGENTS.mdis a more general entry that is also viable option, but in since I’m using Claude code, I’m sticking to the named file format).For a new project, I’d reuse an existing
CLAUDE.mdfrom a recent similar project. If I have nothing good to reuse, I’ll continue to the next step and come back toCLAUDE.mda bit later, when I have some context for the app. -
I will add common Claude commands. These would include commands for the most common actions that I take, when trying to write better and more consistent code:
-
spec commands - used to manage spec files: create, update, verify, apply, refactor with. The idea is to able to persist reusable context long-term. That’s why specs are keept it in the repo, to be close to the code and to be able to do ad-hoc changes on the fly with predefined prompts. Specs are usually kept in the repo as
specs/**/*.md. -
test commands - working with multiple apps, requires different type of tests and approaches to testing. One can verify that web frontend and a service written in Elixir would have different testing concepts. Test commands are context specific and require a specific instruction set to be considered, when generating code for coverage
-
git assist - I don’t ask my agent to push code for me, but I do ask it to commit staged code for me. It would generate a smart message and run the husky pre-commit hook and fix issues, if any.
One might need to grant their agents
read/writepermissions when running a multi-agent pipeline of sorts, which goes and produces some kind of code end-to-end, where pushing changes to the commit tree trigger actions for other other agents. Still, if you work with an AI agent on a machine that uses your personal git access tokens, in my humble opinion,pushandpullshould be reserved only to the host machine.Given unrestricted access to your repository, the agent could modify your commit tree and force push changes, potentially erasing commit history. In addition, in the case of GitHub for example, if a token by any chance provides further credentials, it could potentially modify the repository settings, to grant itself capabilities previously unavailable (like disabling branch restrictions on main to allow
--forcepush). -
Secure-first approach to working with AI agents
The benefits of using devcontainers vary from:
- improving onboarding of new team members
- setting up CI pipelines
- eliminating “runs on my machine”
- etc.
Nowadays there’s an open-source devcontainer toolchain that integrates almost natively with VS Code.
My personal reason to quickly integrate devcontainers for my AI assited dev environment is quite more simplistic, yet serious.
NOTE: If the above Reddit link goes missing - it is a post on Reddit, where a dude got his entire home directory deleted by Claude Code (the bot ran
rm -rf tests/ patches/ plan/ ~/, when it was doing a supposedly controlled code cleanup. Note the last~/, that wiped the contents of the user’s home directory, to which… I don’t know about you, but for me personally, it would be beyond tragic).
Setting up a devcontainer
For a node app, such as this blog, a basic setup go something like that…
Dockerfile
This is a cleaned-up version of the devcontainer Dockerfile that I use for the blog.
NOTE: For my devcontainers I also install
zshwith oh-my-zsh for personal shell pleasure. I also set uppnpmand grantsudoprivileges to the vscode user. I do this for debugging only, you shouldn’t do this for prod containers!
.devcontainer/Dockerfile
# Development container for Astro.js blog
FROM node:22-bookworm
# Avoid prompts from apt
ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
git \
netcat-openbsd \
sudo \
vim \
wget \
&& rm -rf /var/lib/apt/lists/*
# Install Claude CLI
RUN npm install -g @anthropic-ai/claude-code
# Set working directory
WORKDIR /workspace
# Default command
CMD ["sleep", "infinity"]
docker-compose
I initially started using the devcontainer CLI with
a devcontainer.json configuration, however, eventually
I had more issues with it and just migrated to using plain docker-compose.yml
running my Dockerfile for my apps.
It made my life easier when it came to forwarding ports, using resources within my docker network, etc. In addition, I run my dev container from the terminal and not my text editor. You still have the choice to have a full integration with it (or JetBrains, but outside of that, I’m not sure what’s the support and plugin marketplace for other text editors and IDEs).
.devcontainer/docker-compose.yml
services:
devcontainer:
build:
context: .
dockerfile: Dockerfile
image: blog-dev-container:latest
container_name: blog-dev-container
ports:
- 4321:4321
- 6006:6006
volumes:
- ./.home:/home/vscode/
NOTE: the volumes links .devcontainer/.home/ to the current user’s
home directory, allowing me to rebuild the image and keep my auth sessions,
as well as to keep in the repository configuration and dotfiles
such as .claude/settings.json with Claude global hooks, my own .zshrc with
custom aliases, oh-my-zsh configuration, etc.
This requires me to add some ugliness to
.gitignore, but it’s manageable
# Devcontainer
.devcontainer/.home/*
!.devcontainer/.home
!.devcontainer/.home/.zshrc
!.devcontainer/.home/.claude
.devcontainer/.home/.claude/*
!.devcontainer/.home/.claude/settings.json
Setting up agent notifications
Working with an agent would require your attention once in a while to know when a job has completed or a question by the AI is pending an answer.
Since the docker environment doesn’t have a native way to propagate notifications downstream to the host machine (or at least to my knowledge), I use a bit more complex setup, which relies on:
- Having a shell script that accepts messages via sockets and runs a command to show notifications on my docker host machine.
- Exposing the script using
socaton port4545 - Claude hooks to emit a message to
host.docker.internal:4545usingnetcat.
Propagating notifications
To propagate messages from the devcontainer downstream to my machine, I pipe messages to the
notifier.sh script with SOcket CAT on port 4545.
Start the listner
pgrep -f 'socat TCP-LISTEN:4545' >/dev/null || \
( \
nohup socat TCP-LISTEN:4545,reuseaddr,fork \
EXEC:.devcontainer/notifier.sh >/tmp/hostcmd.log 2>&1 & \
echo '🔔 Notifier started.'
)
Stop the listener
pkill -f 'socat TCP-LISTEN:4545' && \
echo '🔕 Notifier stopped.'
WARNING: Using socat directly with a
shell(e.g.EXEC:/bin/bash) entirely beats the purpose of trying to secure your local environment from arbitrary command injection, you’d effectively give the bot the opportunity to run any commands on your machine withncmessages tosocat.
notifier.sh
I use a script wrapping agent-to-host shell functionality as predefeind “commands”,
this way I effectively prevent the AI agent from executing dangerious commands
on my machine, such as printf 'rm -rf ~' | nc host.docker.internal 4545.
.devcontainer/notifier.sh
#!/bin/bash
read -r CMD ARG
case "$CMD" in
notify)
terminal-notifier -title "Claude" -message "$ARG" -open "iterm2://session"
;;
*)
echo "DENIED"
;;
esac
I use terminal-notifier to send
clickable notificatinos. In this case they have a title “Claude” and clicking on them
focuses my iTerm. This can be further improved to focus a specific iTerm tab,
however I cannot achieve it, since I use tmux and it’s terminal session IDs can belong
to tabs that have been long gone, because of the way tmux session service works (e.g.
they persist through tab closure).
Connecting Claude hooks
Notifications are sent using 2 Claude hooks:
- Notification/permission_prompt - triggered when the agent is waiting for my response
- Stop/* - triggered when a task is completed
To send notifications, we use netcat to send a message
to the notifier.sh script on port 4545.
.devcontainer/.home/.claude/settings.json
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "printf 'notify blog (waiting)\n' | nc host.docker.internal 4545"
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "printf 'notify blog (done)\n' | nc host.docker.internal 4545"
}
]
}
]
}
}
Running devcontainers locally
Basic docker commands
You would start your devcontainer like a standard docker container using
docker compose. Here’s an excerpt from this blog’s package.json and the pnpm
commands that I’m using at the time of writing this article.
{
"devcontainer": "docker compose -f .devcontainer/docker-compose.yml",
"devcontainer:build": "pnpm devcontainer build",
"devcontainer:rebuild": "pnpm devcontainer build --no-cache",
"devcontainer:exec": "pnpm devcontainer:up && pnpm devcontainer exec devcontainer",
"devcontainer:up": "pnpm devcontainer up -d && pnpm devcontainer:notifier:up",
"devcontainer:notifier:up": "pgrep -f 'socat TCP-LISTEN:4545' >/dev/null || (nohup socat TCP-LISTEN:4545,reuseaddr,fork EXEC:.devcontainer/notifier.sh >/tmp/hostcmd.log 2>&1 & echo '🔔 Notifier started.')",
"devcontainer:notifier:stop": "pkill -f 'socat TCP-LISTEN:4545' && echo '🔕 Notifier stopped.'",
"devcontainer:shell": "pnpm devcontainer:exec zsh",
"devcontainer:stop": "pnpm devcontainer:notifier:stop; pnpm devcontainer down",
"devcontainer:claude": "pnpm devcontainer:exec sh -lc 'claude -c || claude'"
}
Worth noting
Maybe you’d like this working differently, however I found the following mods quite useful:
devcontainer:execalso lifts the devcontainer for ease of use.devcontainer:shellcommand startszsh. From here I run tests/builds/dev or directly start claude, if I want to.devcontainer:claudecontinues the current Claude session or starts a fresh claude, if no previous conversation is found, as the agent exits, if there’s no previous conversation.devcontainer:notifier:*commands are super lengthy and an excellent script candidate, I keep them like that only in order to copy/paste commands across projects.
Rule of thumb
It’s worth mentioning, that my containers have different setup from my local Mac
environment. This causes pnpm install ran through the container and locally
to behave differently.
In an ideal world, you’d run install and build commands only inside the devcontainer.
This is where Claude would be running builds and tests, so don’t want to rebuild dependencies when you run tests on your local machine, simply run them inside the devcontainer, same goes for starting applications for local dev and possibly CI.
-
Run Claude Code from the devcontainer
-
Run your applications from the devcontainer when possible. Forward relevant application ports and serve from
0.0.0.0inside the container, to access it onlocalhostfrom the docker host machine.NOTE: This is required, especially for people coding on ARM CPU with containers running x86. At the same time, by simply using different library versions on host and container (e.g.
pnpm,mix, etc.). This way both you and the agent can run/build/test the same code. -
Commit code from the host, do not expose Claude to your repository. You’ll be effectively sharing the
sshkey that you’re using forgitwith the agent.
Setting up devcontainers is easy and almost effortless. It will help you by creating consistency when running and testing applications with both the agent, CI and other users on your team, and will add an extra layer of security for your environment when working with AI agents by protecting you from arbitrary command injection and sporadic malicious agent behaviour.