Building a dev setup for the AI era
We already had a local development setup.
It was not beautiful, but it worked. There was a docker-compose.yml file. I could start the services I needed and do my job. Some engineers used Docker Compose too. Others ran parts of the system directly on their machines. People used different editors as well: VS Code, Cursor, Zed, or just a terminal. For a backend project, this was normal.
Then Claude Code entered the picture.
That changed how I looked at the whole setup. The question was no longer just:
Can a developer run the project locally?
It became:
Can a developer and an AI agent work in this project without giving the agent my whole laptop?
That second question is harder.
When I use an AI coding agent, I want it to do actual work. I want it to read files, run tests, inspect errors, make changes, and try again. If I have to approve every small command, the flow breaks. At some point the tool starts feeling like a very slow autocomplete.
But the other extreme is also uncomfortable. Running an agent in a permissive mode directly on the host gives it access to much more than the project. My laptop has SSH keys, shell history, browser data, personal config, other repositories, and a lot of accidental state. Even if I trust the tool, that is too much surface area.
So this work was not about using devcontainers because they are fashionable. I was trying to build a better local development setup for a world where AI agents are part of the workflow.
The setup needed to stay familiar for people who already used Docker Compose. I wanted it to feel native for people working in IDEs. Another requirement: give Claude Code enough room to be useful without exposing the whole host.
In this article, I will walk through how I took an existing Docker Compose setup and added service-specific devcontainers, ran Claude Code inside them, kept secrets and VPN access scoped to the project, and looked at what still needs stronger guardrails.
The setup was fine until it was not
Before this work, the local workflow was straightforward.
Clone the repo. Start Docker Compose, or run the services locally. Turn on the corporate VPN when staging services were needed. Run Claude Code on the host, or run it somewhere else if you had your own setup.
Nothing was obviously broken. That is probably why this kind of work is easy to postpone. The pain is spread out across small things.
One person runs everything through Docker Compose. Another person runs one service locally and the rest in containers. Debugging works in one editor but not in another. None of these problems is dramatic, but they add up.
VPN access had the same shape. Some workflows needed staging services behind the corporate VPN. Turning the VPN on globally worked, but it affected the whole machine. Routing changed. DNS changed. Other unrelated work could be affected. The project needed VPN access sometimes; my whole laptop did not.
Claude Code made these tradeoffs more visible.
An AI agent is useful when it can move with fewer interruptions. But fewer interruptions usually mean broader permissions. If those permissions are on the host, I do not like the risk. If they are inside a project container, I can live with the tradeoff.
A container is not a perfect security boundary. I would not describe this as "secure" in an absolute sense. But it is still a smaller box than my laptop, and that matters.
The rough principle became:
Give the agent room to work, but make the room smaller.
What changed with AI agents
I was thinking a lot about Dan Guido's talk on how Trail of Bits rebuilt their company around AI. The detail that stayed with me was not a specific repository or tool. It was the idea that adopting AI is not just installing a CLI.
If you want people to use these tools seriously, you need a workflow around them. You need defaults. You need a good first run. You need some policy. You need a place where the tool can act without forcing the developer to make a security decision every two minutes.
AI adoption needs the same standards as any other tool. If every developer has to decide how to install the agent, where to run it, what permissions are OK, and where config belongs, they spend energy before doing real work. A standard dev environment removes one of those decisions: the project opens the same way, the agent runs in the same kind of container, shared settings live in the repo, and personal auth stays personal.
That matched my experience.
If Claude Code asks for approval all the time, people will use it less. Or they will give it broad permissions in the easiest place: directly on the host. Nobody wants to fight their tools all day.
I wanted a middle ground:
- Claude Code runs inside the devcontainer
- the main filesystem it sees is the project
- secrets are mounted only when needed
- project guidance lives in the repo
- personal auth stays outside the repo
- later, we can add stricter network and command guardrails
This does not solve every problem. It just changes the default from "agent on my laptop" to "agent in this project environment".
For me, that is already a meaningful improvement.
Two services, one Compose stack
The project was a monorepo with a few connected services. I will call them an API Gateway and a backend service.
The API Gateway proxies requests to the backend service. They also communicate through queue-like flows. In production, that is closer to AWS SQS. Locally, Redis stands in for that piece. The backend service also needs PostgreSQL.
So the local setup had the API Gateway, the backend service, Redis, and PostgreSQL.
The design question was simple to ask and annoying to answer:
How should devcontainers work in a monorepo with multiple connected services?
One answer is to create one big devcontainer for everything. I did not like that. It makes the environment heavier, and it does not match how I usually work. Most of the time I am inside one service, even if I need the rest of the stack running nearby.
Another answer is to give each service its own full Docker Compose setup. That also felt wrong. It duplicates configuration and makes cross-service work harder.
The approach that fit this repo was somewhere in the middle.
We kept one root Docker Compose stack. Then each service got its own devcontainer config. The API Gateway devcontainer could start the pieces it needed. The backend devcontainer could start its own dependencies. When I needed to work across the boundary, both devcontainers could exist at the same time.
In practice, the structure looked roughly like this:
repo/
docker-compose.yml
api-gateway/
.devcontainer/
devcontainer.json
backend/
.devcontainer/
devcontainer.json
The names do not matter much. The important part is that docker-compose.yml stays at the root, while each service owns its own devcontainer entry point.
This is not a universal monorepo pattern. If you have dozens of services, you may need something more deliberate. But for a small set of connected services, this worked well.
The devcontainer did not replace Docker Compose. It reused it.
That distinction made the design much easier to explain. Docker Compose remained the shared runtime model. Devcontainers became the IDE-friendly entry point into that model.
Reusing Docker Compose
One thing I liked about this setup is that it did not create a second stack.
The project already had a working docker-compose.yml, so the devcontainer config pointed at it:
{
"dockerComposeFile": ["../../docker-compose.yml"],
"service": "api-gateway",
"workspaceFolder": "/workspace",
"shutdownAction": "none"
}
This is simplified, but the shape is the point.
The IDE opens inside the selected Compose service. The other services still come from the same Compose file. People who prefer terminal workflows can continue using Compose directly.
One setting was more important than I expected:
{
"shutdownAction": "none"
}
This matters when multiple devcontainers share the same Compose stack.
Imagine the API Gateway is open in one IDE window and the backend service is open in another. Closing one window should not stop services that the other window still needs. Without thinking about shutdown behavior, it is easy to make one editor window accidentally own the whole stack.
With shutdownAction: "none", the devcontainer behaves more like an entry point. It does not try to be the lifecycle manager for everything.
That is a small config detail, but it changes how the setup feels.
Keeping the container idle
Another mistake I wanted to avoid was auto-starting the app as soon as the devcontainer opened.
At first that sounds convenient. Open the container, app starts, done.
In practice, it gets in the way.
I wanted VS Code tasks and launch configs to control the workflow. Sometimes I want watch mode. Sometimes I want debug mode. Sometimes I just want a terminal with dependencies installed. If the container starts the app by default, then a task can start a second copy and hit a port conflict.
So the devcontainer should prepare the environment, then wait.
The setting for that is:
{
"overrideCommand": true
}
With this enabled, the container does not run the normal service command from Docker Compose. It stays alive, and the editor tasks decide what happens next.
For debugging Node inside the container, the inspector needs to listen on an address reachable from outside the process namespace:
node --inspect=0.0.0.0:9229 ...
Binding to localhost inside the container can be confusing because it is localhost from the container's point of view, not necessarily from the host or debugger's point of view.
I also learned to be explicit with ports. Docker Compose can publish ports. VS Code can auto-forward ports. Both features are useful, but if they both try to be clever at the same time, debugging becomes harder to reason about.
The pattern I settled on was:
- Compose defines the important service ports
- editor tasks start the app
- launch configs attach the debugger
- the devcontainer provides the environment
That separation made the workflow easier to debug.
Moving readiness into Compose
One cleanup came from a CodeRabbit review.
There was a startup script that waited for PostgreSQL with a shell loop. You have probably seen this pattern: try to connect, sleep, try again, repeat until the database accepts connections.
It worked. I have written this kind of script many times.
But in this setup, it was in the wrong layer. Docker Compose was already managing PostgreSQL, so Compose should also know when PostgreSQL is ready.
The cleaner version was to use a PostgreSQL healthcheck with pg_isready, then make the backend service wait for it:
services:
postgres:
healthcheck:
test: pg_isready -U backend -h 127.0.0.1
interval: 5s
backend:
depends_on:
redis:
condition: service_started
postgres:
condition: service_healthy
The actual username and service names depend on the project, but the idea is the same: let PostgreSQL report when it can accept connections, and let Compose use that signal.
The important part for the dependent service is this:
depends_on:
postgres:
condition: service_healthy
This moved the readiness concern closer to the service that owns it.
A post-create script should prepare the workspace. It can install dependencies or create local files. But waiting for infrastructure belongs in the infrastructure config when Compose is already responsible for those services.
CodeRabbit was useful here because it pointed at a rough edge. I still had to decide whether the suggestion fit the architecture. That is the right relationship with AI review tools, at least for me. They are good at making me look twice. They do not get to make the design decision.
Running Claude Code inside the devcontainer
This was the section I cared about most.
Installing Claude Code was only half the work. The real decision was where it should run.
That meant running it inside the devcontainer as the existing non-root node user, with the terminal starting at the repo root. This mattered because the Claude Code devcontainer docs recommend a non-root user, especially with --dangerously-skip-permissions: if I reduce approval prompts for the agent, I do not also want it running as root, and I do not want to mount more of the host than the project needs.
The repo root part sounds minor, but it matters. Claude Code needs to find project-level guidance and settings. If the terminal opens in the wrong directory, the tool may miss files like CLAUDE.md, or the developer has to remember extra setup steps.
Persistence also needed a bit of care.
A named volume for ~/.claude can preserve Claude Code's directory across container rebuilds. But it does not automatically preserve sibling files such as ~/.claude.json.
That distinction matters because tool state and auth can live in different places.
The pattern I liked was:
- project-owned Claude guidance stays in the repo
- developer-owned auth stays outside the repo
- auth is mounted through
docker-compose.override.yml - the mount is read-only where possible
For example:
services:
api-gateway:
volumes:
- ~/.claude.json:/home/node/.claude.json:ro
The /home/node path in this example is not random. It matches the non-root user inside the container. If the container uses a different non-root user, the mount path should match that user's home directory instead.
This gives the team a shared way to configure Claude Code for the project without putting personal credentials in Git.
I like this boundary. The repository can describe how the agent should behave in this codebase. The developer can bring their own auth. Those are different concerns, and they should stay separate.
Reference: Claude Code devcontainer docs.
Putting VPN access in the container
The VPN work followed the same logic.
Some workflows need staging services behind a corporate VPN. That does not mean my whole laptop should be on that VPN all day.
So I moved VPN access into the devcontainer path.
The image can include OpenVPN tooling. A local Compose override can add the device, capability, and personal .ovpn file:
services:
api-gateway:
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
volumes:
- ~/vpn/company.ovpn:/etc/openvpn/company.ovpn:ro
The real .ovpn file is personal and should not be committed. The repo can include a docker-compose.override.yml.example to show the shape of the setup, and each developer can create their own local override.
I also added an editor task to connect the VPN. That made it part of the normal development workflow instead of a separate thing I had to remember.
The rule here is simple:
Put access where the project needs it.
The app sometimes needs the VPN. My whole machine does not.
What I would add next
The setup is still not as strict as I would like.
The next thing I would look at is network control. Running Claude Code inside a container is better than running it on the host, but an open container network still allows a lot.
A stricter version could allow access to Claude Code, package registries, required internal services, and selected staging endpoints. Everything else could be blocked by default.
That sounds clean on paper. In real projects it can get annoying quickly. Package managers download from more places than you expect. Company networks have weird dependencies. Internal tools call other internal tools. A strict allowlist needs maintenance, or people will route around it.
I would also like better guardrails around destructive actions:
- deleting large directory trees
- force-pushing branches
- changing sensitive config
- installing unexpected packages
Some of this could be handled with hooks. Some of it needs team policy. Some of it may be too fragile to treat as security.
I am fine with that. Guardrails do not have to be perfect to be useful. They just need to catch enough mistakes to be worth the friction.
The result
This work started as devcontainer setup, but that is not really what it was about.
It was about making the development environment fit the way we now work.
After the changes, IDE users get a native devcontainer workflow. Terminal users can still use Docker Compose. The two connected services can run together. Claude Code can work with fewer approval prompts, but inside a project environment instead of directly on the host. VPN access can be scoped to the container. The setup is also documented in the project instead of living only in someone's head.
That feels like progress.
It does not remove all risk. It does not mean this exact design fits every repo. And it definitely does not make AI agents magically safe.
But I am much more comfortable giving Claude Code room to work inside a constrained project environment than on my laptop.
That is the practical tradeoff I wanted: fewer interruptions, more useful autonomy, and a smaller blast radius when something goes wrong.
References
These resources might be inspirational for you as they were for me. Specifically I want to mention the video by Dan Guido about his approach to transform the company to AI native. It's a top to bottom action, but you can learn a lot on hands on actions.
- Dev Container Specification
- Dev Container JSON reference
- VS Code Dev Containers documentation
- VS Code documentation on connecting to multiple containers
- Claude Code devcontainer documentation
- Trail of Bits Claude Code config
- Trail of Bits Claude Code devcontainer
- Dan Guido's talk on rebuilding Trail of Bits around AI
- Evil Martians: Ruby on Whales: Docker for Ruby & Rails development