Running Claude Code as Persistent Infrastructure: A Containerised Setup
Most people run Claude Code the obvious way: open a terminal, start a session, get to work. I did too — until I noticed how little of my time was actually going into the work.
The setup was eating it. I work across several machines and I want tasks to move with me — to pick something up and put it down without being tied to the one box where I started it. But context evaporated every time I switched. Authentication — to GitHub, to MCP services, to local databases — was fiddly in a way I’d expected to be trivial, and every friction point pulled focus from whatever I’d actually sat down to build. And because I was still establishing trust with the tooling, I wanted it boxed into an environment I could reason about and reproduce.
Eventually, it was time to short-circuit this pain. I decided to build a proper foundation: Claude Code as a persistent, containerised service I can reach from anywhere — over SSH, inside tmux, so a dropped connection never costs me the session.
The problem, properly framed
Pulled apart, “the setup was eating my time” is really five separate walls, and I hit all of them.
State was ephemeral. Every machine started from zero — no memory of what I’d decided an hour ago on a different box, no thread to pick back up, just a blank session and a project I had to re-orient myself in by hand.
MCP config had to be rebuilt per machine. Each one needed its own copy of the same server list, its own tokens typed in again, and the copies drifted — one box had Elasticsearch wired up, another didn’t, and I wouldn’t find out until I reached for it mid-task.
Authentication was its own tax, paid repeatedly. Git hosting first (Gitea, in the early days, before I settled on GitHub), then every MCP service, then whatever local database a given project needed — each one its own login flow, and none of it travelled with me.
There was no isolation between projects. One Claude Code install touched everything on the machine; project boundaries existed only because I was careful, not because the environment enforced them.
And there was no clean way to run it anywhere but the machine in front of me. Nothing long-running could outlive the terminal window. If I wanted to leave a task going and walk away, I couldn’t — the daily driver had to stay up, and I had to be at it.
None of these are exotic problems. They’re exactly what you’d expect from treating an agent like a local dev tool when what you actually want is a small piece of infrastructure.
Docker vs. bare install
The machines I actually sit at are Windows. Not for lack of options — I spent years on macOS and walked away from it about eighteen months ago, deliberately: I’d gotten too comfortable in that ecosystem, and the comfort itself was the problem.
Claude Code wants Linux underneath it, and once that’s true, the rest isn’t really a decision. Wrestling PowerShell into behaving like bash, versus just running bash, isn’t a contest. Linux wins by default the moment you need a shell an agent is going to live in all day.
So: containerisation, ergo Linux. It follows almost as a formality once the platform question is settled, rather than being a choice made on its own merits. WSL2 would get me a Linux shell on the same box, but it doesn’t get me anywhere near what I actually wanted — an environment that’s identical wherever I reach it from, not a fixed relationship with one physical machine.
There’s a second reason underneath the first, less about the OS and more about trust. I was still building confidence in the tooling — an agent with shell access, credentials, and opinions about my code is not something to hand the run of a daily-driver machine on day one. A container gave me a boundary I could reason about: what it can see, what it can reach, what survives if I throw it away and rebuild. Bare-metal install gets you Claude Code. It doesn’t get you a foundation.
The architecture
Once “containerised” was settled, the interesting decisions were all about what state lives where, and who’s allowed to authenticate as what.
One config directory, not a machine
Claude Code keeps its state — auth, settings, MCP registrations, session history — under a config directory. Point CLAUDE_CONFIG_DIR at a path of your choosing, mount that path as a volume, and the container can be destroyed and rebuilt without losing any of it. Log in once, and every rebuild after that starts already authenticated, with every MCP server still registered.
That sounds obvious written down. It wasn’t obvious in practice, because Claude Code doesn’t keep all of its state in one tidy place by default — more on that in the gotcha below, because it cost me a real evening.
User-scope vs. project-scope MCP
Not every MCP server belongs in the same place. Some are things I want available no matter what I’m working on — Logfire for observability, Elasticsearch for log search — and those go in user scope, registered once against the persistent config directory, present in every project without a second thought.
Others belong to a specific codebase: a server that only makes sense in the context of one project’s stack, that a future collaborator on that repo should also get for free. Those go in project scope — an .mcp.json committed alongside the code, not in my personal config. The rule I use is simple: if it’s about me, it’s user scope; if it’s about the project, it travels with the project.
One image, two postures
I don’t run separate images for interactive work and CI — that turned out to be unnecessary. It’s the same container either way; the only thing that changes is whether ANTHROPIC_API_KEY is set.
Leave it unset, and the container falls back to the ordinary interactive login flow — I run claude, authenticate with my subscription in a browser, and that OAuth token lands on the persistent config volume, so I only ever do it once per container, not once per session. That’s the posture for daily-driver, interactive work.
Set it, and Claude Code authenticates with the key directly, no browser, no human in the loop. That’s the posture for CI — a job that needs to invoke Claude Code non-interactively can’t complete a login flow, and shouldn’t be trying to. Same Dockerfile, same image, one environment variable deciding which kind of thing it is on a given run.
gh, baked in
GitHub CLI ships in the image rather than being something I install per-session, for the same reason Claude Code itself is baked in: I don’t want setup steps between “container starts” and “I can get real work done.” Auth follows the same non-interactive instinct as the API-key posture above — a token supplied through the environment rather than an interactive device-code dance, so a fresh container is already able to open PRs and check CI status the moment it’s up.
dev {project}: tmux as the persistence layer for sessions
SSH gets me into the container, but SSH connections drop — a laptop sleeps, a network blips — and I didn’t want a dropped connection to cost me a running session. tmux is the layer underneath that solves it: sessions live in the container, independent of any one SSH connection, and reattaching just means SSH-ing back in.
The one thing that needed to be more deliberate than “always attach to the same session” was working on more than one project at a time. So dev is a small shell function baked into the image: dev fastapi creates (or attaches to) a tmux session named for that project, rooted in that project’s directory, running claude. Run dev with no argument from inside /workspace/<project> and it infers the project from your current path. Each project gets its own durable session and its own Claude context, and switching between them is a tmux session switch, not a cold start.
The walkthrough
Enough reasoning — here’s the actual thing, close to what’s running.
The Dockerfile
FROM ubuntu:24.04ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \ ca-certificates curl \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ && apt-get update && apt-get install -y \ gh openssh-server nano tmux git iproute2 python3.12 python3.12-venv \ && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/*
# uv, straight from its own image — no installer script neededCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Claude Code itselfRUN curl -fsSL https://claude.ai/install.sh | bashRUN echo 'export PATH="/root/.local/bin:$PATH"' >> /root/.bashrc \ && echo 'export PATH="/root/.local/bin:$PATH"' >> /root/.profile
# The `dev {project}` function — see belowCOPY dev.sh /tmp/dev.shRUN cat /tmp/dev.sh >> /root/.bashrc && rm /tmp/dev.sh
# Login shells (what SSH starts) read .profile, not .bashrc — without this,# `dev` is invisible on a fresh SSH connection. See the bug below.RUN echo '[ -n "$BASH_VERSION" ] && [ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"' >> /root/.profile
RUN mkdir -p /var/run/sshd /root/.sshCOPY authorized_keys /root/.ssh/authorized_keysCOPY tmux.conf /root/.tmux.confRUN chmod 700 /root/.ssh && chmod 600 /root/.ssh/authorized_keysRUN sed -i \ -e 's/#PasswordAuthentication yes/PasswordAuthentication no/' \ -e 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' \ /etc/ssh/sshd_config
COPY entrypoint.sh /entrypoint.shRUN sed -i 's/\r//' /entrypoint.sh && chmod +x /entrypoint.sh
RUN echo 'TEST_DATABASE_URL=postgresql+asyncpg://testuser:testpass@db:5432/testdb' >> /etc/environmentRUN echo 'CLAUDE_CONFIG_DIR=/config' >> /etc/environment
WORKDIR /workspace
ARG GIT_USER_EMAILARG GIT_USER_NAMERUN git config --global --add safe.directory /workspace \ && if [ -n "$GIT_USER_EMAIL" ]; then git config --global user.email "$GIT_USER_EMAIL"; fi \ && if [ -n "$GIT_USER_NAME" ]; then git config --global user.name "$GIT_USER_NAME"; fi
EXPOSE 22ENTRYPOINT ["/entrypoint.sh"]Two build args worth calling out: GIT_USER_EMAIL and GIT_USER_NAME get baked in at build time, not supplied at runtime. That’s deliberate — git identity isn’t something that changes container to container, so there’s no reason to make it an env var I have to remember to set every time.
git config --global --add safe.directory /workspace earns its line too: Git refuses to operate on a repository it doesn’t consider “safe” when the repo’s owner doesn’t match the current user — which is exactly the situation a bind-mounted /workspace creates by default. Without it, every git command inside the container fails with a dubious ownership error before it does anything else.
Volumes and networks
services: claude: build: . image: claude-code-tmux container_name: claude-code volumes: - ./projects-root:/workspace - ./config:/config env_file: - .env networks: - cc-internal # reaches db - cc-external # direct internet access - traefik # allows Traefik to forward SSH to this container labels: - "traefik.enable=true" - "traefik.tcp.routers.claude-ssh.rule=HostSNI(`*`)" - "traefik.tcp.routers.claude-ssh.entrypoints=ssh" - "traefik.tcp.routers.claude-ssh.service=claude-ssh-svc" - "traefik.tcp.services.claude-ssh-svc.loadbalancer.server.port=22" depends_on: db: condition: service_healthy restart: unless-stopped
db: image: postgres:17-alpine container_name: db environment: POSTGRES_USER: testuser POSTGRES_PASSWORD: testpass POSTGRES_DB: testdb healthcheck: test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"] interval: 5s timeout: 5s retries: 5 networks: - cc-internal # internal only — no external access at all tmpfs: - /var/lib/postgresql/data
networks: cc-internal: internal: true # Docker blocks all external routing on this network cc-external: driver: bridge # Claude uses this for direct outbound internet access traefik: external: true # pre-existing Traefik network, not managed by this stackOnly two volumes: projects-root (all my projects, one directory per project, becomes /workspace) and config (the persistent Claude state from the previous section, becomes /config). Everything else is either baked into the image or handled by the network layer.
Inbound access is Traefik forwarding SSH on a TCP router — HostSNI(`*`) because SSH isn’t TLS, so there’s no server name to route on; every connection to that entrypoint lands here. Outbound, the container gets its own bridge network (cc-external) for ordinary internet access — direct, unfiltered. There’s no egress control on that path yet; that’s a separate piece I’m covering on its own. db doesn’t get one at all — cc-internal is marked internal: true, which is Docker refusing to route it anywhere outside the compose stack, full stop. The database that integration tests hit has no path to the internet, by construction, not by convention — and it’s disposable too: its data directory is tmpfs, so every restart starts from an empty database. That’s the right default for a service that only exists to give integration tests something to talk to.
Wiring in an MCP server
The user-scope MCP servers from earlier live inside /config — the same volume that holds Claude’s auth — so registering one is a one-time action per container, not per project. Here’s roughly what that config looks like once Logfire and Elasticsearch are both wired up:
{ "mcpServers": { "logfire": { "type": "http", "url": "https://logfire-eu.pydantic.dev/mcp" }, "elasticsearch": { "type": "stdio", "command": "npx", "args": ["@elastic/mcp-server-elasticsearch"], "env": { "...": "your config goes here" } } }}Two different transports, side by side, and that’s the normal case rather than the exception. logfire is http — a remote endpoint I’m pointing at, no local process involved. elasticsearch is stdio — Claude Code launches npx @elastic/mcp-server-elasticsearch itself and talks to it over stdin/stdout, so the “server” is really just a local process the container starts on demand, configured entirely through env. Neither needs anything from me beyond this file once it’s in place, because it’s sitting on the same persistent volume as everything else.
The entrypoint — and why it barely does anything
#!/bin/bash
# Start SSH daemon in foreground (keeps container alive)exec /usr/sbin/sshd -DThat’s the whole file. Earlier versions of this container tried to be cleverer — auto-launching a tmux session with claude running in it as part of startup — and it was the wrong instinct. A container’s entrypoint should keep the container alive and get out of the way; deciding which project gets a session, and when, is a per-connection decision, not a boot-time one. dev makes that decision at the point I actually SSH in, not before. sshd -D just needs to stay in the foreground so Docker has something to watch.
dev, in full
dev() { local project="$1"
# No argument: if we're somewhere under /workspace, use the current # path relative to /workspace as the project. if [ -z "$project" ] && [ "${PWD#/workspace/}" != "$PWD" ]; then project="${PWD#/workspace/}" fi
if [ -z "$project" ]; then echo "usage: dev {project} (or run with no argument from within /workspace)" >&2 return 1 fi
local dir="/workspace/$project" mkdir -p "$dir" || return 1 cd "$dir" || return 1
# tmux session names can't contain '.' or ':' — swap them for '_' local session="${project//[.:]/_}"
if ! tmux has-session -t "=$session" 2>/dev/null; then tmux new-session -d -s "$session" -c "$dir" 'claude' fi
if [ -n "$TMUX" ]; then tmux switch-client -t "=$session" else tmux attach-session -t "=$session" fi}The branch on $TMUX matters more than it looks: switch-client when I’m already inside tmux (moving between projects without nesting a session inside a session), attach-session when I’m not (the normal case, right after SSH). Get that backwards and you either end up with tmux-in-tmux, which mangles key bindings, or an attach that fails silently because you’re already attached to something else.
Bug found writing this section
While writing the Dockerfile listing above, I actually went back and checked whether dev is reachable the moment you SSH in — and it isn’t, not reliably. dev is appended to .bashrc only, but an interactive SSH session starts a login shell, and login shells read .profile, not .bashrc, unless .profile explicitly sources it. Nothing in this setup did, until now — root doesn’t get the usual Debian skel .profile that regular users do, which is the only reason this normally works invisibly. I reproduced it with a bare bash -l, confirmed the fix, and opened the PR that adds the missing line — it’s in the Dockerfile listing above already. Small bug, but exactly the kind of thing that’s invisible until you sit down and actually trace through what a login shell reads.
The gotcha, in full
Earlier I said the config volume “sounds obvious written down” but wasn’t obvious in practice. Here’s the whole story, because it’s the one that actually cost me an evening, and it’s the direct ancestor of the auth friction I opened this post with.
The first version of this container didn’t think hard about the config directory at all — it just bind-mounted the whole home directory, ./user:/root, on the theory that wherever Claude Code put its state, mounting /root would catch it. That works, in the sense that nothing gets lost. It also means handing a bind mount the entire root user’s home — shell history, dotfiles, anything else that ever ends up there — when what I actually wanted persisted was Claude’s state and nothing else.
So I narrowed it: mount ~/.claude specifically, plus, it turned out, a second file that had to be mounted separately — ~/.claude.json. That one’s easy to miss on paper and easy to find out about the hard way: Claude Code doesn’t keep everything inside ~/.claude/; part of it sits right next to that directory, as a dotfile at the top of the home folder rather than inside it. Mount the directory and skip the file next to it, and the container looks like it’s persisting state — right up until a rebuild, when you find yourself looking at a login prompt again despite being sure you’d already set that up.
Two mounts held for a while. Then I actually started registering MCP servers — Logfire, Elasticsearch — and the same shape of bug showed up again, in a different file: ~/.mcp.json, also outside ~/.claude/, also its own bind mount. Third mount, ./mcp.json:/root/.mcp.json, added for the same reason as the second: something vanished after a rebuild, and the cause was always the same shape — Claude Code’s state wasn’t in one place, and I’d only mounted where I’d already been bitten, not where I hadn’t yet.
Three separate bind mounts, pointed at three separate paths, that all had to move together and were all invisible from the docker-compose file alone — nothing in volumes: told you why three, or whether a fourth was waiting to be discovered. That’s the point where “annoying” tips into “worth fixing properly,” and where I went looking for whether Claude Code had an actual answer to this rather than a workaround. It does: CLAUDE_CONFIG_DIR. Set it, and Claude Code puts all of its state — the directory, the top-level JSON, MCP registrations, everything — under that one path instead of scattering it across $HOME. One environment variable in /etc/environment, one bind mount, and the fragmentation problem doesn’t get solved so much as stop existing, because there’s nothing left outside the mounted path for a rebuild to silently drop.
The lesson wasn’t “read the docs harder” — at the time, nothing pointed at CLAUDE_CONFIG_DIR as the fix for this specific symptom. The lesson was that when persisted state keeps reappearing partially after a rebuild, the right question isn’t “what did I configure wrong,” it’s “what does this tool consider its complete state to be, and am I certain I’ve mounted all of it” — because “mostly persisted” fails exactly like “not persisted,” just later, and with more of your trust already invested by the time it does.
Where that leaves things
None of this is exotic. A base image, two volumes, a tmux function, a side-car database, one environment variable that took an evening to find. What it adds up to is the thing I actually wanted: dev fastapi from any machine, over SSH, into a session that was already there, already authenticated, already knowing what MCP servers it has — and a dropped connection costs me nothing, because the session was never living in the connection to begin with.
That’s also, not incidentally, why this post kept finding real bugs instead of just describing a finished system. The dev-on-login fix went out as an actual PR while I was writing the walkthrough, not as a hypothetical aside — this container has been lived in daily, not built once and photographed. A foundation only earns the word once it’s been rebuilt on top of, more than once, and each time survived.
There’s more to build on top of it — an egress-controlled network layer, a browser-driving MCP server — but neither of those needed a persistent, reachable-from-anywhere base to exist first. This did. Everything after this post is a layer; this post was the floor.