Persisting Claude Code and GitHub CLI Auth in Dev Containers
  June 13, 2026     4 min read

Persisting Claude Code and GitHub CLI Auth in Dev Containers

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.json config file).
  • GitHub CLI stores its auth in ~/.config/gh/hosts.yml. Modern gh prefers 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_DIR points Claude Code at the mounted directory. Set this and ~/.claude.json lives 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 denied

The 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:

  • ~/.claude came up owned by vscode, because the Claude Code dev container feature creates that directory (as vscode) in the image. The volume inherited it.
  • ~/.config/gh came 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"
fi

Then 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, the chown is skipped.
  • sudo works because the devcontainers/base image gives vscode passwordless 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.

devopstar