Skip to content

Volume ITechnology

14.2.6 — Docker on Mac & Windows ​

Three developers on one team run the same command, against the same image, on the same project:

bash
docker compose run --rm api npm install
  • On the Linux workstation it finishes in 40 seconds.
  • On the MacBook it takes 3 minutes.
  • On the Windows laptop, with the code in C:\Users\priya\projects\shop, it takes 6 minutes. Priya moves the project folder into her WSL 2 Ubuntu home directory, runs it again, and it takes 35 seconds.

The exact numbers vary from machine to machine, but the pattern is real and every team meets it. One fact explains all of it, and every other surprise on this page too: a Linux container needs a Linux kernel (Chapter 14.1.3), and neither macOS nor Windows has one. So on both, "Docker" quietly means "a Linux virtual machine with Docker inside it". The slowness, the networking oddities, the CPU-architecture errors and the memory limits all come from the boundary between your own operating system and that hidden VM. Once you can see the boundary, you can predict which side of it a problem lives on.

1. The picture: your laptop, a VM, and three crossings ​

Here is what is actually running when you open Docker Desktop on a Mac. Windows is the same shape with a different VM underneath, and section 6 draws it.

macOS (your laptop)docker CLI · your terminal~/projects/shop (APFS)browser → localhost:8080No namespaces, no cgroups hereLinux VM (hidden)dockerd · containerd · runccontainer sees /appcontainer listens on :80Real Linux kernel, real containers① commands (socket)② files (VirtioFS)③ ports (forwarded)
Figure 1 — Docker Desktop on a Mac. Everything that makes a container lives on the right. Your tools and files live on the left. Each of the three crossings is a separate piece of machinery, and each has its own costs.

Walk the three crossings, because the rest of the page is about them.

① Commands. The docker program on your Mac is only a client. As Chapter 14.2.1 showed, it sends requests over a socket to the daemon, dockerd. Here the daemon is inside the VM, and Docker Desktop connects a socket file on your Mac to the daemon's socket inside the VM, so the client does not know the difference. You can see which daemon your client is talking to with docker context ls. This crossing is cheap: a few small requests per command.

② Files. When you bind-mount ~/projects/shop into a container, the files physically live on your Mac's disk, and the container is inside a VM with its own disk. Something has to pass every file operation across the boundary. This crossing is the expensive one, and section 3 explains why.

③ Ports. The container listens on port 80 inside the VM. Your browser is on the Mac. Docker Desktop listens on localhost:8080 on the Mac and forwards each connection into the VM. This crossing works well, but it changes what "localhost" and "host" mean, and section 4 covers that.

2. How the Mac side got here ​

The Mac story is worth five minutes, because each step was an attempt to make one of those crossings cheaper, and you will still meet traces of every step in old blog posts and old scripts.

2013 to 2016: a full VirtualBox VM. The first way to run Docker on a Mac was boot2docker, a tiny Linux distribution running in VirtualBox, later managed by a tool called docker-machine. You ran eval $(docker-machine env) to point your client at the VM, and your containers were reachable not at localhost but at the VM's own address, usually 192.168.99.100. If you see that address in a README, the README is from this era.

2016: Docker for Mac. Docker shipped a native app built on HyperKit, a small hypervisor based on xhyve, which itself was a port of FreeBSD's bhyve. HyperKit used Apple's built-in Hypervisor.framework, so there was no VirtualBox to install, and the VM was hidden. Ports were forwarded to localhost, which removed the 192.168.99.100 confusion. Files crossed through a custom system called osxfs, which was notoriously slow.

2020 onward: Apple Silicon and Apple's own VM layer. When Apple moved Macs to its own ARM processors, Docker moved to Apple's higher-level Virtualization.framework, which handles the VM with Apple's own code. Docker has since added its own VM manager, Docker VMM, as an option tuned for container workloads, still marked beta in Docker's documentation as of October 2026. Whichever you choose, the shape in Figure 1 does not change. Only the code doing the crossings gets faster.

3. Files: why a bind mount is slow, and what to do about it ​

On Linux, a bind mount is free. The container and the host share one kernel, so the container's /app is literally the same directory, and reading a file inside the container is exactly as fast as reading it outside.

On a Mac, the container's kernel and the file's disk are on opposite sides of a VM boundary. So every operation on a bind-mounted file becomes a message: "open this path", "read 4 KB from this file", "list this directory", "what are this file's size and modification time?". Each message goes from the VM to a program on the Mac, which does the real work on the Mac's filesystem and sends the answer back.

One message is fast, well under a millisecond. The trouble is how many messages real tools send. npm install creates tens of thousands of small files, and checks the size and time of many more. A build tool scanning a source tree asks about every file in it. Ten thousand files times several operations each times even a tenth of a millisecond is seconds of pure crossing overhead, on top of the real work. The cost grows with the number of files, not their size, which is why a 2 GB video file copies through a bind mount fine and a 200 MB node_modules folder takes minutes.

There is a second, subtler cost: caching. Normally the Linux kernel keeps recently read file data in memory, so reading a file twice costs one disk read. But here the real file is on the Mac, and your editor on the Mac can change it at any moment without the VM's kernel knowing. If the VM kept trusting its cached copy, the container would see stale code. So the file-sharing layer has to be careful about how long it trusts its cache, and every "is this still up to date?" check is another crossing.

The mechanisms, in order:

  • osxfs (2016) was a custom design, and it was slow.
  • gRPC FUSE (2020) caught file operations inside the VM with FUSE, a Linux feature that lets an ordinary program act as a filesystem, and forwarded them to the Mac over gRPC, a fast message format. It was better, but every operation still went through a user program on both sides.
  • VirtioFS, the default today, is a file-sharing protocol designed specifically for virtual machines. It uses the paravirtual approach from Chapter 14.1.2: shared memory between the guest and the host, and far fewer trips through ordinary programs. It made bind mounts several times faster for typical development work, and it is the reason Mac Docker is now reasonable for most projects.

What you do about the remaining cost. The rule is simple: keep the directories with many small files that your Mac never needs to see inside the VM. The usual one is node_modules, and the same applies to Python virtual environments, Go and Rust build caches, and .git objects for very large repositories.

yaml
services:
  api:
    build: .
    volumes:
      - ./:/app                         # (1)
      - node_modules:/app/node_modules  # (2)
    command: npm run dev

volumes:
  node_modules: {}                      # (3)

(1) Your source code, bind-mounted. You edit on the Mac, the container sees the change. This crossing is worth paying for, because source files are few and you need both sides to see them.

(2) A named volume mounted over node_modules. A volume lives on the VM's own disk, inside Linux, so npm install writes there at native speed and never crosses the boundary. Mounting it over the bind-mounted path also stops the Mac's copy of node_modules, if there is one, from hiding the container's. Chapter 14.2.5 used an anonymous volume for the same trick; a named one survives docker compose down, so you do not reinstall every time.

(3) Declares the volume. Docker creates it the first time.

The price is that the Mac no longer sees those files, so your editor's autocomplete, which reads node_modules on the Mac, needs its own copy. Most teams run npm install once on the Mac for the editor and let the container keep its own. Docker also sells a feature called synchronized file shares for paid plans, which keeps a fast copy of a whole folder inside the VM and syncs changes both ways in the background. It is worth knowing it exists if a large project still feels slow.

File watching. Development servers restart when you save a file, and they learn about saves through a Linux kernel feature called inotify, which notifies a program when a file changes. A save on the Mac happens outside the VM's kernel, so the file-sharing layer has to generate the matching notification inside. VirtioFS on current Docker Desktop does this, but on older setups and some tools, hot reload silently stops working. The fallback is to tell the tool to poll: check every file's modification time every second or so. Many tools have a setting for this, such as CHOKIDAR_USEPOLLING=true for Node's most common file watcher.

4. Networking: what "localhost" and "host" mean now ​

On Linux, the host can reach a container's own address directly. In Chapter 14.1.4 you ran curl http://10.200.0.2/ from the host, because the host is plugged into the same bridge as the container. On a Mac or Windows that bridge is inside the VM, so the 172.17.x.x addresses Docker shows you are not reachable from your laptop. Only published ports are.

Publishing works the way you expect from outside. -p 8080:80 makes Docker Desktop listen on your laptop's localhost:8080 and forward connections into the VM, where Docker's normal port rule takes them to the container.

Going the other way, a container calling something on your laptop, needs a special name. Inside the container, localhost means the container itself, as always. To reach a database or a mock server running on your Mac, use host.docker.internal, a name Docker Desktop's DNS answers with an address that leads back to the laptop. On plain Linux the name does not exist by default, and you add it with --add-host=host.docker.internal:host-gateway.

--network host does not mean what it means on Linux. On Linux it puts the container directly on the host's network, with no namespace of its own. On Docker Desktop the "host" is the VM, so by default the container is on the VM's network, not your laptop's. Recent Docker Desktop versions include an opt-in setting that brings it closer to the Linux behaviour; check your version's documentation before relying on it.

Outbound traffic goes through your laptop. Docker Desktop routes the VM's outgoing connections through a program on the host side, historically called vpnkit, so that from the network's point of view they come from your laptop. That is why containers on a Mac work through your company VPN and proxy settings without extra configuration, something that took real effort in the VirtualBox days.

5. Apple Silicon: when the CPU is different too ​

Macs since 2020 have ARM processors, which Docker calls arm64. Most servers, and most cloud Kubernetes nodes, are x86, which Docker calls amd64. A program compiled for one cannot run on the other. This adds a second boundary on Apple Silicon Macs, separate from the operating-system one.

Pulling is handled for you. A multi-platform image (Chapter 14.2.2) lists one variant per CPU type, and Docker picks arm64 on your Mac. Most popular images publish both.

Running an amd64-only image on ARM is possible but slow. The Linux kernel has a feature called binfmt_misc, which lets you say "when someone tries to run a program in this foreign format, run this other program and pass it the file". Docker Desktop registers an emulator there, either QEMU or Apple's Rosetta translator when you enable it in the settings. So an amd64 program starts, the kernel recognises its format, and hands it to the emulator, which translates its instructions to ARM as it goes. It works for many programs, at a real speed cost, and some programs that rely on unusual processor features crash. You ask for it explicitly with --platform linux/amd64.

The failure everyone meets once is in the other direction: you build an image on your Mac, push it, and deploy it to x86 servers.

bash
docker build -t registry.example.com/shop/api:1.4 .                         # (1)
docker image inspect --format '{{.Os}}/{{.Architecture}}' registry.example.com/shop/api:1.4
docker push registry.example.com/shop/api:1.4
linux/arm64

(1) A plain build produces an image for the machine you are on, here arm64. It runs perfectly on your Mac and on every other Apple Silicon Mac on the team. Deployed to an amd64 Kubernetes node, the container starts and immediately dies with exec format error, which Chapter 14.2.1 lists in its failure table, and the pod lands in CrashLoopBackOff (Chapter 14.3.9).

Fix it at the source: build for the platforms you deploy to. docker buildx build --platform linux/amd64,linux/arm64 -t … --push . builds both variants and pushes them as one multi-platform image (Chapter 14.2.2 shows the format). Better still, build release images in CI on the same kind of machine you deploy to, so a laptop never produces what production runs. Laptops build for laptops.

6. Windows: WSL 2, and what Docker does with it ​

Windows took a different road to the same place. The figure below shows the shape today; compare it with Figure 1.

Windows (root partition)C:\Users\priya\projectsVS Code · browser9P file serverWSL 2 utility VM — one Linux kernelUbuntu distro~/projects (ext4)docker CLI/mnt/c → C:docker-desktopdockerdcontainerdcontainersBoth distros share the VM's kernel,so ~/projects → container is fastHyper-V hypervisor — on the hardware, below bothslow crossing
Figure 2 — Docker Desktop on Windows with WSL 2. Windows and the Linux VM are both guests of Hyper-V (Chapter 14.1.2). Inside the VM, your Ubuntu and Docker's own distribution share one kernel. The red crossing, from C: into Linux, is the one that made Priya's install take six minutes.

WSL 1 and WSL 2 are completely different things ​

WSL stands for Windows Subsystem for Linux, and it has had two designs that share a name and almost nothing else.

WSL 1 (2016) translated. It had no Linux kernel at all. When a Linux program made a system call, such as "open this file", WSL 1 translated it into the matching Windows call and ran that. It was clever and fast to start, but it could never run Docker, because Docker needs namespaces and cgroups, and those are not things you can translate into Windows calls. Windows has no equivalent to translate them to.

WSL 2 (2019) runs a real Linux kernel in a VM. As Chapter 14.1.2 explained, it is a small, special-purpose VM on Hyper-V, booting a Linux kernel that Microsoft builds and updates through Windows Update. It starts in about a second, gives memory back to Windows when it is not using it, and has no window of its own. Because it is a real kernel, everything from Chapter 14.1.3 works inside it, and that is what made Docker on Windows good.

The three generations of Docker on Windows ​

  • Docker Toolbox, to around 2017, was the same VirtualBox approach as boot2docker on the Mac.
  • Docker for Windows (2016) ran a Linux VM called MobyLinuxVM in Hyper-V, and shared your C: drive into it over the Windows file-sharing protocol, SMB. It was slow for bind mounts and needed Windows Pro.
  • The WSL 2 backend (2020) is what Docker recommends today. Docker Desktop also still offers a plain Hyper-V backend, and Docker's own settings page describes WSL 2 as the faster of the two.

What Docker Desktop actually puts inside WSL 2 ​

Open PowerShell and list the WSL distributions:

powershell
wsl -l -v
  NAME              STATE           VERSION
* Ubuntu            Running         2
  docker-desktop    Running         2

Two Linux "distributions", one kernel. In WSL 2, a distribution is closer to a container than a VM: every distribution runs inside the same single utility VM, sharing its kernel, each with its own filesystem. Ubuntu is yours. docker-desktop is Docker's: it runs dockerd, containerd and your containers. Docker Desktop adds the docker command to your Ubuntu too, connected to the daemon in docker-desktop. Older installs also show a third distribution, docker-desktop-data, which held images and volumes. Fresh installs since Docker Desktop 4.30 keep that data in a separate disk file instead and no longer create it, while upgraded machines keep theirs.

Because both distributions share one kernel, a bind mount from your Ubuntu home directory into a container is a same-kernel mount, close to Linux speed. That is the 35-second result from the top of the page.

7. Files on Windows: keep the code where the tools run ​

Inside WSL 2 there are two kinds of storage, and the whole Windows performance story is which one your files are on.

  • The Linux filesystem, your Ubuntu's / and ~, is an ext4 filesystem stored in a virtual disk file on your Windows drive. Inside Linux it is native and fast.
  • Your Windows drives appear inside Linux as /mnt/c, /mnt/d and so on. Those files really live on Windows' own filesystem, NTFS, and every operation on them crosses from the VM to Windows using a file-sharing protocol called 9P. 9P comes from Plan 9, a research operating system from Bell Labs, and it was chosen because it is simple. Like the Mac's crossing, it costs a round trip per operation.

The crossing also works in the other direction. Windows programs reach your Linux files through the path \\wsl.localhost\Ubuntu\home\priya, and that path goes over 9P too.

So the rule is about where the heavy tool runs. Docker, npm, git and your build all run in Linux, so keep the project in Linux:

bash
# inside Ubuntu (WSL 2)
mkdir -p ~/projects && cd ~/projects
git clone https://github.com/example/shop.git      # (1)
cd shop && code .                                    # (2)
docker compose up                                    # (3)

(1) Clone into the Linux filesystem, not into /mnt/c/.... This one decision is the six-minutes-to-35-seconds difference.

(2) code . opens VS Code on Windows, connected into WSL. The editor window is a Windows program, but its file access, terminal, extensions and language tools run inside Linux through VS Code's WSL extension. So the heavy file work happens on the fast side, and only the screen crosses.

(3) The bind mount ./:/app is now Linux to Linux.

Two Windows-only traps that look like Docker bugs:

Line endings. Windows tools traditionally end lines with two characters, carriage return and line feed, written CRLF. Linux uses only the line feed, LF. Git on Windows can convert files to CRLF when you check them out. A shell script with CRLF endings, copied into a Linux image, fails in confusing ways: exec /docker-entrypoint.sh: no such file or directory, or /bin/sh^M: bad interpreter. The ^M is the invisible carriage return, and "no such file" means Linux looked for an interpreter literally named sh followed by a carriage return. Fix it once for the whole repository with a .gitattributes file containing *.sh text eol=lf, which tells Git to always keep LF in shell scripts.

Permissions. Files under /mnt/c do not carry Linux permission bits by default, so they all appear as fully writable and executable, and chmod +x appears to do nothing. Scripts kept in the Linux filesystem behave normally, which is one more reason to keep them there.

8. Memory, processors and disk: the VM's size limits ​

The VM has a size, and your containers live inside it. If Docker Desktop's VM has 8 GB, then a container with no memory limit, alone on that VM, still dies at about 8 GB, and you will see OOMKilled (Chapter 14.2.4) even though your laptop has 32 GB.

On a Mac, set the VM's memory, processors and disk size in Docker Desktop's settings under Resources.

On Windows with WSL 2, the VM is WSL's, and its limits come from a file in your Windows home folder, %UserProfile%\.wslconfig. By default WSL 2 may use up to half of the computer's memory and all of its processors.

ini
[wsl2]
memory=8GB        # (1)
processors=4      # (2)

(1) A ceiling on the WSL 2 VM's memory. Set it when Docker builds make the rest of Windows slow.

(2) How many processors the VM may use.

Run wsl --shutdown in PowerShell for the change to take effect, since the file is only read when the VM starts.

Disk. Images, build cache and volumes all live on the VM's virtual disk, and that disk file grows as you use it but does not always shrink on its own after you delete things. docker system df shows what is using the space and docker system prune frees it inside the VM (Chapter 14.2.2). Reclaiming the space on the host is a separate step that differs between versions, so check Docker Desktop's Troubleshoot page or WSL's documentation when the file itself has grown too big.

9. Windows containers are a different thing entirely ​

Everything above is about running Linux containers on Windows. Windows also has its own containers, which share the Windows kernel and run Windows programs. They exist mostly for older .NET Framework applications that cannot move to Linux.

  • A Windows container needs a Windows host, just as a Linux container needs a Linux kernel. Docker Desktop has a menu item, Switch to Windows containers, that points your client at a separate Windows engine. One engine cannot run both kinds at the same time.
  • Windows containers come in two isolation modes. Process isolation shares the host's kernel, like a Linux container, and requires the image to be built for a matching Windows version. Hyper-V isolation gives each container its own small VM, which relaxes the version matching and adds a VM boundary.
  • Kubernetes can run them on Windows worker nodes, and AKS supports Windows node pools (Chapter 14.4.1), but the control plane is always Linux.

If you are not maintaining Windows-only software, you will not need them.

10. Licence, and the alternatives that do the same thing ​

Docker Desktop is free for personal use, education, open-source projects, and businesses with fewer than 250 employees and less than $10 million a year in revenue. Above either line, commercial use needs a paid subscription. These are the terms Docker introduced in 2021; check Docker's pricing page for the current details. Docker Engine itself, the daemon on a Linux machine, is open source and free for everyone, so Linux servers are not affected.

Several tools build the same hidden-VM arrangement, and knowing they exist matters when a company decides not to pay:

  • Podman Desktop and Rancher Desktop work on both Mac and Windows. Podman is a different container engine with a Docker-compatible command line; Rancher Desktop can run either containerd or Docker's engine, plus a small Kubernetes.
  • Colima is a free command-line tool for the Mac that starts a Linux VM with a container engine inside.
  • OrbStack is a commercial Mac app focused on speed and low memory use.
  • On Windows, you can skip Docker Desktop entirely and install Docker Engine directly inside your WSL 2 Ubuntu with the normal Linux instructions. You lose the graphical app and the automatic port forwarding to Windows, but WSL 2 forwards localhost ports to Windows by itself in most setups.

All of them are Figure 1 or Figure 2 with different software on the crossings. Everything this page taught applies to each.

11. When something is wrong, which side is it on? ​

SymptomSideFirst check
Cannot connect to the Docker daemonCrossing ①Is Docker Desktop running? docker context ls
Bind-mounted install is slowCrossing ②Move dependencies into a volume
Windows build slowCrossing ②Is the code under /mnt/c?
Hot reload never firesCrossing ②Enable polling in the watcher
Can't reach 172.17.x.xCrossing ③Publish a port instead
Container can't reach laptop serviceCrossing ③Use host.docker.internal
exec format error on serverCPUdocker image inspect the architecture
OOMKilled with RAM freeVM sizeRaise VM memory or .wslconfig
bad interpreter / ^MWindows files.gitattributes eol=lf
Disk fullVM diskdocker system df, then prune

The habit to build is to ask "which side?" before "what's wrong?". A slow container on a Mac is almost never the container. It is usually crossing ②, and the fix is moving files, not tuning the application.

What the interviewer will push on ​

"Why does Docker run slower on a Mac than on Linux?" Because Linux containers need a Linux kernel, so Docker Desktop runs a Linux VM, and bind-mounted files have to cross the VM boundary on every operation. The tell of a real answer is that the cost scales with the number of file operations, not the size of the data, which is why node_modules is the classic victim. Name VirtioFS as the current mechanism and a volume over the dependency folder as the fix. The weak answer is "Docker is just slow on Mac".

"What is the difference between WSL 1 and WSL 2, and why does it matter for Docker?" WSL 1 translated Linux system calls into Windows ones and had no Linux kernel, so it could not provide namespaces or cgroups and could not run Docker. WSL 2 runs a real Linux kernel in a lightweight Hyper-V VM. Add the practical consequence: keep code in the Linux filesystem, because /mnt/c crosses over 9P.

"An image works on your laptop and crashes on the cluster with exec format error. What happened?" It was built on an ARM laptop and the nodes are x86. Fix with a multi-platform buildx build, and better, build release images in CI on the deploy architecture. The interviewer is checking whether you know an image is tied to a CPU architecture, which surprises many people who think of containers as fully portable.

"Why can't a container on Docker Desktop use localhost to reach a service on your laptop?" Because localhost inside a container is the container's own network namespace, and the laptop is outside the VM besides. Use host.docker.internal.

One thing to volunteer: say that every Docker Desktop problem lives on one of three crossings, commands, files or ports, plus the CPU architecture on Apple Silicon. That one sentence turns a list of symptoms into a way of diagnosing, and it shows you understand the mechanism rather than having memorised fixes.

Recall

  • The one fact: Linux containers need a Linux kernel, so Docker Desktop is a hidden Linux VM. Three crossings: ① commands over a forwarded socket (cheap) · ② files (expensive) · ③ ports forwarded to localhost.
  • Mac history: boot2docker + VirtualBox (192.168.99.100) → 2016 HyperKit on Hypervisor.framework (osxfs) → Apple Virtualization.framework, plus Docker VMM (beta).
  • Files: each operation is a round trip, so cost scales with file count. osxfs → gRPC FUSE → VirtioFS (default). Fix: named volume over node_modules and other dependency folders. Hot reload may need polling.
  • Network: container IPs are not reachable from the laptop; publish ports. Laptop from container = host.docker.internal. --network host means the VM's network.
  • Apple Silicon is arm64; most servers are amd64. Emulation via binfmt_misc + QEMU or Rosetta. Plain builds give arm64 → exec format error on x86 nodes; use buildx --platform or build in CI.
  • WSL 1 translated system calls (no kernel, no Docker). WSL 2 is a real kernel in a Hyper-V utility VM. Distros share one kernel; docker-desktop runs the engine; docker-desktop-data only on pre-4.30 installs.
  • Windows files: ext4 inside Linux is fast; /mnt/c over 9P is slow. Clone into ~, open with code .. CRLF breaks scripts (^M, "no such file") — .gitattributes *.sh text eol=lf.
  • VM size caps containers: Mac in Settings → Resources; Windows in .wslconfig (default half of RAM), then wsl --shutdown.
  • Windows containers share the Windows kernel; process vs Hyper-V isolation; separate engine.
  • Licence: Desktop free under 250 employees and under $10M revenue; Engine on Linux always free. Alternatives: Podman Desktop, Rancher Desktop, Colima, OrbStack, or Engine directly in WSL 2.

Self-test: Why does a 2 GB file cross a bind mount faster than node_modules? · What did WSL 1 lack that Docker needs? · Why does an image built on an M-series Mac fail on an AKS node? · What does host.docker.internal solve? · Where do you set the memory of the WSL 2 VM?

Next: 14.3.1 leaves the single machine. Everything so far, on any operating system, is one Docker engine running containers on one computer. What happens when you need fifty computers, and the one running your checkout service dies at three in the morning?