This document explains how the devtools container environment is assembled.
┌─────────────────────────────────────────────────────────────┐
│ Host │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ acl-proxy │ │ run.sh │ │
│ │ (port 8881) │ │ (nsenter) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ iptables via nsenter │
│ ┌────────┴────────────────────┴────────────────────────┐ │
│ │ Container │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ Network Namespace │ │ │
│ │ │ - iptables redirects 80/443 → proxy │ │ │
│ │ │ - Default deny outbound │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ /home/$USER ──────────── workspace/ (bind mount) │ │
│ │ /usr, /lib, /etc ─────── host dirs (read-only) │ │
│ │ ~/.bashrc, etc ───────── overlay/ (overrides) │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
devtools/
├── container/
│ ├── Dockerfile # Minimal image (just creates user)
│ ├── build.sh # Build with current UID/GID
│ ├── run.sh # Launch container with mounts
│ └── overlay/ # Files that override host mounts
│ ├── etc/ # System config overrides
│ └── home/user/ # User config overrides (placeholder)
├── workspace/ # Becomes $HOME inside container
│ └── worktrees/ # Git worktrees live here
├── proxy/
│ └── acl-proxy.toml # URL allowlist config
└── host/
├── bashrc.additions # Add to ~/.bashrc
└── bash_completion.d/
The Dockerfile is intentionally minimal:
- Base: Rocky Linux 10
- Creates user matching host UID/GID
- No tools installed (uses host's via bind mounts)
This keeps the image small and ensures tools are always up-to-date with the host.
workspace/ is mounted as $HOME inside the container. This is where all your files live.
/usr, /lib, /lib64, /etc are mounted from the host. This provides:
- All host-installed tools (git, cargo, node, etc.)
- System libraries
- CA certificates (including acl-proxy CA)
Files in overlay/ are mounted last, overriding anything from host or workspace:
- Custom
.bashrcfor container-specific shell setup - SSH config (without copying private keys)
- Tool configurations
Rename
overlay/home/user/to your actual username so paths match/home/<username>inside the container.
Certain host directories are mounted 1:1 to preserve state:
~/.cargo,~/.rustup- Rust toolchain~/.claude,~/.codex,~/.pi- AI assistant state~/.local/bin- User binaries
The transparent proxy is an optional add-on. See docs/PROXY.md for setup details.
When PROXY=1 is set:
- Container starts without network capabilities
- Host uses
nsenterto enter container's network namespace - iptables rules redirect 80→8881, 443→8889
- Default deny blocks any non-proxied traffic
- User inside container cannot modify rules
This provides secure URL allowlisting without giving the container elevated privileges.
Git worktree stores absolute paths in .git metadata files. To ensure paths are consistent between host and container, we use a bind mount (not a symlink).
Why not a symlink? When you run wt switch --create <branch> on the host, git resolves symlinks and stores the real path. A symlink at ~/worktrees → ~/devtools/workspace/worktrees would result in git storing ~/devtools/workspace/worktrees/... in metadata, breaking consistency.
Solution: Bind mount makes both paths identical at the kernel level:
Host: ~/worktrees ←bind mount→ ~/devtools/workspace/worktrees
Container: ~/worktrees ← workspace mounted as $HOME
Setup (in /etc/fstab):
/home/user/devtools/workspace/worktrees /home/user/worktrees none bind 0 0
Now /home/$USER/worktrees/project/main is the same path in both environments, and git metadata stores the correct path.
The container is designed for a git-worktree workflow:
# Clone a repo (creates bare repo with worktrees)
wt clone git@github.com:user/project.git project
# Create a worktree for a feature
cd project
wt switch --create feature/my-work
# Files live at: workspace/worktrees/project/feature/my-workThe wt tool (worktrunk) manages worktrees. The overlay .bashrc integrates it and overrides wt clone to run git clone --bare.
This setup uses Podman but can be adapted for Docker with these changes:
Podman:
podman run --userns=keep-id ...Docker (rootless mode, or with userns-remap configured):
docker run --user $(id -u):$(id -g) ...The :Z suffix on volume mounts is for SELinux relabeling. On systems without SELinux, remove it:
# Podman on SELinux systems
-v $WORKSPACE:$HOME:Z
# Docker or non-SELinux
-v $WORKSPACE:$HOMEPodman uses host.containers.internal (169.254.1.2) to reach the host.
Docker on Linux requires adding the host explicitly:
docker run --add-host=host.docker.internal:host-gateway ...Update proxy-iptables.sh to use Docker's address:
PROXY_IP="host-gateway" # or the actual host IPOn Docker Desktop (macOS/Windows), host.docker.internal works automatically.
The nsenter approach works identically - get the container PID and enter its network namespace:
# Docker
PID=$(docker inspect --format '{{.State.Pid}}' <container>)
sudo nsenter -t $PID -n iptables ...| Podman | Docker |
|---|---|
podman run |
docker run |
--userns=keep-id |
--user $(id -u):$(id -g) |
:Z volume suffix |
remove or use :z |
host.containers.internal |
--add-host=host.docker.internal:host-gateway |
podman inspect |
docker inspect |