Dev container must haves
dev container must havesA robust dev container needs five things from the host: the timezone, your git credentials, the gh CLI’s login, your coding agent’s authentication, and a non-root user.
The problem
A dev container gives you a clean, reproducible environment per project. What is easy to overlook is everything the host had been providing for free until you moved in.
Timezone
A timezone matters inside a dev container, above all for logs. The default is Etc/UTC, which is almost certainly neither yours nor the project’s. For deployed projects we already set the zone in the Dockerfile, or mount it through Docker Compose — the dev container deserves the same.
Git Credentials
Pass the host’s git credentials into the dev container — the same SSH key, or a different one — so git keeps working without a hiccup.
GitHub CLI
The GitHub CLI is essential for anything you do on GitHub. The host can authenticate it with gh auth login, but a dev container is better served by a fine-grained PAT (personal access token), scoped to only the privileges the project needs. That PAT lives on the host and is handed over only when it is asked for.
Claude Code/AI coding agent
Unless your AI coding agent is GitHub Copilot, authenticating it in every dev container — and again after every rebuild — becomes a chore. Forward a token instead, and the agent inside the container is signed in as the same account it is on the host.
Non-root User
A container that runs as root writes files onto the host as root, and hands root to everything running inside it. Unless you have to bind a privileged port, run the dev container as a non-root user.
The five
The example, and the three ways to build it
There are three shapes a dev container comes in, and every snippet below is given in all three. Pick your shape once and read only that tab for the rest of the page — the tabs stay in sync.
Every example uses the same project, the same service name and the same user, so the parts compose instead of contradicting each other:
- devcontainer.json
- docker-compose.yml
- Dockerfile
- README.md
| Thing | Value | Used as |
|---|---|---|
| Project | your-project | workspace folder name |
| Service | dev | the compose service VS Code attaches to |
| User | vscode | uid 1000 in the Microsoft base images |
| Home | /home/vscode | every mount target below |
Four host variables carry everything across. Two are set unconditionally, two only while VS Code is looking:
HOST_TZ— the host’s IANA timezone. Not a secret.PROJECT_SSH_KEY— the filename of this project’s SSH key. Not a secret.PROJECT_GH_TOKEN— a GitHub token, scoped to this project.PROJECT_CLAUDE_TOKEN— a Claude Code OAuth token, if you go that route.
Rename the last three per project. That is the whole point of them.
1. Timezone forwarding
A container image ships as UTC. Nothing warns you: the clock is simply wrong by however far you are from Greenwich, and everything the container writes inherits it — commit author dates, log lines, generated front matter, the filenames of anything you name after the time.
Two ways to fix it.
| Approach | What it does | Best for |
|---|---|---|
| Bind-mount | Container reads the host’s zoneinfo file /etc/localtime directly | Linux hosts |
| Export an IANA zone name | Container gets TZ=Europe/London in its environment | Everywhere, including macOS and Windows |
The mount is shorter to write, but the variable is the one that works on every
host: on macOS and Windows the Docker daemon runs inside its own Linux VM, so
/etc/localtime there is the VM’s file and not your laptop’s.
Exporting an IANA zone name is the preferred approach. The bind-mount is worth reaching for only on a Linux host.
A timezone is not a credential. Export it unconditionally in your shell
profile — no VSCODE_RESOLVING_ENVIRONMENT guard, no secret store, one
variable shared by every container on the machine.
The exception is a project that must run in a zone other than yours — a
reproduction of a production incident in UTC, a team in another country. Give
that one its own variable name, PROJECT_TZ, and set it per project rather
than bending HOST_TZ to fit.
And for a team project that has to run in one agreed timezone, hardcoding the zone name is perfectly reasonable.
1.1 Export an IANA zone name — preferred
Works on macOS, Linux and Windows, needs nothing from the host’s filesystem, and falls back to UTC instead of failing when the variable is missing.
Read the host’s zone
The answer is an IANA name — Europe/London, America/New_York. Not an
abbreviation like BST, which is ambiguous and which tzdata will reject.
readlink /etc/localtime/var/db/timezone/zoneinfo/Europe/LondonThe zone is everything after zoneinfo/.
Export it from your shell profile
Strip the prefix in the shell rather than hardcoding the name, so the variable follows you when you travel or when the machine changes zone.
export HOST_TZ="${$(readlink /etc/localtime)##*/zoneinfo/}"Open a new terminal and check it:
echo "$HOST_TZ"Do not export bare TZ on the host. Every process you start would then read
your zone from a variable instead of from the system, which is the same value
until the day it is not — after a DST change, or after you fly somewhere, a
stale shell keeps the old one and date quietly lies to you.
HOST_TZ is inert on the host and only means something on the way into a
container.
Pass it into the container
{
"name": "your-project",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"remoteUser": "vscode",
"containerEnv": {
"TZ": "${localEnv:HOST_TZ}"
}
}${localEnv:...} reads the environment VS Code resolved from your login shell.
An unset variable becomes an empty string, and an empty TZ means UTC — the
same place you started, so a missing variable degrades rather than breaks.
Use containerEnv here, not remoteEnv. containerEnv is set on the
container itself, so a dev server, a postCreateCommand and a background job
all see it; remoteEnv only reaches the terminals and tasks VS Code starts,
which would leave half the container in UTC.
Verify
Rebuild, open a terminal in the container:
date
echo "$TZ"The date should match the clock on your host, to the minute.
TZ is read once, when a process starts. Changing it needs a rebuild, or at
minimum a new terminal — Reload Window will not move the clock of anything
already running.1.2 Bind-mount the host’s zoneinfo — Linux hosts only
On a Linux host the daemon shares the host’s kernel and filesystem, so
/etc/localtime really is the file you think it is:
"mounts": [
"source=/etc/localtime,target=/etc/localtime,type=bind,readonly",
"source=/etc/timezone,target=/etc/timezone,type=bind,readonly"
] volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:roMount both. /etc/localtime is the binary zoneinfo that C programs read;
/etc/timezone is the plain-text name that Debian tooling and some runtimes
read instead, and a container with only the first will tell you the right time
under the wrong name.
It has one real advantage over the variable: change the host’s zone and the container follows on its next start, with nothing to re-export.
It also has one real limitation, which is why it is not the main route above. On macOS and on Windows the path is resolved inside Docker’s Linux VM, so you either mount the VM’s UTC file over your container’s UTC file, or the bind fails outright because the source does not exist. Neither is what you wanted.
2. SSH forwarding
Inside the container, git push is an SSH connection like any other, and it
needs a key. There are two honest ways to give it one, and they are not the
same trade.
| Approach | What crosses the boundary | Cost |
|---|---|---|
| Forward the agent | An encrypted key file. The host does the signing | The agent must hold the key, or nothing pushes |
| Mount an unlocked key | A key anything can use | Any process in the container can copy it out |
Forwarding the agent is the preferred approach. Mount an unlocked key only where there is no agent to forward.
2.1 Forward the agent — preferred
VS Code binds the host’s SSH_AUTH_SOCK into the container automatically. The
container asks the agent to sign a challenge and gets a signature back; the
passphrase, and the decrypted key, never leave the host.
The key file itself is still mounted, read-only, at a fixed path. That sounds
like it gives the file away and does not, because the file is encrypted and
nothing in the container can unlock it. What it buys is a configuration that
behaves like every other ssh setup you have ever read: IdentityFile names a
private key, the way it does everywhere else.
Two things follow from that, and both are worth having:
docker execcannot push. It gets no forwarded agent, so the key it can read is the key it cannot use. Only a session VS Code opened can write to GitHub.- Which identity the container uses is declared in the repository, not
inherited from whichever laptop you opened it on. The host’s
~/.ssh/configis never mounted and never parsed.
The catch is the agent. It signs with what it is holding, and an agent that was never given your key holds nothing — which is what step 2 is for.
Pick the key this container will use
You almost certainly already have one. Look before you make another:
ls -l ~/.ssh/*.pubAny key on that list works. Reuse the one GitHub already knows — typically
~/.ssh/id_ed25519 — and there is nothing to create, nothing to upload, and
nothing to wait for. Skip to storing its passphrase below.
Only the key you name here is mounted or loaded. The rest of ~/.ssh stays on
the host and never reaches the container, which is the point of the fixed path
in step 5.
When a separate key is worth it
Three cases, and none of them is “a new project”:
- The container will act as a different GitHub account than your host normally does — your personal account on a work laptop, say.
- You want to be able to revoke this one key without breaking every other repository on the machine.
- The key will be mounted unlocked, as in 2.2, where the blast radius is the whole reason to keep it separate.
If none of those apply, reuse what you have.
Creating one, if you decided you need it
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_project -C "your-project"Give it a real passphrase. The whole design below rests on the mounted file being useless on its own.
Then add the public half to the GitHub account it should authenticate as — a key GitHub has never seen authenticates as nobody:
cat ~/.ssh/id_ed25519_project.pubSign in as that account, go to Settings → SSH and GPG keys → New SSH key, paste the whole line, and give it the name of the machine it lives on. Choose key type Authentication key; a signing key is a separate entry and does not let you push.
ssh-add -l lists it — and every push fails
with Permission denied (publickey), because GitHub has no idea whose key that
is.Whichever key you settled on, store its passphrase so you are not typing it
again. Substitute your own filename for id_ed25519_project here and in every
step that follows:
ssh-add --apple-use-keychain ~/.ssh/id_ed25519_projectThe passphrase goes into the login keychain, and every later ssh-add for this
key reads it from there without prompting.
ssh-keygen -p -f ~/.ssh/id_ed25519, which changes nothing
on GitHub’s side because the public half is unchanged, or accept the trade
knowingly.Load the key lazily, on the host
Adding every key to the agent at login defeats the passphrase. Adding none means the container fails. So add exactly the key this machine’s dev containers need, at exactly the moment VS Code asks for an environment:
if [[ -n $VSCODE_RESOLVING_ENVIRONMENT ]]; then
key=~/.ssh/id_ed25519_project
fp="$(ssh-keygen -lf "$key.pub" 2>/dev/null | awk '{print $2}')"
if [[ -n $fp ]] && ! ssh-add -l 2>/dev/null | grep -q "$fp"; then
ssh-add --apple-use-keychain "$key" >/dev/null 2>&1
fi
unset key fp
fi--apple-use-keychain reads the passphrase you stored in the previous step, so
nothing prompts.
Two constraints shape that snippet. It runs inside VS Code’s environment resolution, which has a ten second budget before VS Code gives up with “Unable to resolve your shell environment in a reasonable time” — so it checks the fingerprint first and does nothing on the common path. And anything written to stderr during resolution can corrupt what VS Code parses back, hence the redirections.
The trap that costs the most time. VS Code resolves your shell environment
once per application session. Reload Window does not redo it. Rebuild
Container does not redo it. So you add the snippet, rebuild, and the key — or
the gh token in the next section — is still missing, with every file on disk
looking correct.
The fix is mechanical:
- Quit VS Code completely.
Cmd+Qon macOS, File → Exit elsewhere. Closing the window is not quitting. - Wait a minute or two. The extension host and the running containers take a moment to actually go away.
- Open VS Code from Spotlight or the app launcher — not from
code .in a terminal. Launched from a terminal it inherits that terminal’s environment instead of resolving a fresh one, and a terminal you opened before editing~/.zshrchas none of your changes. - Now Rebuild Container.
Name the key on the host, so the config never has to
One variable decides which key this project uses. Everything downstream refers to a fixed path instead, which is what lets a second repository copy the same files and point at a different key:
export PROJECT_SSH_KEY=id_ed25519_projectNo guard on this one. A filename is not a secret, and the container needs it at build time as well as at connect time.
Declare the identity in the repository
The agent may hold more than one key. With nothing to choose by, ssh offers them in the agent’s order and GitHub answers as whichever account matches first — and you will not notice, because the connection succeeds.
So put the choice in the repository, next to everything else that describes this container:
Host github.com github-project
HostName github.com
User git
IdentityFile ~/.ssh/id_container
IdentitiesOnly yesFour things are load-bearing here:
- Nothing names a host path, a key file or an account. The path is
id_container, which the mount in the next step points at whateverPROJECT_SSH_KEYsays. The file is portable between projects unchanged. - Both spellings resolve to the same identity.
github-projectis the alias you give the remote so the host — which keeps its own multi-key config — selects the right key for the same URL.github.comis there so that a baregit@github.cominside the container, typed by you or by some tool, cannot land on a different identity. IdentityFilenames the private key, not the.pub. That is the ordinary arrangement, it is what every other ssh setup you meet looks like, and it keeps working if the agent is ever not there — ssh falls back to asking for the passphrase rather than failing withPermission denied (publickey).IdentitiesOnly yesis what makes any of it binding. Alone,IdentityFileis a preference: the forwarded agent still offers every key it holds and GitHub accepts the first that maps to an account. It does not bypass the agent — signing still happens there, which is what unlocks the passphrase-protected key — it just narrows what the agent is asked to offer.
~/.ssh/config is never mounted, IgnoreUnknown UseKeychain
stops being a requirement. The macOS-only keywords that make Linux OpenSSH
abort a config file live on the host, in a file this container does not read.Mount both halves at that fixed path
Read-only, and both files: ssh reads id_container.pub to work out which
identity to offer the agent, and reading it separately means it never has to
touch the encrypted private file to do so.
"mounts": [
"source=ssh-home-${devcontainerId},target=/home/vscode/.ssh,type=volume",
"source=${localEnv:HOME}/.ssh/id_ed25519_project,target=/home/vscode/.ssh/id_container,type=bind,readonly",
"source=${localEnv:HOME}/.ssh/id_ed25519_project.pub,target=/home/vscode/.ssh/id_container.pub,type=bind,readonly"
]The volume comes first so ~/.ssh is a writable directory the two binds then
land inside. Without it, known_hosts is written into the container’s
filesystem and lost on every rebuild, and you re-confirm GitHub’s host key
forever.
devcontainer.json has no default-value syntax, so ${localEnv:PROJECT_SSH_KEY}
on an unset variable expands to nothing — and
source=${localEnv:HOME}/.ssh/ would then bind your entire ~/.ssh into
the container. Name the file literally in this shape, and keep the variable for
the compose one, which has real defaults.Own the directory and link the config
Three commands, in postCreateCommand, in this order:
"postCreateCommand": "sudo chown \"$(id -u):$(id -g)\" /home/vscode/.ssh && sudo chmod 700 /home/vscode/.ssh && ln -sfn \"${containerWorkspaceFolder}/.devcontainer/ssh-config\" /home/vscode/.ssh/config"chown— Docker creates a named volume root-owned unless the image already had that directory, and ssh cannot writeknown_hostsinto a directory it does not own. It is deliberately not recursive: the two keys inside are read-only binds, andchown -Rfails on them.chmod 700— ssh refuses to use a config or key directory that is group- or world-readable, and says so withBad owner or permissions, which looks nothing like a key problem.ln -sfn— a symlink, not a mount. The config is already inside the bind-mounted workspace, so linking to it means an edit takes effect on the next connection rather than the next rebuild.
Point the remote at the alias
git remote set-url origin github-project:you/your-project.gitInside the container both spellings work. On the host, where your own
~/.ssh/config has several keys, the alias is what selects the right one for
the same repository — so the remote is correct on both sides of the boundary.
Verify
Inside the container:
ssh-add -l
ls -l ~/.ssh
ssh -T github-project
ssh -T git@github.com
git remote -vssh-add -l lists what the forwarded agent is holding. Empty output means the
host never loaded the key — go back to step 2, and quit VS Code properly.
ls -l ~/.ssh should show config as a symlink into .devcontainer/, plus
id_container and id_container.pub. Both ssh -T calls should greet the same
account:
Hi you! You've successfully authenticated, but GitHub does not
provide shell access.Two names, or one name you did not expect, means IdentitiesOnly yes is
missing or the config is not actually linked.
Then confirm the boundary holds, from the host:
docker exec -it <container> ssh -T github-projectgit@github.com: Permission denied (publickey).That failure is the design working. docker exec gets no forwarded agent, so
the key it can read is a key it cannot use.
2.2 Mount an unlocked key
Sometimes there is no agent to forward: a Codespace, a CI runner, a headless
box you reach over Remote SSH. The shape above still applies — same
ssh-config, same id_container path, same mounts — with one change, and it
is the expensive one:
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_project_ci -N "" -C "your-project ci"-N "" means no passphrase, because there is nothing in the container to type
one into. Add its public half to the GitHub account exactly as in step 1 — a
new key is still a key GitHub has never seen — then point PROJECT_SSH_KEY at
it. Drop step 2, since there is no agent to load, and drop the docker exec
check at the end of step 8, since there is no boundary left to demonstrate.
Understand what you traded. Any process in that container, including a dependency’s install script, can now read the key and use it. Mount exactly one key, use it for exactly one thing, and be able to revoke it without touching anything else.
3. GitHub CLI forwarding
VS Code has its own GitHub sign-in, and inside a dev container it is not
reliably the thing gh reads. The account picker signs the editor in; gh in
a container terminal looks for GH_TOKEN, then for credentials in
~/.config/gh, and finds neither.
Running gh auth login inside the container fixes it exactly until the next
rebuild, and persisting that login in a volume leaves a live GitHub credential
with no expiry and no rotation.
A scoped personal access token in the host’s secret store is better on every axis. It is per project, so a leak is bounded. It expires on a date you chose. And it is looked up at the moment VS Code needs it, so it is never written to a file in the repository. It is the preferred approach, and the only one this section sets up.
The real payoff is that a token is just a variable name. You can hold several:
| Host variable | Token owner | Used by |
|---|---|---|
WORK_GH_TOKEN | work account | the work monorepo’s container |
PROJECT_GH_TOKEN | personal account | an open source project you contribute to |
Which means you can be on the work laptop, in a container for an upstream
project, acting as your personal self — pushing with a personal SSH key and
opening the pull request with a personal token — while the host’s own gh
stays signed in for work and never notices.
Create the token
Sign in as the account that should own it, then github.com/settings/personal-access-tokens/new.
| Field | Value |
|---|---|
| Resource owner | the account that owns the repository |
| Repository access | Only select repositories — name this project’s |
| Expiration | 90 days, or something you will actually notice |
| Permission | Level | Needed for |
|---|---|---|
| Metadata | Read-only | mandatory, granted automatically |
| Contents | Read and write | reading files through the API, releases |
| Pull requests | Read and write | gh pr create, gh pr merge |
| Issues | Read and write | gh issue — skip it if you do not use it |
| Workflows | Read and write | only if you edit .github/workflows/ |
Copy the value. GitHub shows it once.
A fine-grained token can only act on repositories owned by its resource
owner. Opening a pull request against someone else’s project is a call
against their repository, so a fine-grained token has no reach there and
gh pr create fails with Resource not accessible by personal access token.
Contributing upstream through a fork needs a classic token with
public_repo.
The full version of that, including the two-identity setup this section is a condensed form of, is in git and gh CLI in a dev container.
Store it in the host’s secret store
security add-generic-password -a project -s gh-token-project -U -wLeave -w bare and last. security then prompts for the value instead of
taking it as an argument, so the token never lands in your shell history or in
the process table. -U updates an existing item, which is what rotation needs.
Export it, gated
Unlike the timezone, this one is a credential, so it exists only in the transient shell VS Code spawns to read your environment:
if [[ -n $VSCODE_RESOLVING_ENVIRONMENT ]]; then
export PROJECT_GH_TOKEN="$(security find-generic-password \
-a project -s gh-token-project -w 2>/dev/null)"
fiNever export it as GH_TOKEN on the host. gh prefers GH_TOKEN over its
stored credentials, so every terminal on your machine would silently become
that account. You find out weeks later, when a work issue has been filed under
the wrong name.
The rename to GH_TOKEN happens on the way into the container and nowhere
else.
Pass it in as GH_TOKEN
"remoteEnv": {
"GH_TOKEN": "${localEnv:PROJECT_GH_TOKEN}"
}remoteEnv for the token, containerEnv for the timezone. containerEnv
bakes the value into the container: it shows up in docker inspect, and it is
fixed until you rebuild, so a rotated token means a full rebuild. remoteEnv
is applied by VS Code to terminals, tasks and debug sessions, so nothing is
stored in the container’s configuration.Verify
echo "${GH_TOKEN:+set}"
gh auth statusThe first prints set or nothing, so you confirm it arrived without putting
the secret on screen. The second names the account and, in parentheses, where
the credential came from:
github.com
✓ Logged in to github.com account you (GH_TOKEN)GH_TOKEN in the parentheses is what you want. keyring or oauth_token
means it found a stored login instead, and you are about to act as the wrong
account.
4. Claude forwarding
The era of AI coding agents is here, whether you like it or not. And unless yours is GitHub Copilot — which rides VS Code’s own sign-in — you will be authenticating again on every rebuild, and again in every other project’s container.
Claude Code keeps its state in two places, and that split is the reason most attempts at fixing this fail:
| Path | Holds |
|---|---|
~/.claude/ | settings, commands, agents, history |
~/.claude.json | the OAuth account, personal MCP servers, per-project trust |
Persist the directory and not the file, and you will still be logged out on every rebuild while everything looks like it should have worked.
CLAUDE_CONFIG_DIR is what closes the gap: point it at the mounted path and
Claude Code writes both inside it. That single variable is the piece people
miss.
Three ways to go, and they differ only in what you are willing to expose. 4.1 is the preferred approach when every container you open holds your own code. Use 4.2 or 4.3 for anything third-party.
| Shares credentials | Survives rebuild | Container can read your host login | |
|---|---|---|---|
4.1 — bind-mount ~/.claude | across every container | yes | yes |
| 4.2 — named volume per project | no | yes | no |
| 4.3 — long-lived token | across every container | yes | no |
4.1 Bind-mount your host login — preferred
The simplest, and the right default when every container you run holds your own code.
{
"mounts": [
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind"
],
"containerEnv": {
"CLAUDE_CONFIG_DIR": "/home/vscode/.claude"
}
}Replace /home/vscode with whatever remoteUser’s home directory actually is.
Microsoft’s base images use vscode; the Node ones use node. Rebuild once,
sign in once, and every container carrying this mount is signed in from then
on.
4.2 A named volume per project
Same durability, no sharing. A named volume keyed to the container survives rebuilds without ever touching your host’s credentials:
{
"mounts": [
"source=claude-code-config-${devcontainerId},target=/home/vscode/.claude,type=volume"
],
"containerEnv": {
"CLAUDE_CONFIG_DIR": "/home/vscode/.claude"
},
"postCreateCommand": "sudo chown -R vscode:vscode /home/vscode/.claude"
}${devcontainerId} is a stable identifier for this dev container, so the volume
name is unique per project and constant across rebuilds.
chown, Claude Code
cannot write its config and fails in a way that reads like a login problem
rather than a permissions one.You authenticate once per project instead of once per machine. Rebuilds are free.
4.3 A long-lived token
If you would rather mount no credentials at all, generate a long-lived token on
the host and pass it in as a variable, exactly like the gh token above.
Generate the token, once, on the host
On the machine with a browser, where you are already signed in:
claude setup-tokenThe same OAuth flow opens, and the terminal prints a token:
sk-ant-oat01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxClaude Code does not save this anywhere. That printout is the only time you see it — copy it into your password manager or secret store immediately.
It lasts a year, requires a Pro, Max, Team or Enterprise subscription, and can only make model requests: no Remote Control sessions, no claude.ai connectors. MCP servers you configure locally in a project still work.
Store it in the host’s secret store
Not in devcontainer.json — that file is committed. It goes in the same place
the gh token does, under its own name.
security add-generic-password -a project -s claude-token-project -U -wLeave -w bare and last. security then prompts for the value instead of
taking it as an argument, so the token never lands in your shell history or in
the process table. -U updates an existing item, which is what you will want in
a year when this one expires.
Read it back to confirm it stored:
security find-generic-password -a project -s claude-token-project -wIf you would rather not use the secret store
Roughly in order of convenience against safety:
| Where | Notes |
|---|---|
| Host secret store | The route above. Best default. |
A gitignored .env, via envFile | Plaintext in the working tree. Workable, not good. |
| A secrets manager or vault | No plaintext on disk at all. |
| A Codespaces secret | Exposed as an environment variable automatically. |
Export it, gated
Same guard as the gh token, so the value exists only in the shell VS Code
spawns to read your environment:
if [[ -n $VSCODE_RESOLVING_ENVIRONMENT ]]; then
export PROJECT_CLAUDE_TOKEN="$(security find-generic-password \
-a project -s claude-token-project -w 2>/dev/null)"
fiCLAUDE_CODE_OAUTH_TOKEN on the host. Claude Code reads
that variable before your own /login credentials, so every terminal on the
machine would start using the standing token instead. The rename happens on the
way into the container, and nowhere else.Pass it in
"remoteEnv": {
"CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:PROJECT_CLAUDE_TOKEN}"
}Verify
Rebuild, open a terminal in the container, and start it:
claudeIt should come up already authenticated, with no browser prompt. /status
inside Claude Code names which credential source is active.
Four things to know about the token route.
Precedence. CLAUDE_CODE_OAUTH_TOKEN is checked before your subscription’s
/login credentials, but after ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN
and apiKeyHelper. If your image or your shell also sets ANTHROPIC_API_KEY,
that wins, and you are billed on API credits instead of your subscription.
unset ANTHROPIC_API_KEY if that happens.
Bare mode. Claude Code launched with --bare does not read
CLAUDE_CODE_OAUTH_TOKEN at all. Those setups need ANTHROPIC_API_KEY or an
apiKeyHelper.
Expiry. One year. Re-run claude setup-token and update wherever you
stored it.
Blast radius. It is a standing credential with full access to your subscription, in every container you drop it into.
Whichever option you pick, a dev container is not a security boundary for
this. Run with --dangerously-skip-permissions and nothing stops a malicious
project from reading anything reachable inside the container — including
~/.claude and anything in the environment.
So: 4.1 for your own code. 4.2 or 4.3 for anything third-party, and a token you can revoke without touching your host login. The Claude Code dev container docs are explicit about this, and worth reading before you mount your real credentials into a stranger’s repository.
5. Run as a user, not root
Docker runs as root unless told otherwise, and a dev container inherits that.
It works, and it costs you in two ways.
The visible one is ownership. Your workspace is a bind mount, so a file the
container creates as root is owned by root on the host too. On Linux you
then cannot edit your own source file without sudo, and git status starts
reporting changes you did not make.
The invisible one is everything a compromised dependency can do with root in
a container that has your SSH key and your tokens in it.
The only real argument for root is binding a port below 1024. If this is a
hobby project, or anything where you choose the ports, use 8080 or 8043 and the
argument goes away.
Pick the user
The Microsoft base images already ship a vscode user at uid 1000 with
passwordless sudo, so on those there is nobody to create — you just have to
say so.
{
"name": "your-project",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"containerUser": "vscode",
"remoteUser": "vscode",
"updateRemoteUserUID": true
}Both keys, because they are different jobs:
| Key | Who it sets |
|---|---|
containerUser | the user the container process itself runs as |
remoteUser | the user VS Code’s server, terminals and tasks run as |
Set only remoteUser and the container’s own entrypoint, plus anything started
outside VS Code, is still root.
updateRemoteUserUID defaults to true and is the one that makes ownership
come out right on Linux: it rewrites the container user’s uid and gid to match
yours on the host, so files created on either side belong to the same person.
It is a no-op on macOS and Windows, where the file sharing layer already
translates ownership.
Point every home-directory path at that user
Every mount target in this guide is /home/vscode/.... If you changed the
user, change them all — a mount into /home/vscode on a container whose user
is node creates a directory nobody reads, and the symptom is Claude asking
you to log in again with everything apparently configured.
Keep off the privileged ports
Ports below 1024 need root, or a capability you have to grant deliberately.
Choose above it:
"forwardPorts": [8043, 8080]If something genuinely must listen on 443 inside the container, the way to do
it without root is a capability on the binary, not a privileged container:
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/your-serverVerify
id
touch /workspaces/your-project/ownership-check && ls -l /workspaces/your-project/ownership-checkid should print uid=1000(vscode), not uid=0(root). Then check the same
file on the host — it should be yours, with no sudo needed to delete it.
The whole thing
All five, assembled. Same project, same user, same variable names as everywhere above.
On the host
One block in your shell profile serves every container on the machine. The timezone is ungated; the credentials are not.
# Timezone and key filename — neither is a secret, so no guard.
export HOST_TZ="${$(readlink /etc/localtime)##*/zoneinfo/}"
export PROJECT_SSH_KEY=id_ed25519_project
# Everything below exists only while VS Code resolves the environment.
if [[ -n $VSCODE_RESOLVING_ENVIRONMENT ]]; then
# Load this project's SSH key into the agent, if it is not there already.
key=~/.ssh/id_ed25519_project
fp="$(ssh-keygen -lf "$key.pub" 2>/dev/null | awk '{print $2}')"
if [[ -n $fp ]] && ! ssh-add -l 2>/dev/null | grep -q "$fp"; then
ssh-add --apple-use-keychain "$key" >/dev/null 2>&1
fi
unset key fp
# Credentials, out of the login keychain.
export PROJECT_GH_TOKEN="$(security find-generic-password \
-a project -s gh-token-project -w 2>/dev/null)"
fiUsing bash, the same block goes in ~/.bashrc — VS Code runs the shell as both
interactive and login, so make sure ~/.bash_profile sources it, as Ubuntu’s
default already does. For fish, the guard is
test -n "$VSCODE_RESOLVING_ENVIRONMENT" in ~/.config/fish/config.fish.
In the repository
One file, one image.
{
"name": "your-project",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"containerUser": "vscode",
"remoteUser": "vscode",
"updateRemoteUserUID": true,
"containerEnv": {
"TZ": "${localEnv:HOST_TZ}",
"CLAUDE_CONFIG_DIR": "/home/vscode/.claude"
},
"remoteEnv": {
"GH_TOKEN": "${localEnv:PROJECT_GH_TOKEN}"
},
"mounts": [
"source=ssh-home-${devcontainerId},target=/home/vscode/.ssh,type=volume",
"source=${localEnv:HOME}/.ssh/id_ed25519_project,target=/home/vscode/.ssh/id_container,type=bind,readonly",
"source=${localEnv:HOME}/.ssh/id_ed25519_project.pub,target=/home/vscode/.ssh/id_container.pub,type=bind,readonly",
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind"
],
"forwardPorts": [8043],
"postCreateCommand": "sudo chown \"$(id -u):$(id -g)\" /home/vscode/.ssh && sudo chmod 700 /home/vscode/.ssh && ln -sfn \"${containerWorkspaceFolder}/.devcontainer/ssh-config\" /home/vscode/.ssh/config"
}Host github.com github-project
HostName github.com
User git
IdentityFile ~/.ssh/id_container
IdentitiesOnly yesThe one command that checks all five
date && id && ssh-add -l && echo "${GH_TOKEN:+GH_TOKEN set}" && echo "$CLAUDE_CONFIG_DIR"Local time, uid=1000(vscode), a key fingerprint, GH_TOKEN set, and a path.
Anything missing points at exactly one of the five sections above.
When something comes back empty
VS Code cached a stale environment
The first thing to check, every time, because it explains most of the rest.
The resolved environment is computed once per application session. Reload
Window does not redo it. Rebuild Container does not redo it. Quit VS Code
entirely, wait a minute or two, reopen it from Spotlight or the app launcher —
not from code . — and only then rebuild.
Launching from a terminal is its own version of the same trap: VS Code inherits
that terminal’s environment rather than resolving a fresh one, so a terminal
opened before you edited ~/.zshrc carries none of your changes.
The guard never fired
Prove it rather than guessing. Add a probe to your shell rc:
[[ -n $VSCODE_RESOLVING_ENVIRONMENT ]] && date >> /tmp/vscode-env-probeQuit and relaunch VS Code, then look at the file. A new line means the guard fires and the lookup inside it is at fault. No line means VS Code is not resolving your shell at all. Remove the probe afterwards.
Resolution timed out
VS Code gives shell resolution ten seconds, then gives up with “Unable to resolve your shell environment in a reasonable time” — and when it gives up, every variable is absent, not just the slow one.
A ssh-add waiting on a passphrase prompt is the usual cause. So is a version
manager doing real work at shell start. Keep everything inside the guard
non-interactive and fast.
git works but gh does not, or the reverse
Expected, and a useful clue: they share nothing.
git failing is an SSH problem — check ssh-add -l inside the container, the
remote URL, and IdentitiesOnly yes in the ssh config. gh failing is a token
problem — check echo "${GH_TOKEN:+set}" and what gh auth status names in
parentheses.
Claude asks you to log in again
Three causes, in order of likelihood.
CLAUDE_CONFIG_DIR is not set, so ~/.claude.json — which holds the OAuth
account — was written outside the mount and vanished with the container.
The mount target is not the remote user’s home. Check echo $HOME in the
container against the path in your mounts.
The volume is root-owned and Claude Code cannot write to it. ls -ld $CLAUDE_CONFIG_DIR, then sudo chown -R vscode:vscode it.
The container’s clock is still UTC
echo $HOST_TZ on the host first. Empty means the export never ran — and
since that one is ungated, it is a shell profile problem, not a VS Code one.
Set on the host but not in the container means the variable is in remoteEnv
where it should be in containerEnv, or the image has no /usr/share/zoneinfo
and cannot resolve the name. Most base images carry it; a slim or alpine
one may not, and needs tzdata installed.
Files come back owned by root
containerUser is missing. remoteUser alone leaves the container’s own
processes as root, and anything they create is root-owned on the host too.
On a Linux host where your uid is not 1000, also confirm
updateRemoteUserUID is on — or, under compose, that the Dockerfile built the
user with your real ids.
Worth doing once
None of these five is clever. They are each about fifteen minutes, and then
they are permanent: the next project copies a devcontainer.json and starts
with the right time, the right key, the right GitHub account, a signed-in
agent, and files that belong to you.
The part worth internalising is not any single setting. It is the shape they all share — the host keeps the secret and decides the identity, the container receives a value it never stores, and the repository holds the wiring but never the credential. Everything above is that sentence, five times.