The short answer to your confusion: you are not being fooled. They really are the same — at the layer you work in. macOS is a certified UNIX. WSL2 is a real Linux kernel. Your shell, your commands, and your file tree are a standardized contract (POSIX) that both sides deliberately implement the same way. The differences don't live in the middle where you type; they live below it (kernel, binary format, filesystem semantics) and above it (package manager, service manager, what you can actually ship). This map shows you exactly where the seams are, so you know when "it works on my Mac" is a promise and when it's a lie.
Every terminal output in this document was captured on a real machine (Apple Silicon, Darwin 25.5.0). Nothing here is from memory.
New to the term "userland"? Read the companion note first: What is userland? — the kernel/userland split is the single idea that makes everything below click.
flowchart TB
U["UNIX (Bell Labs, 1970s)<br/>the original"] --> BSD["BSD lineage<br/>Berkeley"]
U --> SPEC["POSIX / Single UNIX Specification<br/>(the written contract:<br/>shell, utilities, syscalls)"]
BSD --> DAR["Darwin / XNU<br/>= macOS kernel + BSD userland"]
SPEC -.->|"implemented by"| DAR
SPEC -.->|"implemented by"| LIN
GNU["GNU project<br/>'GNU's Not Unix'<br/>re-wrote the userland"] --> LIN["Linux<br/>= Linux kernel + GNU userland"]
DAR --> MAC["macOS Terminal<br/>(certified UNIX)"]
LIN --> VM["Linux VM / WSL2 / server / container"]
Two independent implementations of the same written specification. macOS inherited its userland from BSD UNIX; Linux re-implemented the same interface from scratch (that's literally what GNU means — "GNU's Not Unix"). Like two car manufacturers both honoring the same pedal layout: brake is always on the left, but the engines have nothing in common.
So when WSL felt exactly like your Mac — that instinct was correct. You were using the standardized part.
This is the whole map in one picture. Green = identical, so your muscle memory transfers. Red = different, and this is where every "but it worked on my machine" bug is born.
flowchart TB
subgraph TOP["ABOVE the shell — DIFFERENT ❌"]
direction LR
PKG["package manager<br/>brew vs apt/dnf"]
SVC["service manager<br/>launchd vs systemd"]
GUI["app model<br/>.app bundles vs desktop files"]
end
subgraph MID["THE SHELL — SAME ✅ (this is why you're confused)"]
direction LR
SH["zsh / bash<br/>pipes · redirects · globs"]
NAV["cd · ls · pwd · mkdir · rm"]
TXT["grep · awk · sed · cat"]
DEV["git · python · node · docker CLI"]
FS2["everything is a file<br/>/usr /etc /home · permissions · $PATH"]
end
subgraph BOT["BELOW the shell — DIFFERENT ❌"]
direction LR
K["kernel<br/>XNU/Darwin vs Linux"]
BIN["binary format<br/>Mach-O vs ELF"]
LNK["dynamic linker<br/>dyld vs ld.so"]
FS["filesystem semantics<br/>APFS case-insensitive vs ext4 case-sensitive"]
PROC["/proc, cgroups, namespaces<br/>Linux-only"]
end
TOP --> MID --> BOT
The rule to remember: the shell layer was designed to be portable. Everything it sits on and everything that installs into it was not.
| Layer | macOS Terminal | Linux VM | Same? |
|---|---|---|---|
| Shell language | zsh (default since Catalina) | bash/zsh | ✅ identical |
| Navigation, pipes, redirects, globs | cd, ls, |, >, * |
same | ✅ identical |
| File tree metaphor + permissions | /usr, /etc, rwx, chmod |
same | ✅ identical |
| Dev toolchains | git, python, node, go | same | ✅ identical |
| Utility flags | BSD (sed -i '', date -v+1d) |
GNU (sed -i, date -d) |
❌ subtly different |
| Kernel | XNU (Darwin, hybrid Mach+BSD) | Linux | ❌ different |
| Executable format | Mach-O (.dylib) |
ELF (.so) |
❌ binaries not portable |
| Linker env var | DYLD_LIBRARY_PATH |
LD_LIBRARY_PATH |
❌ different |
| Init / services | launchd (launchctl, plists) |
systemd (systemctl, units) |
❌ different |
| Package manager | Homebrew (/opt/homebrew, user-space) |
apt/dnf (system-wide, OS-integrated) | ❌ different |
| Default filesystem case | insensitive (APFS default) | sensitive (ext4) | ❌ silent bug source |
/proc, cgroups, namespaces |
absent | present | ❌ containers need Linux |
Same command names, same core behavior, different flags — because macOS inherited BSD's implementations and Linux uses GNU's. Proof from a real Mac:
$ sed --version
sed: illegal option -- -
usage: sed script [-Ealnru] [-i extension] [file ...]
$ date -d "1 day ago"
date: illegal option -- d
$ stat -c %s /etc/hosts
stat: illegal option -- c
$ ls /proc
ls: /proc: No such file or directoryEvery one of those works fine on Linux. The translation table for the ones that bite most often:
| Task | Linux (GNU) | macOS (BSD) |
|---|---|---|
| In-place edit | sed -i 's/a/b/' f |
sed -i '' 's/a/b/' f ← the empty string is mandatory |
| Date math | date -d "1 day ago" |
date -v-1d |
| File size | stat -c %s f |
stat -f %z f |
| Resolve symlink | readlink -f p |
greadlink -f p (or realpath) |
| Print custom format in find | find . -printf '%p\n' |
not supported — use -exec |
The sed -i one deserves its own warning. A script written on Linux that runs sed -i 's/x/y/' file on a Mac will consume the next argument as the backup suffix and behave in ways you won't expect. It's the single most common cross-platform shell bug.
The standard fix — install the GNU tools and let them win:
brew install coreutils findutils gnu-sed grep
# then either call them prefixed (gsed, gdate, gstat) …
# … or put the GNU versions first on PATH:
export PATH="$(brew --prefix)/opt/coreutils/libexec/gnubin:$PATH"After that, your Mac terminal behaves like Linux for text processing — which is why experienced developers' Macs feel even more Linux-like. They've deliberately made it so.
flowchart LR
subgraph M["macOS"]
SRC1["source code"] --> C1["clang"] --> MO["Mach-O binary<br/>links .dylib<br/>found by dyld"]
end
subgraph L["Linux"]
SRC2["same source code"] --> C2["gcc/clang"] --> EL["ELF binary<br/>links .so<br/>found by ld.so"]
end
MO -. "copy across ❌<br/>refuses to execute" .-> L
SRC1 -. "portable ✅<br/>recompile" .-> SRC2
Real proof — the format is baked into the file:
$ file $(which git)
/usr/bin/git: Mach-O universal binary with 2 architectures:
[x86_64: Mach-O 64-bit executable] [arm64e: Mach-O 64-bit executable]
$ otool -L $(which git)
/usr/lib/libSystem.B.dylib ← macOS system library (.dylib, not .so)Source code is portable. Compiled artifacts are not. A Linux ELF binary will not run on macOS and vice versa — they're different container formats loaded by different dynamic linkers. This is why npm install recompiles native modules per platform, and why a node_modules folder copied from a Linux server to a Mac (or into a Docker image) breaks in confusing ways.
One extra macOS-only trap: System Integrity Protection strips DYLD_LIBRARY_PATH and DYLD_INSERT_LIBRARIES from protected processes, so library-preload tricks that work on Linux silently do nothing on a Mac.
macOS formats APFS case-insensitive by default. Linux is case-sensitive. Proof:
$ touch /tmp/CaseTest && [ -e /tmp/casetest ] && echo "case-INSENSITIVE"
case-INSENSITIVEflowchart TB
DEV["you write<br/>import './components/Button'<br/>but the file is Button.tsx → button.tsx"]
DEV --> MAC["macOS: ✅ works<br/>(FS ignores case)"]
DEV --> CI["Linux CI/prod: ❌ Module not found<br/>(FS is exact)"]
MAC -.->|"false confidence"| SHIP["you ship it"]
SHIP --> CI
This is why the classic failure is "works on my Mac, breaks in CI" — and it's the most expensive item in this whole map, because the feedback comes late and the error message points at the wrong thing. Git makes it worse: git tracks names case-sensitively, so renaming Foo.js → foo.js may register as no change at all on a Mac (fix: rename via a temp name in two steps, or git mv --force).
Mitigations, in order of effectiveness: let CI be the source of truth (it runs Linux — trust it over your laptop); build/test in Docker locally; or create a case-sensitive APFS volume for code.
| macOS | Linux | |
|---|---|---|
| Start a service at boot | launchctl load ~/Library/LaunchAgents/x.plist |
systemctl enable --now x.service |
| Config format | XML plist | INI-style unit file |
| Logs | log show / Console.app |
journalctl -u x |
| Install a package | brew install x → /opt/homebrew |
apt install x → /usr, /etc |
| Who owns the files | you (user-space prefix, no sudo) | the OS (system-wide, needs sudo) |
Proof of the split on this machine:
$ brew --prefix
/opt/homebrew ← Apple Silicon; Intel Macs use /usr/local
$ ls /sbin/launchd && which systemctl
/sbin/launchd
systemctl not foundThe conceptual difference that matters: Homebrew is a guest in your home directory; apt is the operating system itself. That's why breaking a brew package annoys you, while breaking an apt package can brick a server — and why server-side automation (Ansible, Dockerfiles) is written against apt/dnf, never brew.
This is the part that dissolves most of the confusion, including your WSL experience.
flowchart TB
subgraph W["Windows + WSL2"]
WT["your terminal"] --> WK["a REAL Linux kernel<br/>in a lightweight Hyper-V VM"]
end
subgraph MD["macOS + Docker"]
DT["docker CLI (native macOS binary)"] -->|"talks to"| DVM["a Linux VM<br/>(Apple Virtualization framework)"]
DVM --> CT["your containers<br/>(need Linux namespaces + cgroups)"]
end
subgraph MN["macOS native terminal"]
MT["your terminal"] --> XN["XNU/Darwin<br/>BSD userland — no Linux kernel anywhere"]
end
WSL2 is not an emulator or a translation layer (WSL1 was). It runs a genuine Microsoft-maintained Linux kernel inside an optimized VM with tight host integration. So it felt like your Mac for a sound reason: both are real UNIX-family systems with a native-feeling terminal. Your instinct was reading actual architecture, not surface polish.
Docker on macOS is quietly a Linux VM too. Containers are a Linux kernel feature (namespaces + cgroups) — Darwin has no such thing, which is why /proc doesn't exist on your Mac. Docker Desktop therefore runs a Linux VM and your native docker CLI just talks to it. The evidence is sitting in your own config:
$ docker context ls
NAME DOCKER ENDPOINT
desktop-linux * unix:///Users/shauntsai/.docker/run/docker.sock
↑ the context is literally named "linux"Practical consequence on Apple Silicon: containers default to arm64, but most production servers are amd64. An image built on your Mac can fail to run in production. Fix it explicitly:
docker build --platform linux/amd64 -t myapp .flowchart TD
Q["I'm typing in a terminal"] --> Q1{"does /proc exist?"}
Q1 -->|"no"| MAC["macOS/Darwin<br/>BSD tools · Mach-O · launchd · brew"]
Q1 -->|"yes"| LNX["Linux<br/>GNU tools · ELF · systemd · apt"]
LNX --> Q2{"where is this Linux running?"}
Q2 --> V1["a VM (WSL2, UTM, cloud instance)"]
Q2 --> V2["a container (docker exec)"]
Q2 --> V3["bare metal server"]
uname -s # Darwin = macOS, Linux = Linux
ls /proc # exists only on Linux
sed --version # errors on BSD/macOS, prints version on GNU/Linux- Trust CI over your laptop. Your Mac is a development convenience; Linux is the deployment truth. When they disagree, Linux is right.
- Install GNU tools on your Mac (
brew install coreutils gnu-sed findutils) so your scripts behave the way servers will. - Never
sed -iwithout thinking. Either usegsed, or writesed -i.bak(works on both) and delete the backup. - Ship source, not binaries. Anything compiled must be built for its target platform — that's what Docker is for.
- Assume the filesystem is case-sensitive, even though yours isn't. Match import paths to filenames exactly, always.
- Build with
--platform linux/amd64on Apple Silicon when the target is a normal cloud server. - When you need real Linux, use real Linux — a container, a VM, or WSL2. Emulating it in your head is where bugs come from.
flowchart TB
YOU["you, typing"] --> POSIX["POSIX shell + utilities<br/>✅ THE SAME EVERYWHERE<br/>(this is why Mac, WSL and Linux feel identical —<br/>and that feeling is CORRECT)"]
POSIX --> D1["macOS: BSD flags · Mach-O · dyld<br/>launchd · brew · case-insensitive FS<br/>no /proc, no containers natively"]
POSIX --> D2["Linux: GNU flags · ELF · ld.so<br/>systemd · apt/dnf · case-sensitive FS<br/>namespaces + cgroups = containers live here"]
D1 -->|"so your Mac runs a hidden Linux VM<br/>whenever you use Docker"| D2
D2 --> PROD["production, CI, and every server<br/>= the environment that actually judges your code"]
Mental shortcut: macOS and Linux are two dialects of the same language. You can hold a conversation in either without noticing — until you try to hand over something physical (a compiled binary, a filename, a service definition), and then the dialects turn out to be different languages after all.
Run these on any machine to locate yourself on this map.
| Question | Command | macOS answer | Linux answer |
|---|---|---|---|
| Which kernel? | uname -s |
Darwin |
Linux |
| BSD or GNU tools? | sed --version |
error | version string |
| Which binary format? | file $(which git) |
Mach-O | ELF |
| Which init? | ls /sbin/launchd; which systemctl |
launchd | systemctl |
| Case-sensitive FS? | touch A; [ -e a ] && echo insensitive |
insensitive | (no output) |
| Containers native? | ls /proc |
missing | present |
Research assisted by an automated search agent; all terminal outputs verified on Darwin 25.5.0 (arm64).