Date: 2026-01-08 Context: Investigation of permission denied errors in deva containers Outcome: Fixed broken UID remapping + documented industry patterns
After commit 5807889 (2025-12-29), deva containers failed to start with:
env: 'claude': Permission denied
error: failed to launch ephemeral container
Root cause: Overly-clever optimization broke fundamental UID remapping logic.
if [ "$DEVA_UID" != "$current_uid" ]; then
usermod -u "$DEVA_UID" -g "$DEVA_GID" "$DEVA_USER"
chown -R "$DEVA_UID:$DEVA_GID" "$DEVA_HOME" 2>/dev/null || true
fiBehavior:
- Simple, reliable, comprehensive
- Fixed ALL files in /home/deva after UID change
- Problem: Slow on large mounted volumes, corrupts host permissions
if [ "$DEVA_UID" != "$current_uid" ]; then
usermod -u "$DEVA_UID" -g "$DEVA_GID" "$DEVA_USER"
# Only chown files owned by container, skip mounted volumes
find "$DEVA_HOME" -maxdepth 1 ! -type l -user root -exec chown "$DEVA_UID:$DEVA_GID" {} \; 2>/dev/null || true
fiFatal flaws:
-maxdepth 1: Only checks /home/deva directly, doesn't recurse into subdirectories-user root: Only fixes root-owned files- Skipped critical directories:
.npm-global(owned by UID 1001, not root) →/home/deva/.npm-global/bin/claudenever fixed.local(uv, Python packages).oh-my-zsh(shell config).skills(atlas-cli).config,.cache,go/
Result: After usermod changes user from 1001→501, binaries remain owned by 1001 → Permission denied.
if [ "$DEVA_UID" != "$current_uid" ]; then
usermod -u "$DEVA_UID" -g "$DEVA_GID" "$DEVA_USER"
# Fix container-managed directories (whitelist approach - safe for mounted volumes)
# These directories are created at image build time and must be chowned to match host UID
for dir in .npm-global .local .oh-my-zsh .skills .config .cache go; do
if [ -d "$DEVA_HOME/$dir" ] && [ ! -L "$DEVA_HOME/$dir" ]; then
chown -R "$DEVA_UID:$DEVA_GID" "$DEVA_HOME/$dir" 2>/dev/null || true
fi
done
# Fix container-created dotfiles
find "$DEVA_HOME" -maxdepth 1 \( -type f -o -type d \) -name '.*' \
! -name '..' ! -name '.' \
-exec chown "$DEVA_UID:$DEVA_GID" {} \; 2>/dev/null || true
fiAdvantages:
- Explicit whitelist: Only touches known container directories
- Complete: Recursively fixes everything needed
- Safe: Won't touch unknown mounted volumes
- Fast: Minimal chown operations
- Maintainable: Each directory is documented
Pattern: Automatic UID matching via container lifecycle hooks
Implementation:
{
"remoteUser": "vscode",
"updateRemoteUserUID": true
}How it works:
- VS Code detects host UID/GID on Linux
- Automatically runs
usermod/groupmodat container start - Uses lifecycle hooks (onCreate, postCreate) for complex setups
Sources:
Pros:
- Zero user configuration
- Transparent UID matching
- Industry standard (Microsoft)
Cons:
- Requires VS Code
- Not portable
- Black-box magic (hard to debug)
Pattern: Purpose-built Go binary for runtime UID/GID fixing
Installation:
RUN addgroup --gid 1000 docker && \
adduser --uid 1000 --ingroup docker --home /home/docker \
--shell /bin/sh --disabled-password --gecos "" docker
RUN USER=docker && \
GROUP=docker && \
curl -SsL https://github.com/boxboat/fixuid/releases/download/v0.6.0/fixuid-0.6.0-linux-amd64.tar.gz \
| tar -C /usr/local/bin -xzf - && \
chown root:root /usr/local/bin/fixuid && \
chmod 4755 /usr/local/bin/fixuid && \
mkdir -p /etc/fixuid && \
printf "user: $USER\ngroup: $GROUP\npaths:\n - /home/docker\n" > /etc/fixuid/config.yml
ENTRYPOINT ["fixuid", "-q"]
CMD ["/bin/bash"]Usage:
docker run -e FIXUID=1000 -e FIXGID=1000 --user 1000:1000 myimageHow it works:
- Runs as setuid root (4755 permissions)
- Changes user/group atomically
- Recursively fixes specified paths
- Drops privileges and execs child process
Sources:
Pros:
- Battle-tested (600+ stars)
- Handles edge cases (existing UIDs, locked files)
- Faster than shell usermod + chown
- Atomic operations
Cons:
- External dependency (~2MB)
- Dev-only warning: Should NOT be in production images (security)
- setuid binary (potential attack surface)
Pattern: Runtime environment variables for UID/GID
Implementation:
# Jupyter base-notebook pattern
ARG NB_USER="jovyan"
ARG NB_UID="1000"
ARG NB_GID="100"
RUN groupadd -g $NB_GID $NB_USER && \
useradd -u $NB_UID -g $NB_GID -m -s /bin/bash $NB_USER
COPY start.sh /usr/local/bin/
ENTRYPOINT ["tini", "-g", "--"]
CMD ["start.sh"]start.sh:
#!/bin/bash
# Adjust UID/GID if environment variables provided
if [ -n "$NB_UID" ] && [ "$NB_UID" != "$(id -u $NB_USER)" ]; then
usermod -u "$NB_UID" "$NB_USER"
chown -R "$NB_UID:$NB_GID" "/home/$NB_USER"
fi
# Drop privileges
exec sudo -E -u "$NB_USER" "$@"Usage:
docker run -e NB_UID=1000 -e NB_GID=100 --user root jupyter/base-notebookSources:
- Jupyter Docker Stacks: Running Containers
- Issue: Revisit root permissions
- Forum: NB_UID and NB_GID meaning
Pros:
- Well-documented
- Industry precedent (Jupyter is trusted)
- Explicit control via env vars
Cons:
- Requires starting as root
- Runtime overhead (usermod + chown every start)
- Dev-only pattern (not recommended for production)
Pattern: Build-time permissions, zero runtime changes
Recommended Dockerfile:
FROM ubuntu:24.04
# Build-time ARGs for flexible UID/GID
ARG USER_UID=1000
ARG USER_GID=1000
# Create user at build time with specified UID/GID
RUN groupadd -g $USER_GID appuser && \
useradd -u $USER_UID -g $USER_GID -m -s /bin/bash appuser
# Install as root
RUN apt-get update && apt-get install -y nodejs npm
# Install app dependencies as root, set ownership at copy time
COPY --chown=appuser:appuser package*.json ./
RUN npm install -g some-cli-tool
# Fix ownership of global npm installations
RUN chown -R appuser:appuser /usr/local/lib/node_modules
# Switch to non-root user for runtime
USER appuser
# No entrypoint scripts, no runtime permission changes
CMD ["node", "app.js"]Build for specific host UID:
docker build --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) -t myapp .Sources:
Pros:
- Production-safe: No root required at runtime
- Fast: Zero runtime overhead
- Secure: Immutable permissions
- Compatible: Works with
--read-onlyfilesystems
Cons:
- Inflexible: Must rebuild for different UIDs
- Not multi-user: Can't share single image across team
- Build-time dependency: Requires Docker on host
Key Security Principle:
"Running containers as root user is a common anti-pattern. If an attacker exploits a vulnerability in your application, and the container is running as root, the attacker gains root-level access to the container."
Pattern: Transparent UID remapping at kernel level
Configuration:
// /etc/docker/daemon.json
{
"userns-remap": "default"
}How it works:
- Docker daemon creates subordinate UID/GID ranges
- Container UID 0 (root) maps to unprivileged host UID (e.g., 100000)
- Container UID 1000 maps to host UID 101000
- Transparent to container processes
Sources:
- Docker: Isolate with user namespace
- Dreamlab: User namespace remapping
- Collabnix: User Namespaces Lab
Pros:
- Strongest security: Root in container = unprivileged on host
- Transparent: No Dockerfile changes needed
- Kernel-level: Can't be bypassed
Cons:
- Host-level config: Requires daemon restart
- Not portable: Different on each host
- Compatibility issues: Some images expect real UID 0
- UID range limits: Must stay within 0-65535
Pattern: Use single UID, accept permission mismatches
Implementation:
FROM ubuntu:24.04
RUN useradd -u 1000 -m appuser
USER 1000:1000
CMD ["myapp"]Workarounds for host volumes:
# Host: Match host UID to container UID
sudo chown -R 1000:1000 ./project
# Or: Add container UID to host groups
sudo usermod -aG 1000 $USERSources:
Pros:
- Simplest: Zero complexity
- Fast: No runtime overhead
- Production-ready: Immutable
Cons:
- macOS incompatible: Default UID 501, not 1000
- Multi-user friction: Different users = different UIDs
- Requires host changes: Must adjust host permissions
| Pattern | Dev Use | Prod Use | Performance | Portability | Security | Complexity |
|---|---|---|---|---|---|---|
| VS Code DevContainer | ✅ Excellent | ❌ No | Good | Medium | Good | Low (transparent) |
| fixuid | ✅ Excellent | Excellent | High | Medium (setuid) | Low | |
| Jupyter Pattern | ✅ Good | ❌ No | Medium | High | Medium (needs root) | Medium |
| Build-time ARG | ✅ Excellent | Excellent | Low (per-user) | Excellent | Medium | |
| User Namespaces | ✅ Excellent | Excellent | Low (host-specific) | Excellent | High | |
| Fixed UID 1000 | ✅ Good | Excellent | High | Good | Low | |
| Deva Whitelist | ✅ Excellent | Good | High | Medium (needs root) | Medium |
Deva is a development container wrapper, not a production workload. Different rules apply:
Production containers:
- Deployed at scale
- Security-critical
- Immutable infrastructure
- Single-user workflows
- Performance-sensitive
Development containers:
- Single developer
- Trusted workspaces
- Need host volume access
- Multi-user (team shares image)
- Flexibility > Security
Three major projects use runtime UID fixing in dev containers:
- Jupyter Docker Stacks (100M+ pulls)
- VS Code DevContainers (millions of users)
- JupyterHub spawners (enterprise deployments)
Pattern validation: If Jupyter and VS Code do it, it's legitimate for dev use.
- Multi-user by design: Team shares single image, can't rebuild per-user
- Host volume integration: Must match host UID for file access
- Agent flexibility: Supports multiple agents (claude, codex, gemini) in one image
- Profile system: Base vs rust images, shared entrypoint logic
Trade-off: Accept runtime UID overhead for team collaboration benefits.
# Add to Dockerfile
RUN curl -SsL https://github.com/boxboat/fixuid/releases/download/v0.6.0/fixuid-0.6.0-linux-amd64.tar.gz \
| tar -C /usr/local/bin -xzf - && \
chown root:root /usr/local/bin/fixuid && \
chmod 4755 /usr/local/bin/fixuid && \
mkdir -p /etc/fixuid && \
printf "user: $DEVA_USER\ngroup: $DEVA_USER\npaths:\n - /home/deva\n" > /etc/fixuid/config.yml
ENTRYPOINT ["fixuid", "-q", "/usr/local/bin/docker-entrypoint.sh"]Decision: Not implemented yet, keep as future enhancement
- Feature flag:
DEVA_USE_FIXUID=1 - Optional dependency
- Fallback to shell if not available
# Build per-user image
docker build --build-arg DEVA_UID=$(id -u) --build-arg DEVA_GID=$(id -g) -t deva:eric .Decision: Rejected
- Defeats shared image model
- CI/CD builds break (whose UID?)
- Team friction (each dev needs different image)
// /etc/docker/daemon.json
{
"userns-remap": "default"
}Decision: Rejected
- Requires host Docker config (not portable)
- Breaks Docker-in-Docker scenarios
- Users can opt-in independently if desired
Decision: Rejected
- Breaks macOS (default UID 501)
- Requires host filesystem changes
- Poor developer experience
setup_nonroot_user() {
local current_uid=$(id -u "$DEVA_USER")
local current_gid=$(id -g "$DEVA_USER")
# Validate UID/GID (avoid UID 0)
if [ "$DEVA_UID" = "0" ]; then
echo "[entrypoint] WARNING: Host UID is 0. Using fallback 1000."
DEVA_UID=1000
fi
if [ "$DEVA_GID" = "0" ]; then
echo "[entrypoint] WARNING: Host GID is 0. Using fallback 1000."
DEVA_GID=1000
fi
# Update GID if needed
if [ "$DEVA_GID" != "$current_gid" ]; then
if getent group "$DEVA_GID" >/dev/null 2>&1; then
# Join existing group
local existing_group=$(getent group "$DEVA_GID" | cut -d: -f1)
usermod -g "$DEVA_GID" "$DEVA_USER" 2>/dev/null || true
else
# Create new group
groupmod -g "$DEVA_GID" "$DEVA_USER"
fi
fi
# Update UID if needed
if [ "$DEVA_UID" != "$current_uid" ]; then
# usermod may fail with rc=12 when it can't chown home directory (mounted volumes)
# The UID change itself usually succeeds even when chown fails
if ! usermod -u "$DEVA_UID" -g "$DEVA_GID" "$DEVA_USER" 2>/dev/null; then
# Verify what UID we actually got
local actual_uid=$(id -u "$DEVA_USER" 2>/dev/null)
if [ -z "$actual_uid" ]; then
echo "[entrypoint] ERROR: cannot determine UID for $DEVA_USER" >&2
exit 1
fi
if [ "$actual_uid" != "$DEVA_UID" ]; then
echo "[entrypoint] WARNING: UID change failed ($DEVA_USER is UID $actual_uid, wanted $DEVA_UID)" >&2
# Adapt to reality so subsequent operations use correct UID
DEVA_UID="$actual_uid"
fi
fi
# Fix container-managed directories (whitelist approach - safe for mounted volumes)
# These directories are created at image build time and must be chowned to match host UID
for dir in .npm-global .local .oh-my-zsh .skills .config .cache go; do
if [ -d "$DEVA_HOME/$dir" ] && [ ! -L "$DEVA_HOME/$dir" ]; then
chown -R "$DEVA_UID:$DEVA_GID" "$DEVA_HOME/$dir" 2>/dev/null || true
fi
done
# Fix container-created dotfiles
find "$DEVA_HOME" -maxdepth 1 \( -type f -o -type d \) -name '.*' \
! -name '..' ! -name '.' \
-exec chown "$DEVA_UID:$DEVA_GID" {} \; 2>/dev/null || true
fi
chmod 755 /root 2>/dev/null || true
}-
Explicit whitelist: Each directory is named, not discovered
- Prevents accidents (won't chown unknown mounted volumes)
- Self-documenting (clear what's managed)
- Maintainable (easy to add new directories)
-
Symlink protection:
[ ! -L "$DEVA_HOME/$dir" ]- Avoids following symlinks to mounted volumes
- Prevents permission corruption on host
-
Error tolerance:
2>/dev/null || true- Continues if chown fails (e.g., NFS volumes)
- Non-fatal for better UX
-
Dotfile handling: Separate find for hidden files
- Catches
.zshrc,.bashrc,.gitconfig - Doesn't recurse (shallow only)
- Catches
-
Execution order fix: Moved
setup_nonroot_userbeforeensure_agent_binaries- Permissions must be fixed BEFORE checking if binaries exist
- Previous order was illogical (root check, then fix permissions)
Avoid repeated chown on persistent containers:
setup_nonroot_user() {
# ... existing UID change logic ...
# Skip if already fixed this session
local marker="/tmp/.deva_uid_fixed_${DEVA_UID}"
if [ -f "$marker" ]; then
return 0
fi
# ... fix permissions ...
# Mark as fixed
touch "$marker"
}Benefits:
- Faster container restarts
- Reduced disk I/O
- Better for persistent container workflows
Feature-flag for advanced users:
if [ "${DEVA_USE_FIXUID:-false}" = "true" ] && command -v fixuid >/dev/null 2>&1; then
exec fixuid -q "$@"
else
# Fallback to shell implementation
setup_nonroot_user
fiBenefits:
- Performance boost for fixuid users
- No breaking change (opt-in)
- Maintains shell fallback
Debug mode for permission issues:
if [ "${DEVA_DEBUG_PERMISSIONS:-false}" = "true" ]; then
echo "[entrypoint] Fixing $dir ownership..."
chown -Rv "$DEVA_UID:$DEVA_GID" "$DEVA_HOME/$dir"
else
chown -R "$DEVA_UID:$DEVA_GID" "$DEVA_HOME/$dir" 2>/dev/null || true
fiBenefits:
- Easier debugging for users
- Troubleshooting permission issues
- Optional verbosity (no log spam by default)
-
Runtime UID fixing is legitimate for dev containers
- Jupyter, VS Code, JupyterHub all do it
- Production rules don't apply to dev workflows
-
The "optimization" in commit 5807889 was premature
- Tried to avoid chowning mounted volumes
- Broke fundamental functionality
- Whitelist approach solves both problems
-
Explicit > Clever
- Named directory list beats find heuristics
- Clear intent beats magic logic
- Maintainability > Performance
-
Context matters in security decisions
- Development containers have different threat models
- Flexibility and UX trump absolute security
- Document why it's OK to break "rules"
-
Industry research validates our approach
- Not inventing new patterns
- Following proven solutions
- Standing on shoulders of giants
- Docker: Isolate with user namespace
- Docker: Understanding USER instruction
- VS Code: Add non-root user to container
- Sysdig: Dockerfile Best Practices
- Nick Janetakis: Non-root with custom UID/GID
- Docker Forums: UID/GID Best Practices
- Deni Bertovic: Handling Permissions with Docker Volumes
- VS Code: UID/GID change fails when GID exists
- Jupyter: Revisit root permissions and entrypoint
- Jupyter Forums: NB_UID and NB_GID meaning
Last Updated: 2026-01-08 Maintained by: Claude Code (via deva development)