Sign inSign up

iflip721/code-server

By iflip721

Updated about 2 months ago

Browser-based React/Vite/TypeScript dev environment with Claude Code, Playwright, Bun and Deno

Buildkit cache
Image
0

1.0K

iflip721/code-server repository overview

iflip721/code-server

A single Docker container that gives you a full React / Vite / TypeScript development environment in the browser, with Claude Code installed and ready.

Runs on a remote host, reached over a Cloudflare Tunnel (or any reverse proxy). Built for linux/amd64 and linux/arm64.

Browser (anywhere) ──https──► Cloudflare ──tunnel──► host ──► code-server container
                                                              ├── Node 24 · pnpm · Vite · TypeScript
                                                              ├── Claude Code (CLI + editor panel)
                                                              ├── Playwright + Chromium
                                                              ├── Python 3 · uv · Bun · Deno
                                                              └── Docker CLI → host daemon

Quick start

On the host that will run the container:

git clone <this-repo> && cd Code-Server
cp .env.example .env && chmod 600 .env
$EDITOR .env                       # set PASSWORD, GIT_PAT, GIT_AUTHOR_*, DOCKER_GID
make pull && make up
make logs                          # the preflight block tells you what it detected

Then point your tunnel at http://localhost:$PORT (8080 by default) and open it in a browser.

To verify everything came up correctly:

make verify

What's inside

Editorcode-server 4.131.0 (Debian 13), running as non-root coder
NodeNode 24 LTS, npm, pnpm, yarn, corepack (honours packageManager)
JS toolingTypeScript, Vite (via npx), ESLint, Prettier, tsx, serve, npm-check-updates
AIClaude Code CLI + the anthropic.claude-code editor extension
TestingPlaywright with Chromium and all OS dependencies
Other runtimesBun, Deno, Python 3, uv (for uvx MCP servers)
VCSgit, git-lfs, GitHub CLI, PAT-based credential helper
ContainersDocker CLI, buildx and compose plugins → host daemon
Shellripgrep, fd, fzf, bat, jq, tree, tmux, htop, rsync

Extensions preinstalled: Claude Code, ESLint, Prettier, Tailwind CSS, Playwright, Vitest, GitLens, Error Lens. All are pulled from Open VSX, which is code-server's default marketplace — no registry substitution needed.

On image size

The image is around 5.9 GB unpacked. That is large, and it is mostly four things you asked for:

Playwright Chromium + its OS dependencies~1.4 GB
code-server itself (from the base image)~0.9 GB
Claude Code — CLI binary plus the extension's own bundled binary~0.5 GB
Node, build-essential, Bun, Deno, Python, Docker CLI~1.1 GB

Nothing here is padding, but two cuts are easy if you want a smaller image: drop --install-extension anthropic.claude-code if you only use the CLI (−270 MB), or drop build-essential if none of your dependencies compile native modules (−250 MB, but npm install will start failing on packages without prebuilt binaries).

On the wire it is 1.42 GB compressed per architecture, and after the first pull only changed layers transfer.


Configuration

Everything is set through .env. See .env.example for the annotated list; the ones that matter most:

VariablePurpose
PORTHost port for the editor. Container-side it is always 8080.
WORKSPACE_PATHWhere projects are stored. Empty = Docker named volume; an absolute path = that host directory.
VSCODE_EXTENSIONSWhere extensions and their settings are stored. Same empty-or-path rule.
DOT_CLAUDEWhere Claude Code's config lives. Same rule. Holds live credentials — see below.
PUID / PGIDuid/gid to run as. Needed when any of the three paths above is a host directory.
PASSWORD / HASHED_PASSWORDEditor login. Prefer the hash — make hash-password generates it.
GIT_PATOne personal access token for every git forge. Also authenticates gh.
GIT_PAT_USERUsername sent with the token. Only needed for forges that reject the defaults.
GIT_AUTHOR_NAME / GIT_AUTHOR_EMAILCommit identity. Without these, commits fail.
GIT_FORCE_HTTPS1 rewrites git@…: remotes to HTTPS so they use the PAT.
DOCKER_GIDGID of the host's docker group. stat -c '%g' /var/run/docker.sock
BIND_ADDRHost interface to publish on. Leave at 127.0.0.1 behind a tunnel.
CLAUDE_CODE_OAUTH_TOKENOptional, for non-interactive Claude sign-in.

.env is not passed wholesale into the container. Deployment knobs (PORT, WORKSPACE_PATH, BIND_ADDR, PUID/PGID, DOCKER_GID) are consumed by docker compose on the host; only secrets and identity are forwarded inside. That separation is deliberate — a bare PORT in the container's environment gets picked up by any dev server reading process.env.PORT and makes it bind a port that isn't published. To pass extra variables of your own into the container (app API keys and such), put them in a .env.extra file, which is loaded automatically if present.

There is no PATH setting, and there deliberately never will be. PATH is the executable search path: defining it here would overwrite the container's and break node, claude and every other binary in the image. Use WORKSPACE_PATH for the project directory.

Where your projects live

Both options are persistent; the difference is who manages the storage.

WORKSPACE_PATH=              # Docker named volume — Docker owns it, no permission setup
WORKSPACE_PATH=/srv/projects # a host directory you can see, back up and rsync yourself

With a host directory you must also match the container to whoever owns those files, or it cannot write to them:

stat -c '%u %g' /srv/projects   # -> e.g. 1001 1001
# then in .env:  PUID=1001  PGID=1001

If you get this wrong the container tells you the exact values to use and refuses to chown your directory — rewriting ownership of real files on your host is not something a container should do on its own.

Changing PUID later is safe. The container's own state volumes (Claude credentials, extensions, config, caches) keep the ownership they were created with, so a uid change would otherwise silently break all of them at once; a startup hook detects the mismatch and adopts those four volumes. Your workspace is deliberately excluded from that.

Where extensions live

VSCODE_EXTENSIONS follows the same rule as WORKSPACE_PATH:

VSCODE_EXTENSIONS=                            # named volume `cs-data`
VSCODE_EXTENSIONS=/srv/code-server/extensions # a host directory

It mounts code-server's whole data directory, which is what makes extensions and their settings travel together:

extensions/   installed extensions
User/         settings.json, keybindings.json, snippets
              — the settings that configure those extensions

You do not need to pre-populate it. The extensions baked into the image are copied in on first start whether the target is an empty volume or an empty host directory, because the seeding is done by a startup hook rather than by Docker's volume-seeding, which only fires for named volumes.

Use a directory dedicated to this. The container sets its ownership to PUID:PGID so code-server can install into it — unlike the workspace, which it refuses to chown.

Where Claude Code's config lives

DOT_CLAUDE follows the same rule again:

DOT_CLAUDE=                             # named volume `claude`
DOT_CLAUDE=/srv/code-server/dot-claude  # a host directory

Useful when you want to version-control the parts of ~/.claude that are genuinely yours — agents/, commands/, skills/, settings.json — or keep session history somewhere you can back up.

This directory holds live credentials. .credentials.json is your OAuth token at mode 0600, and .claude.json carries your account and per-project trust decisions. Point it at a directory dedicated to this container, not at your own ~/.claude on a shared machine. If you commit it to git, exclude .credentials.json and .claude.json.

An empty directory is fine. A startup hook writes the one file a fresh install needs, .claude.json with installMethod, without which claude doctor reports "Running native installation but config install method is 'not set'". Docker's volume seeding used to supply that, but it only fires for named volumes and never for bind mounts — so the hook now handles both identically.

The seed is deliberately minimal. The installer's own .claude.json also contains a build-time machineID and userID, and baking those into a public image would give every container that pulls it the same identifiers; left out, Claude Code generates fresh ones per container.

How Git tokens are handled

GIT_PAT is a single token used for every HTTPS git host — GitHub, GitLab (hosted or self-managed), Gitea, Forgejo, Bitbucket, Azure DevOps. The token is what authenticates; the username that has to accompany it is just protocol furniture, and the forges disagree about it, so the helper fills in the right one per host (x-access-token for GitHub, oauth2 for GitLab, pat for Azure DevOps, and so on). Set GIT_PAT_USER only if your forge rejects those — Gitea and Forgejo sometimes want a real account name.

Tokens are never written to disk inside the container. Instead of a ~/.git-credentials file, git config --global credential.helper env points at /usr/local/bin/git-credential-env, which answers Git's credential query from the process environment and exits. It hands the token to HTTPS endpoints only.

Scope the token to the minimum your work needs — for GitHub, a fine-grained PAT with Contents read/write and Metadata read is enough to clone, pull and push.

The gh CLI is GitHub-only and uses GIT_PAT automatically. If your PAT belongs to another forge, gh simply stays unauthenticated; set GH_TOKEN separately if you want both.


Exposing it

The image contains no TLS and no tunnel client — that is deliberate, so it works behind Cloudflare Tunnel, Traefik, Caddy or anything else without rebuilding. See templates/cloudflared-config.yml.example for a full ingress file, including the originRequest timeouts that keep the editor's websocket from being torn down mid-session.

Minimum viable ingress:

ingress:
  - hostname: code.example.com
    service: http://localhost:8080
  - service: http_status:404

Put Cloudflare Access in front of it. The editor password is one shared secret on a publicly resolvable hostname. Access adds real SSO before anyone reaches the login page, and it is free.


Running a dev server

Bind to all interfaces — the default localhost is only reachable from inside the container:

npm run dev -- --host

Two things break a tunnelled Vite server, and both need config: server.allowedHosts (which rejects your public hostname by default) and the HMR websocket (which otherwise tells the browser to dial port 5173 over plain ws). templates/vite.config.ts.example has the working config for both the dedicated-hostname approach and code-server's /absproxy/5173/ path proxy, with an explanation of when to pick which.

Ports 5173, 4173 and 3000 are published alongside 8080.


Getting files in and out

Your browser is not on the same machine as the container, so there is no live two-way link to your laptop's disk:

  • Upload — drag files onto the Explorer pane.
  • Download — right-click a file or folder → Download.
  • Git — the right answer for anything non-trivial.

Cloudflare caps proxied request bodies at 100 MB on free plans, which is a ceiling on drag-and-drop uploads. Use git for anything larger.


Signing in to Claude Code

Use a token. Don't fight the browser flow. On any machine where Claude Code already works:

claude setup-token          # opens your browser, prints a one-year token

Put it in .env and restart:

CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...

That is the whole setup, and it is the only approach that works cleanly here.

Why the browser flow breaks in a container

Running claude interactively starts a one-shot HTTP listener on a random high port inside the container and asks Anthropic to redirect the browser to http://localhost:<that port>/callback. Your browser resolves localhost to the machine it is running on — your laptop — where nothing is listening. You get a dead page with the code sitting in the URL:

http://localhost:41243/callback?code=8L8Inw...&state=KNK7XI...
                      ^^^^^ this port only exists inside the container

The port is chosen fresh each attempt and claude auth login has no flag to pin it, so it cannot be published ahead of time. Host networking doesn't help either, because the browser is on a different machine entirely.

If you are mid-login and want to finish it: copy the entire URL out of the browser's address bar and paste it at the Paste code here if prompted > prompt. The CLI parses a full URL, not just the bare code. Then switch to CLAUDE_CODE_OAUTH_TOKEN so you never do it again.

Credentials live in the claude volume and survive restarts and image upgrades. CLAUDE_CONFIG_DIR is set so .claude.json lands in that same volume — without it, account state would sit outside the mount and you would be signed out on every recreate.


What persists

VolumeContents
workspace/home/coder/workspace — your code. Replaced by a bind mount when WORKSPACE_PATH is set.
claudeClaude Code credentials, settings, session history
cs-dataInstalled extensions and editor state
cs-configcode-server config, git config, gh config
cs-cachenpm / pnpm / uv caches

Anything outside these is replaced when the image is upgraded, which is the intent — the image is disposable, the volumes are not.

make down keeps volumes. make clean destroys them, and asks first.


Building and publishing

make build     # native arch only, loaded locally — for iterating
make push      # linux/amd64 + linux/arm64 → Docker Hub

make push needs docker login first. It writes a registry build cache to iflip721/code-server:buildcache, which is what makes subsequent builds fast — including on a machine that has never built this image before.

Cold multi-arch builds take 20–40 minutes: the non-native architecture runs under QEMU emulation, and apt, the Chromium download and npm installs all happen twice. Iterate with make build and only run make push when you are ready to publish.

Version comes from the VERSION file. Each push tags :latest, :$VERSION and :cs4.131.0.


Troubleshooting

docker commands fail inside the container. The socket is mounted but your GID does not match. Supplementary groups are fixed before the container's entrypoint runs, so nothing inside can fix this at runtime — it has to be group_add on the container. Run stat -c '%g' /var/run/docker.sock on the host, put it in .env as DOCKER_GID, and make up. On Docker Desktop for Mac use DOCKER_GID=0. The preflight block in make logs prints the exact value it saw.

File watching stops working in a large repo. You have exhausted the host's inotify watches, which code-server and every dev server share. This is a host sysctl and cannot be set from an unprivileged container:

echo 'fs.inotify.max_user_watches=524288' | sudo tee /etc/sysctl.d/99-inotify.conf
sudo sysctl --system

A .pnpm-store directory appears in your workspace. That is correct and you should leave it there. $HOME and the workspace are separate volumes, which means separate filesystems, and pnpm's hardlinking only works within one filesystem. pnpm detects this and puts the store next to your projects so it can still hardlink. Forcing the store into ~/.cache would silently downgrade pnpm to copying every package.

Playwright says the browser executable doesn't exist. Your project pins a different Playwright version than the one baked into the image (1.62.1), and browser builds are version-specific. /ms-playwright is writable, so just run npx playwright install chromium in the project.

Chromium crashes or hangs during tests. Confirm ipc: host is still set in docker-compose.yml. Without it Chromium runs out of shared memory and dies.

The editor reconnects every few minutes. Your proxy is timing out the websocket. See the originRequest block in templates/cloudflared-config.yml.example.

An extension isn't in the marketplace. code-server uses Open VSX, not the Microsoft Marketplace, and some Microsoft-published extensions are not there. Everything this image preinstalls is confirmed available.


Security

This container is deliberately permissive in two ways. Both are worth understanding before you expose it.

The Docker socket is mounted. Anything in the container can create containers on the host, which is equivalent to root on the host. That includes Claude Code acting on instructions it read in a file or web page. If you do not need to build images from the editor, delete the socket mount and group_add from docker-compose.yml — nothing else depends on them.

Tokens are in the container's environment. Claude Code and every process you start can read GIT_PAT. Use fine-grained, minimum-scope, expiring tokens, keep .env at mode 600, and never bake a token into the image (.dockerignore excludes .env from the build context).

The container runs as unprivileged coder (uid 1000), never root. That is also what allows Claude Code to accept --dangerously-skip-permissions if you want unattended runs — though pair that with an egress firewall, because the container can otherwise reach anything your network allows.

For a stronger posture: put Cloudflare Access in front of the tunnel, drop the Docker socket, and use a PAT scoped to a single repository.

Tag summary

Content type

Image

Digest

sha256:f3e5b97a9

Size

1.3 GB

Last updated

about 2 months ago

docker pull iflip721/code-server