Skip to content

Latest commit

 

History

18 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

An Elixir Devcontainer for Phoenix, Claude Code, and Tidewave

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 .claude folder, separate from your host's ~/.claude

Quick start

  1. 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
  1. Create a root Makefile that includes the devcontainer Makefile with:
echo 'include .devcontainer/Makefile' > Makefile
  1. Add project-specific environment variables in devcontainer.json
  2. Customize the protected filepaths in protected-paths.txt
  3. Customize the allowed domains in allowed-domains.txt
  4. Enable any optional tools you need as described below.
  5. Run make dc.up to start the container, then make dc.shell to open a shell inside it.
  6. If enabled, run make dc.claude to start Claude in unsafe mode.
  7. To access Tidewave, run make dc.tidewave.bg to start Tidewave in the background (exposed on localhost:9833) and then start your app with make dc.server or mix phx.server.

Optional tools

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=true

Rust and Bun versions are pinned by the adjacent RUST_VERSION and BUN_VERSION arguments.

Makefile commands

Run make list to see all available commands.

Adding allowed domains

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 only

After changing the file, rebuild the container with make dc.rebuild.

Adding environment variables

Environment variables are configured in .devcontainer/devcontainer.json in two sections:

remoteEnv

For secrets and values that should refresh from your host on each container restart:

"remoteEnv": {
  "MY_API_KEY": "${localEnv:MY_API_KEY}",
  "DATABASE_HOST": "host.docker.internal",
},

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.

containerEnv

For configuration that is baked in at container creation time and does not change between restarts:

"containerEnv": {
  "CLAUDE_CONFIG_DIR": "/home/dev/.claude",
},

Adding Protected paths

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)
**/.env

By default, .env and .env.* are protected. Edit the file to add project-specific paths, then rebuild with make dc.rebuild.

Project-level deny rules

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.

Connecting to Postgres

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

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).

How the firewall works

The container uses a layered approach to make --dangerously-skip-permissions safer:

Network isolation

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.

Known limitations

The firewall reduces the attack surface significantly but is not a complete sandbox:

  • No general sudo: The dev user 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 .env deny rules prevent reading .env files, but secrets injected via remoteEnv are still visible through printenv or /proc/self/environ. Avoid putting highly sensitive secrets in remoteEnv if 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.

Design decisions

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.

Customizing the base image

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

About

A Claude-based Devcontainer for Elixir development

Resources

Stars

69 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages