Persisting Claude Code and GitHub CLI Auth in Dev Containers

Photo by Haris Illahi on Unsplash
Dev containers are fantastic right up until you rebuild one. The container's home directory is ephemeral, so every rebuild wipes your tooling state. For me that meant the same two papercuts, over and over: sign back into Claude Code, then gh auth login again. Two browser dances before I could do any actual work.
The fix is to persist the directories where each tool keeps its auth — but there's a volume-ownership trap in the middle that turns "just mount a volume" into a confusing permission denied. Here's the whole thing end to end.
What each tool actually stores
Both tools keep their credentials in a directory under your home folder:
- Claude Code stores its auth token, settings, and session history in
~/.claude(and a~/.claude.jsonconfig file). - GitHub CLI stores its auth in
~/.config/gh/hosts.yml. Modernghprefers your OS keyring, but a dev container has no keyring, so it falls back to writing this file.
Persist those two locations across rebuilds and you never log in again. The Dev Containers way to do that is a named volume per directory.
The naive setup
{
"image": "mcr.microsoft.com/devcontainers/base:bookworm",
"containerUser": "vscode",
"containerEnv": {
"CLAUDE_CONFIG_DIR": "/home/vscode/.claude"
},
"mounts": [
"source=claude-code-config-${devcontainerId},target=/home/vscode/.claude,type=volume",
"source=gh-config-${devcontainerId},target=/home/vscode/.config/gh,type=volume"
]
}A couple of things worth calling out:
${devcontainerId}scopes each volume to this container, so credentials don't leak between unrelated projects sharing one machine.CLAUDE_CONFIG_DIRpoints Claude Code at the mounted directory. Set this and~/.claude.jsonlives inside the volume too, so it persists with everything else — no symlink tricks needed.
This looks complete. It is not. The first time you gh auth login, you get:
open /home/vscode/.config/gh/hosts.yml: permission deniedThe gotcha: fresh named volumes are owned by root
When Docker initialises an empty named volume, it copies the contents and ownership of whatever directory exists at that mount point inside the image. That's the key detail:
~/.claudecame up owned byvscode, because the Claude Code dev container feature creates that directory (asvscode) in the image. The volume inherited it.~/.config/ghcame up owned by root, because that path doesn't exist in the base image. Docker had nothing to copy ownership from, so it created the mount point fresh — as root.
Your container runs as vscode, gh tries to write hosts.yml, and the kernel says no. Same thing bites anyone mounting a volume at a path their base image doesn't pre-create.
The fix: chown on first create
The cleanest place to handle this is a small post-create script that fixes ownership before you ever touch the tools. I keep mine at .devcontainer/setup.sh:
#!/usr/bin/env bash
set -euo pipefail
USER_NAME="$(id -un)"
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
GH_DIR="$HOME/.config/gh"
# Fix ownership only when the dir exists and isn't already ours. This makes the
# script idempotent: it skips the sudo call on rebuilds where the persisted
# volume is already correct.
for dir in "$GH_DIR" "$CLAUDE_DIR"; do
if [ -d "$dir" ] && [ "$(stat -c '%U' "$dir")" != "$USER_NAME" ]; then
sudo chown -R "$USER_NAME:$USER_NAME" "$dir"
fi
done
# Seed an empty Claude config if missing so the extension's first locked save
# can lstat the file (avoids a noisy ENOENT error on a brand-new volume).
if [ ! -s "$CLAUDE_DIR/.claude.json" ]; then
mkdir -p "$CLAUDE_DIR"
echo '{}' > "$CLAUDE_DIR/.claude.json"
fiThen wire it into devcontainer.json:
"postCreateCommand": "bash .devcontainer/setup.sh"Two things worth knowing about it:
- The
stat -c '%U'guard keeps it idempotent — on a rebuild where the volume is already owned correctly, thechownis skipped. sudoworks because thedevcontainers/baseimage givesvscodepasswordless sudo. If yours doesn't, grant it in your Dockerfile.
The .claude.json seed is a small extra: on a brand-new volume the Claude Code extension's first save logs a noisy ENOENT because the file isn't there yet. It self-heals, but seeding an empty file keeps the logs clean.
That's it
Rebuild once, sign into Claude and gh one last time, and they'll survive every rebuild after. The whole trick is two named volumes — plus knowing that a fresh volume comes up owned by root unless the image already created that directory. Get bitten by that permission denied once and you won't forget it.
