I wanted a setup where I could spin up Claude Code agents on a remote server, give them tasks, and have them do real work on code repos — creating branches, opening PRs, waiting for my review. Here's how I built it and the decisions along the way.
The basic idea
Each agent is a Docker container running Claude Code. The container stays alive in the background. I SSH into the server, attach to a container, and talk to the agent directly. When I'm done, I detach and the agent keeps running.
The main agent is managed by Docker Compose and restarts automatically if the server reboots. Parallel agents — for tasks I want to run at the same time — get spun up manually with docker run and torn down when they're done.
Naming things properly
This sounds trivial but it caused a real problem. When you run docker compose build, Docker names the image after the folder you cloned the repo into. So the docker run command for spinning up parallel agents would silently fail if anyone cloned the repo into a differently-named folder.
The fix is one line in docker-compose.yml:
image: sudocat-agent:latest
This tags the built image with a fixed name regardless of folder. Now every docker run command in the docs references sudocat-agent:latest and it just works.
Same thinking applied to container names. By default Compose generates names like agent-assist-sudocat-agent-1. I set container_name: sudocat-agent-1 explicitly. Cleaner and predictable.
Getting in from your laptop
The README had no mention of how to actually connect from a personal computer. You can't SSH directly into a Docker container — you SSH to the server first, then docker exec into the container. The command that makes it work in one step:
ssh -t user@your-server 'docker exec -it sudocat-agent-1 claude'
The -t flag allocates a TTY. Without it the interactive Claude session breaks over SSH.
I also added an alias pattern so you're not typing that every time:
alias agent1='ssh -t user@your-server "docker exec -it sudocat-agent-1 claude"'
Non-root user
Docker runs as root by default. That means if something goes wrong inside the container, it can do anything. Running as a non-root user (sudocatuser) limits the blast radius — the agent can read and write to /workspace where its files are, but it can't touch system files or break the OS inside the container.
The named volume for Claude's config (~/.claude/) also needs to point to the right home directory, which changes when you rename the user. Easy to miss.
GitHub integration
This is the part I thought hardest about. Agents need to write code, and I need to stay in control of what gets merged.
The setup: agents work in repos mounted from ~/github/ on the server. When a task involves code, the agent creates a branch, makes its changes, pushes, and opens a PR with the gh CLI. It never pushes directly to main.
Two things enforce this:
- A fine-grained GitHub personal access token scoped to specific repos with only
contents: writeandpull-requests: write— no admin access - Branch protection on main in each repo requiring a PR before merging
Token scope alone isn't enough. An agent with write access could still push to main if there's no branch protection. You need both layers.
For authentication inside the containers, git uses a credential helper that reads GITHUB_TOKEN from the environment at runtime:
credential.helper = !f() { echo "username=x-access-token"; echo "password=$GITHUB_TOKEN"; }; f
This goes in the Dockerfile under the non-root user so it's set up for every container automatically.
Tracking which agent did what
With multiple containers all using the same token, PRs would otherwise look identical — same author, no way to tell which agent opened which PR.
The fix is branch naming. Each container gets its name injected as an env var (CONTAINER_NAME=sudocat-agent-1). Agents are instructed to name branches sudocat/${CONTAINER_NAME}/<task-slug>. So you end up with branches like sudocat/sudocat-agent-2/refactor-auth and you can immediately see which container did what.
The one thing to remember when spinning up parallel agents: you have to set both --name and -e CONTAINER_NAME to the same value manually. There's no automatic link between them.
What I'd change
The task system is still markdown files dropped into a folder. It works but it's blind — I can't see what's been done without SSHing in. The next step is probably a dedicated GitHub repo for tasks so I can push task files and attachments from my laptop and review output as commits without touching the server.
The infrastructure is simple enough that it's easy to reason about, which matters when something goes wrong at 11pm.