Browser-based React/Vite/TypeScript dev environment with Claude Code, Playwright, Bun and Deno
1.0K
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
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
| Editor | code-server 4.131.0 (Debian 13), running as non-root coder |
| Node | Node 24 LTS, npm, pnpm, yarn, corepack (honours packageManager) |
| JS tooling | TypeScript, Vite (via npx), ESLint, Prettier, tsx, serve, npm-check-updates |
| AI | Claude Code CLI + the anthropic.claude-code editor extension |
| Testing | Playwright with Chromium and all OS dependencies |
| Other runtimes | Bun, Deno, Python 3, uv (for uvx MCP servers) |
| VCS | git, git-lfs, GitHub CLI, PAT-based credential helper |
| Containers | Docker CLI, buildx and compose plugins → host daemon |
| Shell | ripgrep, 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.
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.
Everything is set through .env. See .env.example for the annotated list;
the ones that matter most:
| Variable | Purpose |
|---|---|
PORT | Host port for the editor. Container-side it is always 8080. |
WORKSPACE_PATH | Where projects are stored. Empty = Docker named volume; an absolute path = that host directory. |
VSCODE_EXTENSIONS | Where extensions and their settings are stored. Same empty-or-path rule. |
DOT_CLAUDE | Where Claude Code's config lives. Same rule. Holds live credentials — see below. |
PUID / PGID | uid/gid to run as. Needed when any of the three paths above is a host directory. |
PASSWORD / HASHED_PASSWORD | Editor login. Prefer the hash — make hash-password generates it. |
GIT_PAT | One personal access token for every git forge. Also authenticates gh. |
GIT_PAT_USER | Username sent with the token. Only needed for forges that reject the defaults. |
GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL | Commit identity. Without these, commits fail. |
GIT_FORCE_HTTPS | 1 rewrites git@…: remotes to HTTPS so they use the PAT. |
DOCKER_GID | GID of the host's docker group. stat -c '%g' /var/run/docker.sock |
BIND_ADDR | Host interface to publish on. Leave at 127.0.0.1 behind a tunnel. |
CLAUDE_CODE_OAUTH_TOKEN | Optional, 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
PATHsetting, and there deliberately never will be.PATHis the executable search path: defining it here would overwrite the container's and breaknode,claudeand every other binary in the image. UseWORKSPACE_PATHfor the project directory.
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.
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.
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.jsonis your OAuth token at mode 0600, and.claude.jsoncarries your account and per-project trust decisions. Point it at a directory dedicated to this container, not at your own~/.claudeon a shared machine. If you commit it to git, exclude.credentials.jsonand.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.
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.
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.
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.
Your browser is not on the same machine as the container, so there is no live two-way link to your laptop's disk:
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.
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.
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.
| Volume | Contents |
|---|---|
workspace | /home/coder/workspace — your code. Replaced by a bind mount when WORKSPACE_PATH is set. |
claude | Claude Code credentials, settings, session history |
cs-data | Installed extensions and editor state |
cs-config | code-server config, git config, gh config |
cs-cache | npm / 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.
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.
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.
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.
Content type
Image
Digest
sha256:f3e5b97a9…
Size
1.3 GB
Last updated
about 2 months ago
docker pull iflip721/code-server