A devcontainer for Elixir and Phoenix development with optional Claude Code, Codex, Rust, Bun, and Tidewave.
The container makes it safe(-er) to run Claude with --dangerously-skip-permissions by:
- Restricting all outbound traffic to a domain allowlist via a strict network firewall
- Hiding sensitive files (e.g.
.env) from Claude using file permissions, with Claude deny rules as a secondary safeguard - Isolating Claude state in a per-project
.claudefolder, separate from your host's~/.claude
- Copy the
.devcontainer/directory into your Elixir project root with:
curl -sL https://github.com/PJUllrich/devcontainer/archive/refs/heads/main.tar.gz \
| tar xz --strip-components=1 devcontainer-main/.devcontainer- Create a root
Makefilethat includes the devcontainer Makefile with:
echo 'include .devcontainer/Makefile' > Makefile- Add project-specific environment variables in
devcontainer.json - Customize the protected filepaths in
protected-paths.txt - Customize the allowed domains in
allowed-domains.txt - Enable any optional tools you need as described below.
- Run
make dc.upto start the container, thenmake dc.shellto open a shell inside it. - If enabled, run
make dc.claudeto start Claude in unsafe mode. - To access Tidewave, run
make dc.tidewave.bgto start Tidewave in the background (exposed on localhost:9833) and then start your app withmake dc.serverormix phx.server.
Rust, Bun, Codex, and Claude Code default to disabled to keep the image small. Enable the tools you need by changing their build arguments at the top of .devcontainer/Dockerfile to true before building:
ARG INSTALL_RUST=true
ARG INSTALL_BUN=true
ARG INSTALL_CODEX=true
ARG INSTALL_CLAUDE_CODE=trueRust and Bun versions are pinned by the adjacent RUST_VERSION and BUN_VERSION arguments.
Run make list to see all available commands.
Edit .devcontainer/allowed-domains.txt to add or remove domains:
# A leading dot matches the domain and all subdomains
.example.com # matches example.com, api.example.com, etc.
# Without a leading dot, only the exact domain is matched
cdn.example.com # matches cdn.example.com onlyAfter changing the file, rebuild the container with make dc.rebuild.
Environment variables are configured in .devcontainer/devcontainer.json in two sections:
For secrets and values that should refresh from your host on each container restart:
The ${localEnv:VAR_NAME} syntax pulls the value from your host machine's environment. Set these variables in your shell profile (e.g., ~/.zshrc) or use direnv or dotenv.
For configuration that is baked in at container creation time and does not change between restarts:
"containerEnv": {
"CLAUDE_CONFIG_DIR": "/home/dev/.claude",
},Files listed in .devcontainer/protected-paths.txt are hidden from the container at startup using bind mounts. The container sees an empty file (or directory) in place of the original — the host files are unaffected.
# Exact filename in the project root
.env
.env.*
# Path relative to the project root
config/secrets.yml
# Recursive match (any depth)
**/.envBy default, .env and .env.* are protected. Edit the file to add project-specific paths, then rebuild with make dc.rebuild.
On container start, init-file-protection.sh also creates a Claude project-level settings.json with deny rules generated from protected-paths.txt as a secondary safeguard. For example, the default protected-paths.txt produces:
{
"permissions": {
"deny": [
"Read(path:**/.env)",
"Read(path:**/.env.*)"
]
}
}This file is written into the project's .claude/ directory on container start.
The container connects to your host's Postgres instance assumed to run in Docker via host.docker.internal. To make this work, configure the hostname in config/dev.exs and config/test.exs:
# config/dev.exs **and** config/test.exs
config :my_app, MyApp.Repo,
hostname: System.get_env("DATABASE_HOST", "localhost")The devcontainer.json sets DATABASE_HOST to host.docker.internal via remoteEnv, so the repo will connect to your host's Postgres when running inside the container and to localhost when running outside it.
Git is available inside the container with safe.directory pre-configured for /workspace and all worktree paths. Credentials are forwarded automatically by the devcontainer CLI when your host has a Git credential helper configured (e.g. gh auth).
The container uses a layered approach to make --dangerously-skip-permissions safer:
All outbound HTTP/HTTPS traffic is transparently intercepted by Squid and filtered against the domain allowlist in allowed-domains.txt. Unlike a traditional forward proxy that relies on processes respecting HTTP_PROXY environment variables, this uses iptables REDIRECT rules to catch all traffic regardless of the client. HTTP is filtered by Host header, HTTPS by reading the SNI hostname from the TLS ClientHello via peek-and-splice — no TLS decryption happens. All other outbound traffic (except DNS, localhost, and the Docker host network) is dropped by default.
On container start, init-firewall.sh verifies the rules by confirming that blocked domains are unreachable and allowed domains are reachable. If any check fails, the container will not start.
The firewall reduces the attack surface significantly but is not a complete sandbox:
- No general sudo: The
devuser only has scoped sudo access for the firewall init script. Claude cannot escalate privileges to flush iptables rules or modify system configuration. If you need general sudo for ad-hoc tasks, you can add it back in the Dockerfile, but this weakens the firewall guarantee. - Runtime environment variables: The
.envdeny rules prevent reading.envfiles, but secrets injected viaremoteEnvare still visible throughprintenvor/proc/self/environ. Avoid putting highly sensitive secrets inremoteEnvif this is a concern. - Non-HTTP protocols: The firewall only restricts ports 80 and 443. Traffic on other ports (other than DNS and localhost) is blocked by the default DROP policy, but if you add custom allow rules, those channels are unfiltered.
npm install over the native Claude installer: When enabled, Claude Code is installed via npm install -g @anthropic-ai/claude-code rather than the native install script. The npm install is faster and produces a cacheable Docker layer.
The Dockerfile uses hexpm/elixir as the base image. To change the Elixir or OTP version, edit the build args at the top of .devcontainer/Dockerfile:
ARG ELIXIR_VERSION=1.20.3
ARG OTP_VERSION=29.0.5
ARG DEBIAN_VERSION=trixie-20260803-slim