My Potato Runs VS Code in a Browser — and Still Uses Less RAM Than Chrome
I spent three days last year rebuilding my development environment on a new laptop. Three days. Installing dependencies, configuring git, hunting down SSH keys, reinstalling extensions, and squinting at my dotfiles trying to work out which ones I actually needed versus which were archaeological remains from a project I abandoned in 2019 and have been too sentimental to delete.
I still haven't found all the fonts. I've made peace with it. Mostly.
The dream is simple, and if you've ever set up a machine from scratch you've had it too: open a browser on any machine — a rented VPS, a friend's laptop, a sticky airport terminal that has clearly survived things — and have your full IDE already waiting. Extensions installed. Git configured. SSH keys loaded. Docker running. Everything you need, zero setup, no three-day ritual.
I built that. Here's how — and here's every wall I walked into so you don't have to.
Why VS Code?
Before we get into the Docker mechanics, let's settle the obvious question: why VS Code and not one of the other web-based editors? There are others — Eclipse Theia, JetBrains' Fleet, a rotating cast of "this time it's different" browser IDEs. But VS Code wins for boringly practical reasons, and boring is a compliment when it's 11pm and something is on fire.
It's open source. The core editor ships under the MIT license. No corporate lock-in, no subscription wall in front of the thing you stare at all day. Microsoft open-sourced it back in 2015 and — plot twist for a company that once charged for a compiler — hasn't looked back.
It's fast. I was fully prepared to hate this part. "IDE in a browser" sounds like a recipe for input lag and regret. But code-server — the project that puts VS Code on the web — is genuinely snappy. Keystrokes land instantly. File navigation is instant. The only thing slower than VS Code in a browser is VS Code running on a potato, and that's a hardware problem, not a VS Code problem. And here's the punchline that writes itself: the whole code-server process, running my entire IDE, sips less memory than the single Chrome tab I'm viewing it in. Let that sink in. The editor uses less RAM than the browser rendering the editor. My potato is out here doing real work while Chrome opens six helper processes to display a login page. I'm fairly sure this violates a law of thermodynamics, and I refuse to look into it in case it stops.
It's actively maintained. Microsoft ships updates roughly weekly. The extension ecosystem is enormous. The Docker extension, Copilot Chat, Remote SSH — these aren't bolt-ons someone maintains on weekends, they're first-class citizens.
The ecosystem is mature. If you already use VS Code locally, you already know the punchline. Moving to a web instance is seamless because it is VS Code — same UI, same keybindings, same muscle memory. Your fingers won't even notice they've changed continents.
Other tools exist. They're fine. But VS Code is the one I actually want open at 11pm when production is doing an interpretive dance and I have to fix it before the morning standup pretends none of this happened.
Architecture: Docker-in-Docker vs Docker-outside-of-Docker
Before we write a single line of config, there's one fork in the road, and picking the wrong branch means quietly rebuilding half of this later. The question: how does the VS Code container talk to Docker?
There are two answers, and they are absolutely not interchangeable, no matter how similar the acronyms look.
Docker-in-Docker (DinD) runs an entire second Docker daemon inside the container. The container gets its own private, isolated Docker world — Docker running Docker. If you've seen Inception, you already know exactly how this feels: it's a dream inside a dream inside a dream, and a few layers deep you're staring at a little spinning top on the desk, no longer entirely sure how many Docker daemons you're running or which level of reality you left your actual code on. You need a docker ps just to keep track of your docker ps. It works, but it's heavy, it duplicates every image, and it's blissfully unaware of anything running on the host. Somewhere down there is a container fully convinced that it is the host, and honestly, who am I to wake it up? We don't have a kick. We don't have Leonardo DiCaprio. We just have a laptop and a growing suspicion that we're two containers deeper than we meant to be.
Docker-outside-of-Docker (DooD) mounts the host's Docker socket into the container. The container talks straight to the daemon that's already running. No second daemon, no duplicated images, full visibility into everything on the box. This is the one we're using — and, full disclosure, the one I reach for every single time. I personally like Docker-outside-of-Docker: one daemon, one reality, one place where docker ps means what it says and no top-spinning is required to confirm I'm awake. Life is complicated enough without asking "wait, which layer am I in?" every time I want to restart a container.

The key insight: DooD gives the container full command over the host's Docker environment. That's genuinely powerful — you get one Docker, shared by host and IDE, no duplication, complete visibility. It's also exactly why we'll need to have a slightly uncomfortable conversation about security later. Power and "wait, should the browser tab be able to do that?" tend to travel together.
The Setup: Dockerfile
We start from the official code-server base image and build up. Here's the Dockerfile:
FROM codercom/code-server:latest
# Install Docker CLI for host Docker access
USER root
RUN mkdir -p /var/lib/apt/lists/partial && \
apt-get update && apt-get install -y --no-install-recommends docker-cli && \
rm -rf /var/lib/apt/lists/*
USER coder
# Install Copilot Chat extension
RUN SERVICE_URL=https://extensions.coder.com/api \
ITEM_URL=https://extensions.coder.com/item \
code-server --install-extension GitHub.copilot-chatA few things earned their place here through suffering, so let me call them out:
docker-cli is the package you want — not docker.io. Debian's docker.io package installs the Docker daemon, which we very much do not want, because we already have a daemon on the host and the whole point of DooD is to not run a second one. I learned this the honest way: I ran apt-get install docker.io, watched it succeed, typed docker ps, and got "command not found." I spent twenty minutes interrogating my life choices before realizing I'd installed a whole daemon and still didn't have the CLI. Install docker-cli. It's a separate package. It's the one you actually need.
The mkdir -p /var/lib/apt/lists/partial line looks pointless. It is not. The base image ships without that directory, and apt-get update face-plants with a permission error if it's missing. It's one of those Docker quirks that exists purely to make you doubt yourself at 1am.
The USER root → USER coder shuffle is deliberate. The base image runs as a non-root user (good!), but installing packages needs root (annoying!), so we briefly become root, do the plumbing, and drop straight back to coder for the rest of the container's life. Root for the chores, coder for the living. Don't leave the container running as root because it was convenient during the build — that's how "temporary" becomes "the security incident."
One more note that will save you a confused evening: those SERVICE_URL / ITEM_URL variables aren't decoration. code-server can't use Microsoft's official extension marketplace — that's licensed for Microsoft's own builds only — so it points at Coder's marketplace (Open VSX under the hood) instead. Most extensions are there. Occasionally one isn't, and you get to feel the exact shape of Microsoft's licensing terms with your bare hands.
The Setup: Compose File and Volume Mounts
This is where the whole thing comes together. The compose file wires up the container, the Docker socket, and — the real hero of this post — the volume mounts that turn "a browser tab with an editor in it" into "my actual dev environment, everywhere."
services:
vscode:
build:
context: .
dockerfile: Dockerfile
image: local/vscode-web:4.128.0
container_name: vscode-web
restart: unless-stopped
init: true
user: "${PUID:-1000}:${PGID:-1000}"
environment:
PASSWORD: "${VSCODE_PASSWORD:?Set VSCODE_PASSWORD in .env}"
TZ: "${TZ:-America/Los_Angeles}"
GIT_CONFIG_GLOBAL: /home/coder/.config/git/config
ports:
- "${VSCODE_BIND_ADDRESS:-127.0.0.1}:${VSCODE_PORT:-8443}:8080"
volumes:
# Project code — your actual work
- /home/youruser/source/vscode:/home/coder/project
# User data — settings, keybindings, snippets
- ./data/User:/home/coder/.local/share/code-server/User
# Extensions — persist across rebuilds
- ./data/extensions:/home/coder/.local/share/code-server/extensions
# Git config — your identity travels with you
- ./data/git:/home/coder/.config/git
# SSH keys — deploy to remote servers without reconfiguring
- ./data/ssh:/home/coder/.ssh
# Docker socket — host Docker access (DooD)
- /var/run/docker.sock:/var/run/docker.sock
command:
- --bind-addr
- 0.0.0.0:8080
- --auth
- password
- /home/coder/projectTwo small details in there are worth stealing before we even get to the volumes. First, I pin the image to a real version — local/vscode-web:4.128.0, not :latest. :latest is the tag that works flawlessly right up until the morning it silently pulls a new build and your extensions stage a walkout. Pin it, sleep better, upgrade on purpose. Second, that ${VSCODE_PASSWORD:?Set VSCODE_PASSWORD in .env} isn't me showing off — the :? means Compose refuses to start if the variable is missing, with that exact message. It's the difference between "fails loudly at boot with a helpful sentence" and "boots a passwordless IDE onto your network and lets you discover this fact socially." I prefer the loud version.
The volume mounts are the entire trick, so let me walk through each one — because "state that survives a rebuild" is the difference between a toy and a tool:
Project code (/home/youruser/source/vscode → /home/coder/project) — Where your actual work lives. I point it at a real directory on the host — the same source folder I'd use if I were editing locally — so VS Code opens straight into it and the code stays on the host, reachable from a plain terminal too. (A relative ./projects works just as well if you'd rather keep everything under the compose folder; I just like my code living somewhere I'd find it even if this whole setup vanished.) Nothing important is trapped inside a container waiting to evaporate the next time you type --build.
User data (./data/User) — Settings, keybindings, snippets, workspace state. Skip this mount and every rebuild lobotomizes your preferences. Keep it and your environment remembers who you are across updates. Your carefully-tuned settings shouldn't have the memory of a goldfish.
Extensions (./data/extensions) — The single biggest time-saver in the list. Docker extension, language packs, that one theme that makes the semicolons feel right — installed once, persisted forever. No reinstalling the same twelve extensions after every docker compose up --build like it's a fresh install every morning.
Git config (./data/git) — Name, email, signing config. Git does not care whether it's inside a container; it still wants to know who you are before it'll let you commit. Mount this and your commits look normal everywhere, instead of authored by coder@a1b2c3d4e5f6, a prolific contributor nobody has ever met.
SSH keys (./data/ssh) — Deploy to remote servers, clone private repos, talk to GitHub. Your keys live here and are ready inside the container. No copying keys around, no re-running ssh-add every single time you open a terminal like it's a superstition.
Docker socket (/var/run/docker.sock) — The DooD magic itself. The container gets direct access to the host's Docker daemon. From inside a browser tab, you can manage containers, build images, run entire compose stacks — everything the host can do. Yes, it's as powerful as it sounds. Yes, we're getting to the part where I admit that.
The One Number That Makes the Socket Work: PGID
Here's the problem, and it's the part every "just mount the socket" tutorial waves past. On the host, /var/run/docker.sock is owned by root:docker with mode 660 — read/write for root, read/write for the docker group, and nothing for everyone else. Our container, though, runs as an unprivileged user (user: "${PUID:-1000}:${PGID:-1000}", so uid 1000). Uid 1000 is not root and, as far as the container knows, is not in any docker group. So it strolls up to the socket, gets a firm "permission denied," and every docker command inside the IDE dies on the spot.
You'll see two bad instincts for fixing this. One is chmod 666 on the socket — flinging the door open to everyone — which also can't be done from inside this container anyway, because uid 1000 isn't allowed to chmod a root-owned file. It fails at the exact thing it was hired to do. The other is group_add: [docker], which we'll get to in a second (spoiler: it's inviting a guest who doesn't exist).
The actual fix is almost embarrassingly small. That socket is readable by the docker group, and a group is just a number. Find that number on the host:
getent group docker
# docker:x:984:youruserThen hand that GID to the container as PGID in your .env, so the process runs in that group:
# .env
PUID=1000
PGID=984 # <-- the host 'docker' group GID from the command above
VSCODE_PASSWORD=change-me-obviouslyNow the container runs as 1000:984. The socket is root:docker(984), mode 660, and our process is a card-carrying member of group 984 — so it can read and write the socket by the rules that were already there. No chmod, no entrypoint script, no flinging doors open, no second daemon. We didn't weaken a single permission; we just showed up wearing the right badge.
One honest caveat: 984 is my host's docker GID, not a universal constant. On Debian it's often 984, on Ubuntu frequently 999, and on your box it's whatever getent group docker says it is. Copy that number, not mine — using the wrong GID is how you end up right back at "permission denied," now with extra confidence.
The Gotchas (Because It's Never That Simple)
Every one of these cost me time. Consider this the map of the landmines, drawn by the guy who found them the traditional way.
The group_add: [docker] Trap
You'll find tutorials suggesting group_add: [docker] in your compose file. It looks right. It feels right. It does nothing useful — because you handed it a name, and the container has never heard that name. Compose tries to resolve "docker" against the container image's /etc/group, finds no such group in there, and you're left inviting a guest who doesn't exist. The kernel, meanwhile, only ever cared about the number.
Fix: Use the number, not the name. Setting PGID=984 (from getent group docker) makes 984 your primary group — that's the approach above. If you'd rather keep the group as a supplementary one, group_add: ["984"] with the numeric GID also works, because a bare number needs no lookup. Either way: feed the socket a GID, never a word.
Permission Denied on the Docker Socket
Mount the socket, run docker ps inside the container, receive "permission denied." Completely expected — the socket's root:docker group membership means nothing until your container process is actually in that group.
Fix: Set PGID to the host's docker group GID (getent group docker). No chmod, no entrypoint — the process joins the group that could already open the socket, and the "denied" turns into a container list.
apt-get update Fails (and Almost Whispers About It)
The base image is missing /var/lib/apt/lists/partial. Without it, apt-get update fails with a permission error that's very easy to scroll right past in a wall of build logs, so instead of an obvious failure you get a mysterious one three steps later.
Fix: mkdir -p /var/lib/apt/lists/partial before apt-get update, as USER root.
Docker CLI vs Docker Daemon
Debian's docker.io installs the daemon, not the CLI. If you install it and then can't find the docker command, welcome — the meeting is already in progress and we've saved you a chair.
Fix: Install docker-cli. Different package. The right one.
Using It: The Docker Extension and Daily Workflow
Container's up. Open a browser at http://<host>:8443, log in with the password you set, and there's your IDE, waiting like it never left.
Install the Docker extension by Microsoft from the marketplace. Note: the plain Docker extension, not "Docker DX" — that's a different, chunkier product, and picking the wrong one is the "grabbed the wrong identical-looking bottle" of dev tooling.
The extension auto-detects the mounted socket. Within a couple of seconds, every container running on the host materializes in the Docker sidebar — and there's a genuinely uncanny moment the first time you watch containers you started from a real terminal show up inside a browser tab. From there you can:
- Start, stop, restart containers
- Tail logs in real time
- Build and push images
- Manage volumes and networks
- Drive entire Docker Compose stacks from the sidebar
The integrated terminal has the same full Docker access. docker ps, docker compose up, docker build — all of it behaves exactly as it would on the host, because it is the host's Docker.
For port exposure, declare ports in your application's own docker-compose.yml:
services:
myapp:
ports:
- "8080:3000" # host_port:container_portReach it at http://<host>:8080. For anything resembling production, put a reverse proxy in front. I run nginx-proxy on this host, but Caddy and Traefik will do the job just as happily — pick the one whose config file annoys you least.
Local AI: Running Qwen3.6 Alongside
Here's where the setup stops being convenient and starts being genuinely fun. Because the VS Code container has full Docker access, it can reach an AI model running right beside it on the same host.
I'm running Qwen3.6 locally — no API keys, no rate limits, no code quietly booking a flight to someone else's data center. The model runs as its own Docker container on the same box, and the VS Code instance talks to it over a local endpoint.
If you want the full saga of getting Qwen3.6 to actually behave — the weeks of compiling llama.cpp, the memory brinkmanship, the time the model answered a Docker Compose request with a philosophy essay — that's a whole story of its own, and I already told it in My DGX Spark Said "Out of Memory." I Took That Personally. (which itself picks up from How I Turned My HomeLab into JARVIS). This post is about reaching that brain from a browser tab, not the ordeal of installing it.
Why local? Three reasons, and none of them are "for the aesthetic":
Privacy. Your code never leaves the machine. No telemetry, no logging, no "anonymized" snippets being sent somewhere to help improve a product you never agreed to improve.
No rate limits. Cloud models throttle you exactly when you're in the zone. A local model does not care that it's 2am and you're on your fortieth request. It has nowhere else to be.
Cost. Once the hardware is paid for, inference is free. No per-token meter running, no month-end bill that reads like you accidentally left the AI on.
The integration is refreshingly boring. The Qwen3.6 endpoint speaks the OpenAI-compatible API, so VS Code's Copilot Chat (or any compatible extension) connects to it as though it were OpenAI — same API shape, entirely different backend, none the wiser. Same plug, different power station.
And this is the part I genuinely love: because everything speaks the same OpenAI-shaped dialect, VS Code does not care where the brain actually lives. The endpoint is just a URL, and a URL can point almost anywhere:
- A model in Docker on the same box —
localhost, the setup we just built. No network hop, no trust boundary, no keys leaving the machine. The brain is in the next room. - A model on another machine on your network — the beefy GPU rig sulking in the closet does the actual thinking while your featherweight laptop takes all the credit. Delegation: it's not just for management.
- A hosted gateway like OpenRouter — one API key, a whole buffet of models, and the freedom to switch whenever today's pick starts hallucinating with confidence.
- Copilot, Claude, or any other cloud provider — for when you want a frontier-grade model and you've made peace with your code taking a brief, supervised field trip off-premises.
Same editor, same chat box, same keybindings — you just swap the endpoint like flipping a channel. And nothing stops you mixing them: local Qwen3.6 for the private, high-volume grunt work, and a cloud heavyweight for the occasional "please explain, gently, why this regex despises me" moment. The IDE is fiercely monogamous about its UI and shamelessly polyamorous about its backends. Pick the brain that fits the task; the browser tab won't judge you either way.
And here's how little it takes to introduce the two. This is the actual model definition I hand VS Code — a plain OpenAI-compatible endpoint, living on another box on my LAN, described in a few honest lines:
"models": [
{
"id": "qwen3.6-mtp",
"name": "Qwen 3.6 MTP",
"url": "http://dgx.local:8001/v1",
"toolCalling": true,
"vision": false,
"maxInputTokens": 128000,
"maxOutputTokens": 16000
}
]That url is the whole trick — a plain hostname, dgx.local, because I'd rather not memorize a 192.168.x.x address that changes the day my router has an opinion. It resolves to the "beefy rig in the closet" from the list above — the DGX Spark itself — doing the thinking one network hop away while my potato takes the credit. The rest is just the model being upfront about itself: toolCalling: true because I want it driving tools, not merely chatting; vision: false because it reads code, not vibes; and the token limits so nobody's surprised when a 200K-token context politely bounces. No API key field, because there's no bill and no gatekeeper — the only thing standing between you and the model is a LAN and your own firewall, which is exactly why "local AI" still deserves a firewall.
The payoff is AI-assisted coding — completion, chat, refactoring — without surrendering your privacy or paying by the request. It won't beat a top-tier cloud model running on a rack that costs more than my car. But for local development, it's not just adequate; it's the model that's actually there when the internet isn't.
Security Considerations
Time for the honest part. Mounting the Docker socket hands the container full root-equivalent control over the host's Docker daemon. Anything that can talk to that socket can spin up a container that mounts the host filesystem and — congratulations — effectively owns the box. That's a lot of authority for something that lives in a browser tab next to your email.
For local development on a personal machine, I'm at peace with it. I trust the code I run, I control the environment, the machine is mine, and the blast radius ends at my own desk. The risk is real but it's contained (pun acknowledged, not apologized for).
But the moment you share this setup, or expose it to a network you don't fully trust, that calculus flips hard. If you're heading that direction, reach for a grown-up option instead:
- Rootless Docker — runs the daemon as a non-root user, shrinking the blast radius from "the whole host" to "considerably less than the whole host."
- Podman with rootless socket sharing — a mostly drop-in Docker replacement that doesn't demand root to begin with.
- Kaniko — builds container images inside Kubernetes with no Docker daemon in sight, which neatly sidesteps the entire "who can touch the socket" question.
And to be clear about the one thing people get wrong here: the PGID/group approach we used is the correct, permission-respecting way to reach the socket — no chmod 666, no door flung open — but it is emphatically not a security control. Group membership decides whether you can open the socket; it does absolutely nothing to limit what you can do once you're through. Anyone who can talk to that socket, by any means, has root-equivalent power over the host. Doing socket access cleanly and doing it safely are different achievements, and only one of them is on this page.
For my setup, that's a deliberate, eyes-open tradeoff: clean group-based access, a machine that's mine, and a blast radius that ends at my own desk. The day this needs to face a network I don't trust, I migrate to rootless or Podman — because no amount of tidy GID wrangling changes the fact that the socket is a skeleton key. Knowing exactly which corner you're cutting, and why, is the difference between a pragmatic shortcut and an incident report with your name on it.
Exposing It Securely
Right now the VS Code instance is bound to a local port. Perfect for machines on the same network, useless the moment you're in a coffee shop watching your latte go cold, wishing you could reach your dev box.
The answer is Cloudflare Tunnel. It opens a secure, encrypted connection from your machine out to Cloudflare's edge, so you can reach your environment from anywhere — no port forwarding, no poking holes in your router and hoping the internet is polite about it.
The full walkthrough — tunnel config, DNS, certificates, the works — is its own post, and it's coming next. This one is already long enough, and I have a suspicion you're reading it in a browser tab that's doing more work than it lets on.
Summary
The whole thing, condensed:
- VS Code in a browser via code-server gives you one consistent dev environment on any machine with a browser and a pulse.
- DooD (Docker-outside-of-Docker) mounts the host socket into the container for full Docker access — no second daemon, no duplicated images, complete visibility.
- Volume mounts are the real trick — project code, user data, extensions, git config, and SSH keys all survive rebuilds, which is what separates a tool from a toy.
- The gotchas are almost all permissions and package names —
docker-clinotdocker.io, the rightPGID(your host'sdockergroup GID) instead ofchmod 666,mkdir -psoapt-getstops sulking. - Local AI (Qwen3.6) runs right alongside as a Docker container — AI-assisted coding with no API keys, no rate limits, and no per-token guilt.
- Security is a real tradeoff, not an afterthought — socket access is root-equivalent power, clean group access is not a safety control, and knowing which corner you cut is the whole game.
The result: a development environment that lives in Docker, runs in a browser, and follows you everywhere you go. No more three-day setup ritual on a new machine.
I still haven't found the fonts. But everything else? It's already waiting.
Happy Coding!!!