A devcontainer for Elixir and Phoenix development with Claude Code and optionally 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
Requires the devcontainer CLI (
npm install -g @devcontainers/cli).
- 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 - Run
make dc.upto start the container, thenmake dc.shellto open a shell inside it. - 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.
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,
# other configs
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 zsh prompt shows git branch and status by default.
Worktrees let you work on multiple branches simultaneously with isolated file changes but shared mix/hex caches. They live under .worktrees/ in the project root and sync to your host via the bind mount.
# Create a worktree (new branch from HEAD)
make dc.worktree.new feature-x
# Create a worktree branching from main
make dc.worktree.new feature-x main
# List all worktrees
make dc.worktree.list
# Switch to a worktree
wt feature-x
# Return to the main workspace
wt
# Remove a worktree
make dc.worktree.remove feature-xOpen multiple shells and run Claude in different worktrees — each instance works on its own branch with isolated file changes:
# Terminal 1
make dc.shell
wt feature-auth
make dc.claude
# Terminal 2
make dc.shell
wt feature-billing
make dc.claudeTo disable Tidewave, make two changes:
-
In
.devcontainer/Dockerfile, set the build arg tofalse:ARG INSTALL_TIDEWAVE=false -
In
.devcontainer/devcontainer.json, remove the Tidewave port mapping fromrunArgs:// Before "runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW", "-p", "4000:4000", "-p", "9833:9832"], // After "runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW", "-p", "4000:4000"],
Then rebuild with make dc.rebuild.
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: 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.19.5
ARG OTP_VERSION=28.3.2
ARG DEBIAN_VERSION=trixie-20260202-slim