skip to content

day 277 of the operation / 05.10.2026

Proxfrito

← technical

technical04/10/202611 min

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: false runs 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: true adds --user $(id -u):$(id -g) to docker run. Without it the container runs as root, and the files it creates in the mounted directories end up owned by root on 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_env versus a literal. A token that comes from the environment isn't written into the config file; a fixed factory value (a DEBUG=1) can be. Mixing the two is like a password in the compose.

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

  1. 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.
  2. :ro on what's read-only. A volume without :ro is write permission you never asked for.
  3. 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.
  4. Port on loopback. 127.0.0.1: costs one word.
  5. No network when you don't need it. --network=none, or docker_network: false.
  6. 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 compose was validated as a file, not as an application coming up: on the machine where I wrote this there was no Docker daemon available for the up.

Sources


The commands in this text are the ones I ran; where I didn't run them, it's said above.