Skip to content

Commit bebd8a9

Browse files
committed
docs: add Docker disk usage runbook and maintenance cheatsheet
Docker Desktop's VM died on 2026-09-07 with "no space left on device", taking every container with it — the third occurrence. The cause was not a bug or a misconfiguration in this repo, but unbounded caches behind a limit never set. - docs/disk-usage.md — why it happens. settings-store.json had no DiskSizeMiB, so the VM disk defaulted to a 1 TB virtual max; on a 460 GB Mac that makes Docker's effective limit the whole machine, so a full disk kills the VM instead of returning ENOSPC to the caller. Documents the growth sources specific to this setup: the Dev Containers shared `vscode` volume (23.5 GB — 17 server installs, plus 11 GB of extensionsCache of which anthropic.claude-code alone was 84 versions / 6.7 GB), daily :latest pulls leaving dangling images, node_modules volumes duplicated across the default and -sandbox variants, and writable-layer growth from in-container caches. Running both Alpine and glibc devcontainers doubles every server install. Also records the two macOS behaviours that make this hard to diagnose: `df -h /` reports the sealed read-only system snapshot, and APFS local snapshots pin freed blocks so reclaimed space does not return for ~24h. - docs/docker-maintenance-cheatsheet.md — what to run. Triage, safe reclaim order, emptying the vscode volume, recovering a hung daemon, the post-crash `container prune` caveat, and volume backup/restore. Stock Docker and VS Code tooling only. There is no built-in pruning to enable: the Dev Containers extension ships no automatic cleanup of the server cache and no setting to bound its size. The one relevant switch, dev.containers.cacheVolume, is documented along with why disabling it is the wrong trade-off here — these devcontainers pull :latest on open, so containers are recreated often and each recreation would re-download the server.
1 parent 88a2161 commit bebd8a9

3 files changed

Lines changed: 356 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ This repository contains Dockerfiles for custom Docker images hosted on GitHub C
1616

1717
- **[ralphex-fe](./ralphex-fe/README.md)** - Bun + Hugo Extended on ralphex base (standalone image)
1818

19+
### Maintenance
20+
21+
- **[Docker maintenance cheatsheet](./docs/docker-maintenance-cheatsheet.md)** - commands for "low disk space", a hung Docker, and safe cleanup
22+
- **[Why Docker fills the disk](./docs/disk-usage.md)** - what actually grows, why, and the settings that prevent an outage
23+
1924
## Repository Structure
2025

2126
Each subdirectory represents a Docker image project. Devcontainer images use the following structure:

‎docs/disk-usage.md‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Why Docker fills the disk, and what actually fixes it
2+
3+
Written after the 2026-09-07 incident: Docker Desktop's VM died with
4+
`no space left on device`, taking every container with it. The Mac had 2.8 GB
5+
free; `Docker.raw` had grown to 80 GB. This is the third occurrence.
6+
7+
The point of this document is that **almost none of it was a Docker bug or a
8+
misconfiguration in this repo**. It is normal, expected growth in caches that
9+
nothing prunes, behind a limit that was never set.
10+
11+
---
12+
13+
## The one setting that turns a nuisance into an outage
14+
15+
Docker Desktop's `settings-store.json` had **no `DiskSizeMiB` key**, so the VM
16+
disk defaulted to a **1 TB** virtual maximum:
17+
18+
```
19+
ls -lh ~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw
20+
# -rw-r--r-- 1 user staff 1.0T <- apparent (virtual max)
21+
du -sh ~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw
22+
# 80G <- actually allocated (sparse file)
23+
```
24+
25+
With a 1 TB ceiling on a 460 GB Mac, Docker's effective limit is *the entire
26+
machine*. So instead of Docker hitting its own wall and returning a normal
27+
`no space left on device` to whatever was writing, it kept growing until macOS
28+
ran out — and the VM itself died.
29+
30+
**Fix: set a disk usage limit below your typical free space.** 64 GB was chosen
31+
(steady-state need is ~25–30 GB). Docker Desktop → Settings → Resources →
32+
Advanced → Disk usage limit.
33+
34+
This is the single highest-value change. It does not stop the growth; it makes
35+
the failure survivable and local to Docker.
36+
37+
---
38+
39+
## Where the space actually goes
40+
41+
Measured on 2026-09-08, inside a 53 GB `Docker.raw`:
42+
43+
| Consumer | Size | Pruned by anything? |
44+
|---|---|---|
45+
| `vscode` volume (VS Code server + extension cache) | 23.5 GB | **No** |
46+
| Named volumes (node_modules, pgdata, claude-config…) | 31 GB total | Only manually |
47+
| Images | 9.5 GB | `docker image prune` |
48+
| Build cache | 3.9 GB | builder GC (configured) |
49+
| Container writable layers | 6.3 GB | `docker container prune` |
50+
51+
### 1. The `vscode` volume — the big one
52+
53+
Created automatically by the Dev Containers extension (nothing in this repo
54+
mounts it) to cache the VS Code Server across container rebuilds. It contained:
55+
56+
- **11.1 GB of server installs** — 17 of them. One directory per VS Code commit,
57+
~500 MB–1.3 GB each, going back ~9 months. Old ones are never deleted.
58+
- **11 GB of `extensionsCache`** — of which `anthropic.claude-code` alone was
59+
**84 cached versions = 6.7 GB**, and `openai.chatgpt` 26 versions = 3.5 GB.
60+
- **819 MB of orphaned `.vsix` downloads** — UUID-named temp files left behind by
61+
interrupted downloads.
62+
63+
Two things make this worse here than for most people:
64+
65+
- **AI extensions ship several releases per week.** A cache designed for
66+
monthly-ish updates now takes ~30x the churn, and keeps every version.
67+
- **Running both Alpine and glibc devcontainers doubles it.** Every VS Code
68+
release is installed twice, as `linux-arm64` *and* `alpine-arm64`.
69+
70+
### 2. Daily `:latest` pulls leave dangling images
71+
72+
The images in this repo are rebuilt by CI daily, and each devcontainer's
73+
`initializeCommand` runs `docker pull …:latest` on open. The previous ~2.5 GB
74+
image becomes dangling locally and nothing removes it automatically — builder GC
75+
only touches build cache, and the GHCR cleanup workflow only touches the remote
76+
registry. Regular `docker image prune` is required.
77+
78+
### 3. node_modules volumes are duplicated per variant
79+
80+
Compose prefixes volume names with the project name, so the default and
81+
`-sandbox` variants of a devcontainer get *separate* `node_modules` volumes even
82+
though `${localWorkspaceFolderBasename}` was intended to share them. Opening both
83+
variants stores dependencies twice.
84+
85+
### 4. Long-running containers accumulate writable layers
86+
87+
`mise install` / `bun install` caches land in the container's writable layer
88+
rather than a volume. One long-lived container reached 13 GB on its own.
89+
90+
---
91+
92+
## Two macOS behaviours that make diagnosis confusing
93+
94+
**`df -h /` lies.** It reports the sealed read-only system snapshot. Real free
95+
space is on the data volume:
96+
97+
```
98+
df -h /System/Volumes/Data
99+
```
100+
101+
**Freeing space inside Docker may not return it to the Mac.** APFS local Time
102+
Machine snapshots are copy-on-write and pin the old blocks. After reclaiming
103+
29 GB and watching `Docker.raw` shrink 80 → 51 GB, host free space did not move
104+
at all, because two same-day snapshots still referenced the old contents. They
105+
expire on their own within ~24h and the space then returns.
106+
107+
```
108+
tmutil listlocalsnapshots / # if space did not come back, look here first
109+
```
110+
111+
---
112+
113+
## What to do about it — official tools only
114+
115+
Ranked by value:
116+
117+
1. **Set the Docker disk usage limit** (above). Do this regardless of everything
118+
else.
119+
2. **Delete the `vscode` volume periodically** — stop your devcontainers, then
120+
`docker volume rm vscode`. The extension recreates it containing only the
121+
current server. Reclaims ~18 GB; costs one server re-download per
122+
devcontainer, once.
123+
3. **`docker image prune -f`** on a regular basis — this is the one that offsets
124+
the daily `:latest` pulls.
125+
4. **VS Code's own commands**: `Dev Containers: Clean Up Dev Containers…` and
126+
`Dev Containers: Clean Up Dev Volumes…`.
127+
128+
See [`docker-maintenance-cheatsheet.md`](./docker-maintenance-cheatsheet.md) for
129+
the exact commands.
130+
131+
### There is no built-in cleanup to enable
132+
133+
Verified against Microsoft's documentation and the extension manifest: VS Code
134+
ships **no** automatic pruning of the server cache or `extensionsCache`, and no
135+
setting to bound their size. There is nothing being "missed".
136+
137+
The one relevant official switch is **`dev.containers.cacheVolume`** (default
138+
`true`) — *"Controls whether a Docker volume should be used to cache the VS Code
139+
server and extensions."* Setting it to `false` removes the shared volume
140+
entirely, and the server then lives in each container's writable layer, which
141+
`docker container prune` can reclaim.
142+
143+
**Not recommended for this setup**: because these devcontainers pull `:latest` on
144+
every open and CI rebuilds daily, containers are recreated often, and each
145+
recreation would re-download the server. The shared cache is genuinely earning
146+
its keep here — it just needs occasional emptying.
147+
148+
---
149+
150+
## Things that look like solutions but are not
151+
152+
- **`docker system prune -a --volumes`** — wipes database and credential volumes
153+
(`*-pgdata`, `*-claude-config`, `*-fish-data`). Never run it here.
154+
- **`docker volume prune`** — same problem; orphaned `*-claude-config` and
155+
`*-fish-data` volumes are kept deliberately, they hold credentials and history.
156+
- **Scheduled-cleanup containers** — Watchtower was archived in Dec 2025 and
157+
`spotify/docker-gc` is unmaintained.
158+
- **Shrinking the Docker disk to reclaim space** — reducing the limit recreates
159+
the disk image and destroys all volumes. Back up first (see the runbook in
160+
`~/docker-volume-backups/`).
161+
162+
---
163+
164+
## Prevention checklist
165+
166+
- [ ] Docker disk usage limit set (64 GB)
167+
- [ ] `docker image prune -f` run periodically
168+
- [ ] `vscode` volume emptied when it exceeds ~10 GB
169+
- [ ] `"log-driver": "local"` in `~/.docker/daemon.json` — replaces unbounded
170+
`json-file` container logs with rotating, compressed ones
171+
- [ ] Retired project? `docker compose down -v` in its folder releases its volumes
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Docker maintenance cheatsheet (macOS)
2+
3+
Stock commands only — nothing custom to install. Background and the *why* live in
4+
[`disk-usage.md`](./disk-usage.md).
5+
6+
---
7+
8+
## "Low disk space" warning — do this first
9+
10+
```bash
11+
# 1. Real free space (df -h / reports the read-only system snapshot, ignore it)
12+
df -h /System/Volumes/Data
13+
14+
# 2. How big is Docker really? (ls shows the sparse virtual max, du shows actual)
15+
du -sh ~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw
16+
17+
# 3. What inside Docker is using it
18+
docker system df
19+
```
20+
21+
## Safe reclaim, in order of value
22+
23+
```bash
24+
docker image prune -f # dangling images only - safe, offsets daily :latest pulls
25+
docker builder prune -f # build cache beyond the keep-storage in daemon.json
26+
docker container prune -f # STOPPED containers only - check nothing was killed mid-work
27+
```
28+
29+
`Docker.raw` auto-TRIMs after a prune, so the file shrinks on its own. No manual
30+
`fstrim` needed on current Docker Desktop.
31+
32+
## The big one: the `vscode` volume
33+
34+
Usually the single largest item (23.5 GB when last measured). Nothing prunes it.
35+
36+
```bash
37+
docker system df -v | grep -i vscode # check its size first
38+
```
39+
40+
**Closing VS Code is not enough, and neither is stopping the containers.**
41+
`docker volume rm` blocks on any container that *references* the volume, running
42+
or not — the containers must be removed. Expect this error otherwise:
43+
44+
```
45+
Error response from daemon: remove vscode: volume is in use - [<ids>]
46+
```
47+
48+
Full procedure:
49+
50+
```bash
51+
# 1. Which containers reference it
52+
docker ps -a --filter volume=vscode --format '{{.Names}}\t{{.State}}'
53+
54+
# 2. Stop any that are running. Note: devcontainers with
55+
# restart: unless-stopped come back by themselves and stay up even with
56+
# VS Code closed.
57+
docker stop <names>
58+
59+
# 3. Remove them. NEVER add -v: that would delete the named volumes too,
60+
# including *-claude-config (credentials) and *-pgdata (databases).
61+
docker rm <names>
62+
63+
# 4. Now the volume will go
64+
docker volume rm vscode
65+
```
66+
67+
Safe to do because devcontainer source is bind-mounted from the host and state
68+
lives in named volumes, both of which survive `docker rm`. What you lose is the
69+
container writable layer (mise/bun caches) — rebuilt on next "Reopen in
70+
Container", along with a one-time VS Code server re-download.
71+
72+
Do this when the volume exceeds ~10 GB, roughly quarterly.
73+
74+
## VS Code's own cleanup (Command Palette)
75+
76+
- `Dev Containers: Clean Up Dev Containers…`
77+
- `Dev Containers: Clean Up Dev Volumes…`
78+
79+
## Never run these
80+
81+
```bash
82+
docker system prune -a --volumes # DESTROYS pgdata / claude-config / fish-data
83+
docker volume prune -a # same - those volumes hold credentials and DBs
84+
```
85+
86+
To release a *retired* project's volumes deliberately: `docker compose down -v`
87+
in that project's folder.
88+
89+
---
90+
91+
## Reclaimed space didn't show up in `df`?
92+
93+
APFS local snapshots pin the freed blocks — copy-on-write means deleting data
94+
inside `Docker.raw` returns nothing to the pool while a snapshot references it.
95+
96+
```bash
97+
tmutil listlocalsnapshots / # same-day snapshots are the usual cause
98+
```
99+
100+
They expire on their own within ~24h and the space returns. To force it (this
101+
is what macOS itself runs under pressure, `4` = urgency):
102+
103+
```bash
104+
tmutil thinlocalsnapshots / 30000000000 4
105+
```
106+
107+
Deleting local snapshots does **not** affect Time Machine backups on an external
108+
or network disk.
109+
110+
---
111+
112+
## Docker is hung — every command just sits there
113+
114+
Symptom: `docker ps` never returns (rather than erroring). Usually means the
115+
Linux VM died but the host-side backend still holds the socket.
116+
117+
```bash
118+
# Confirm it
119+
grep -iE 'no space|GET /error' \
120+
~/Library/Containers/com.docker.docker/Data/log/host/com.docker.backend.log | tail
121+
122+
# Recover
123+
osascript -e 'quit app "Docker Desktop"'
124+
pgrep -f 'com\.docker\.back[e]nd' # note the PID, then: kill -9 <pid>
125+
open -a Docker
126+
until docker info >/dev/null 2>&1; do sleep 5; done; echo "daemon up"
127+
```
128+
129+
Note the `back[e]nd` bracket trick: a plain `pkill -f "com.docker.backend"`
130+
matches the killing shell's own command line and kills itself instead.
131+
132+
**After a crash, `docker container prune -f` is not safe.** Containers that were
133+
*running* are now `Exited (255)` and indistinguishable from long-idle ones.
134+
Separate them by stop time before pruning:
135+
136+
```bash
137+
docker inspect -f '{{.State.Status}}|{{.State.FinishedAt}}|{{.Name}}' $(docker ps -aq) | sort -t'|' -k2
138+
```
139+
140+
Crash-killed containers all share the daemon-boot timestamp.
141+
142+
---
143+
144+
## Settings worth having
145+
146+
**Disk usage limit** — Docker Desktop → Settings → Resources → Advanced.
147+
Set it *below* your typical free space (64 GB here). Without it the default is a
148+
1 TB virtual disk, i.e. Docker can consume the whole Mac. Reducing the limit
149+
recreates the disk and destroys all volumes — back up first.
150+
151+
**Log rotation** — `~/.docker/daemon.json`:
152+
153+
```json
154+
{
155+
"builder": { "gc": { "enabled": true, "defaultKeepStorage": "5GB" } },
156+
"log-driver": "local"
157+
}
158+
```
159+
160+
`local` rotates and compresses; the default `json-file` grows unbounded.
161+
Requires a Docker restart, and applies to newly created containers.
162+
163+
---
164+
165+
## Volume backups
166+
167+
Tarballs, manifest, and `backup.sh` / `restore.sh` live in
168+
`~/docker-volume-backups/`. Restore verifies sha256 against `MANIFEST.txt` and
169+
refuses to overwrite a volume that a running container has mounted.
170+
171+
```bash
172+
~/docker-volume-backups/<date>/restore.sh --verify # integrity check only
173+
~/docker-volume-backups/<date>/restore.sh # restore missing volumes
174+
~/docker-volume-backups/<date>/restore.sh --force # REPLACE existing ones
175+
```
176+
177+
Worth backing up: `*-claude-config`, `*-fish-data`, `*-fish-history`, `*pgdata*`,
178+
`*sql-data*`, `*storage-data*`.
179+
Regenerable, don't bother: `vscode`, `*node-modules*`, `*playwright-browsers*`,
180+
images, container layers.

0 commit comments

Comments
 (0)