The container isn't slow. The shared folder is.
On Windows there are two routes to having Docker — the Desktop with the WSL2 backend, or the Engine inside the distro itself — and both trip over the same spot: where the files live. I measured both sides, brought up the stack, and ran the agent inside the container.
- docker
- wsl
- linux
- infra
- ai
When a container is slow, the container itself is almost never to blame. The same docker compose up, the same image, the same machine — and times that differ by 60x. The difference wasn't in the CPU or the image: it was in the path where the project tree lives.
This is the text I wish I'd read before losing an afternoon: the two Docker routes on Windows, the thirty-second test that shows which folder is costing you, the short compose that comes up on both sides, and how to run an agent inside the container without turning it into a security hole.
Two routes for the same command
On Windows there are two ways to have docker, and they don't like each other.
Route A — Docker Desktop with the WSL2 backend. An installation on Windows; the engine runs in a lightweight VM with a Linux kernel. Check "Use the WSL 2 based engine" and docker works in PowerShell and, with integration enabled, inside the distro too.
Route B — the Engine inside the distro. Installation from Docker's official repository inside WSL. Before doing it, remove the distro packages (docker.io, docker-compose), which clash with the official ones. And for services to come up with WSL, the distro needs systemd:
# /etc/wsl.conf — depois: wsl --shutdown
[boot]
systemd=true
The price of each one:
| Route A — Desktop, WSL2 backend | Route B — Engine in the distro | |
|---|---|---|
| Installation | one, on Windows | one, in the distro |
docker in PowerShell |
yes | no |
| Who administers the daemon | the Desktop | you |
| Graphical interface | yes | no |
| Background service on Windows | yes, always on | no |
| License | proprietary — read it before commercial use | Apache 2.0 |
Docker's documentation is explicit on one point: don't keep both. An Engine installed inside the distro while the Desktop runs causes socket and context conflicts, and the symptom is the worst possible one — it works sometimes.
What sits between the two is the context. It's what decides which daemon your docker talks to:
docker context ls
# NAME DOCKER ENDPOINT
# default unix:///var/run/docker.sock
# desktop-linux * npipe:////./pipe/dockerDesktopLinuxEngine
docker context use default # engine da distro
docker context use desktop-linux # engine do Desktop
If one day docker ps lists something you didn't start, that's the first place to look.
The shared folder charges a toll
/mnt/c looks like a directory. It isn't. It's a network filesystem disguised as a folder: every directory read and every file open crosses the border between Windows and Linux. Editing text you don't notice. Compiling, installing a dependency, and running a build — that's where the afternoon goes.
The test below is the only one that matters, and it runs in thirty seconds:
# compara a árvore no share do Windows com o mesmo teste no lado Linux
python3 - <<'PY'
import os, time, shutil
def bench(raiz, rotulo):
shutil.rmtree(raiz, ignore_errors=True); os.makedirs(raiz)
t = time.perf_counter()
for i in range(2000):
open(f"{raiz}/f{i}", "w").write("x")
escrever = time.perf_counter() - t
t = time.perf_counter()
for i in range(2000):
open(f"{raiz}/f{i}").read()
ler = time.perf_counter() - t
print(f"{rotulo:8} escrever={escrever:6.2f}s ler={ler:6.2f}s")
shutil.rmtree(raiz, ignore_errors=True)
bench("/mnt/c/tmp/bench", "share") # lado do Windows
bench("/tmp/bench", "nativo") # lado do Linux
PY
Running this on a Windows with WSL2, with the tree in a Windows directory on one side and on the native system on the other:
share escrever= 10.27s ler= 9.17s
nativo escrever= 0.15s ler= 0.06s
Same machine, same two thousand one-byte files: 68x to write, 153x to read. On a large file the difference almost disappears — 64 MB sequential gave 82 MB/s on one side and 667 MB/s on the other, about 8x. In other words: the share is not bad at bandwidth, it's bad at operations. And npm install isn't bandwidth, it's a hundred thousand small operations.
Hence the rule Microsoft itself recommends: what belongs to Linux stays on the Linux side.
| what | where |
|---|---|
node_modules, .venv |
Linux side |
.git |
Linux side |
build cache (.next, target/, dist/) |
Linux side |
| database data | in the Docker volume, not on the share |
| code you edit on Windows | you choose — and you pay the bill |
The stumble is in the last item. With the editor on Windows and the project on the Linux side, the dev server's watcher doesn't always see changes through the normal paths, and you end up turning on polling. The trade is honest: fast build with a lazy watcher, or the other way around. Measure and choose.
And don't trust what's written here: measure. Mount details change from machine to machine. On this very test I expected to see file permissions come back wrong from the share and they came back right — a good reminder that "that's how it is on WSL" is not measurement.
The compose that comes up on both sides
The skeleton below is short on purpose, and every line has a reason:
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/senha_db
volumes:
- dados:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
interval: 5s
timeout: 3s
retries: 12
secrets:
- senha_db
app:
build: .
depends_on:
db:
condition: service_healthy
env_file:
- .env
ports:
- "127.0.0.1:3020:3000"
user: "10001:10001"
tmpfs:
- /tmp
volumes:
dados:
secrets:
senha_db:
file: ./secrets/senha_db.txt
Four details that make a difference:
condition: service_healthy. A depends_on on its own waits for the container to start. Postgres starting is not Postgres accepting connections, and the application that comes up in the middle of that blows up on the first query. The healthcheck is what turns "started" into "works".
127.0.0.1:3020:3000. Without the 127.0.0.1, Docker publishes the port on every interface — including the café's network. Binding to loopback is one word more and one surface less.
env_file instead of environment. A password written inside the compose.yaml goes to Git. The .env stays out and the compose just points to it.
secrets instead of environment. An environment variable shows up in full in docker inspect and in the process's ps. secrets mounts the value into a temporary file inside the container, and the app reads from there — the compose above already does this with POSTGRES_PASSWORD_FILE.
The agent inside the container
Here the question changes: it's not where to run your containers, it's where the agent runs its commands. Anyone who uses a terminal agent knows the problem — it runs commands on your machine, as your user, on your files. Using a container as the terminal backend solves it, and not as a best-practice suggestion: dropped Linux capabilities, blocked privilege escalation, and a process limit.
terminal:
backend: docker
docker_mount_cwd_to_workspace: true
docker_volumes:
- "/home/user/dados:/dados:ro"
docker_forward_env:
- "GITHUB_TOKEN"
container_persistent: false
container_cpu: 2
container_memory: 4096
docker_network: true
Reading from the top: the directory where the agent was opened goes into /workspace; a data directory comes in read-only (:ro); the token comes from the environment, not written into the config file; and the box is per session (container_persistent: false) — new conversation, new box, nothing crosses from one to the other.
Three keys worth knowing exist:
docker_network: falseruns the container with no network at all. For a task that only reads files and computes, there's no reason to have network egress — it's the cheapest security setting there is and almost nobody enables it.docker_run_as_host_user: trueadds--user $(id -u):$(id -g)todocker run. Without it the container runs as root, and the files it creates in the mounted directories end up owned byrooton your host — you find out when you go to edit. The price: the container can no longer install packages or write to root paths. One or the other.docker_forward_envversus a literal. A token that comes from the environment isn't written into the config file; a fixed factory value (aDEBUG=1) can be. Mixing the two is like a password in thecompose.
The mental summary: the container limits the blast radius. It isn't a magic sandbox. A :ro volume, network off, and a credential that doesn't live in the config are worth more than any image hardening.
Choosing the model: start with the cheapest decision
Much of an agent's bill comes from using a large model to decide what a regular expression decides. This site already has a piece on that and the conclusion still holds: the deterministic layer runs before the model call, and only what it can't resolve deserves reasoning.
In practice, three rungs:
| rung | who handles it | example |
|---|---|---|
| deterministic | rule table | "run git status", "bring up the stack" |
| cheap decision | small model, structured output | classify intent, pick a tool |
| reasoning | large model | "why did yesterday's deploy break" |
The common mistake isn't using the large model — it's using it to route. And there's a rung almost everyone forgets: summarizing and compressing context are also model calls. They happen in every long conversation and are the kind of cost that doesn't show up as an answer. Putting them on a smaller auxiliary model is silent savings.
What I don't recommend is choosing by ranking. Build a suite with your real messages, run it on the small version, and see where it fails. The boundary between "the small one can handle it" and "you need the large one" is yours, not somebody else's benchmark.
Plugins, skills, and MCP: who does what
Three names that live together and are different things:
| layer | what it is | when it comes in |
|---|---|---|
| skill | procedural memory: a file that teaches a way of doing something | when the task repeats |
| MCP | tool protocol: an external server that exposes functions | when a tool is missing |
| plugin | code that plugs into the runtime (hooks, commands) | when behavior is missing |
| cron | schedule | when there's a set time |
| delegation | subagent with isolated context | when the work is parallel |
The distinction that changes your day the most is skill versus MCP. Missing a tool? It's MCP. Missing a way? It's a skill. House convention inside an MCP server is wasted work; a skill for accessing an external API, too.
And one caution that applies to all three: every plugin or server loaded is context and surface at the same time. The right question isn't "is there a plugin for this?", it's "isn't this already solved with what I have?" — and the answer is no more often than it seems.
Security in six lines almost nobody writes
- A secret doesn't go into the image.
env_file+secrets+.gitignore. If the value went through the build, it's in the layer history forever. :roon what's read-only. A volume without:rois write permission you never asked for.- Never mount the Docker socket inside a container. Whoever can write to the socket controls the host — they get out of the container with no escalation and no exploit.
- Port on loopback.
127.0.0.1:costs one word. - No network when you don't need it.
--network=none, ordocker_network: false. - What must not leak needs an automatic gate, not memory. This is the one nobody writes and the one that matters most. Internal name, path, host, IP, secret: if the rule is "remember not to write it," it fails in the third week. The list of forbidden terms lives outside the repository — a versioned list publishes exactly what it protects — and a script breaks the build when one of the terms appears:
{
"scripts": {
"check:safety": "node scripts/check-safety.mjs",
"check": "npm run check:content && npm run check:safety && npm run typecheck && npm run lint && npm test"
}
}
A guard that doesn't run in a single command doesn't get run. Put everything into one check and tie the deploy to it.
What I didn't test
- Only one of the two routes was measured on a real machine. The other is described from the official documentation, not from a machine I brought up.
- The filesystem numbers are from one machine and one mount type. The order of magnitude reproduces; the exact number doesn't. The point of the text is the test, not the table.
- I didn't measure the database under concurrency, nor the behavior of a named volume on the share.
- The
composewas validated as a file, not as an application coming up: on the machine where I wrote this there was no Docker daemon available for theup.
Sources
- Docker — WSL 2 backend on Windows
- Docker — install the Engine on Ubuntu
- Docker — Compose reference
- Microsoft — working across filesystems in WSL
- Hermes Agent — documentation, security, and source code (MIT)
The commands in this text are the ones I ran; where I didn't run them, it's said above.