Skip to content

Commit 56a837b

Browse files
authored
fix(claude-code): consolidate onto system Chromium to shrink image (#83)
The daily-rebuilt image grew from ~600MB to ~1.07GB because two Chromium installs ran side-by-side: Playwright's --only-shell bake (which still pulls Firefox+WebKit despite the flag) and apt chromium pulled transitively by agent-browser install --with-deps. Align with the project's own .claude/rules/dockerfile.md convention: install system chromium via apt and set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium. Drop the Playwright install-deps and browser-bake RUNs, drop the redundant npx playwright install from both devcontainer.json postCreate/updateContent commands, and move the agent-browser install to run as the node user so no subsequent chown -R duplicates 452MB of claude-code files via overlayfs. Measured (linux/amd64): - default: ~1.07GB -> 660MB (-38%) - sandbox: 3.09GB -> 350MB (-89%) Closes #82
1 parent b5e558c commit 56a837b

4 files changed

Lines changed: 67 additions & 74 deletions

File tree

‎claude-code/.devcontainer/Dockerfile‎

Lines changed: 31 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ RUN set -eux; \
4545
FROM node:24-trixie-slim AS base
4646

4747
ARG GIT_DELTA_VERSION=0.18.2
48-
ARG PLAYWRIGHT_VERSION=1.58.2
4948

5049
# System packages shared by all targets
5150
# - ca-certificates: SSL/TLS for HTTPS connections
@@ -115,14 +114,15 @@ USER root
115114
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
116115
RUN curl -sS https://starship.rs/install.sh | sh -s -- --yes
117116
RUN curl https://mise.run | sh \
118-
&& cp /root/.local/bin/mise /usr/local/bin/mise
117+
&& cp /root/.local/bin/mise /usr/local/bin/mise \
118+
&& rm -rf /root/.local
119119
SHELL ["/bin/sh", "-c"]
120120

121121
# Configure starship and fish shell.
122122
# Mise is installed as a tool manager — projects run `mise install` at container
123123
# creation to install their specific tool versions from .mise.toml.
124-
# Node is NOT installed via mise — it's provided by the base image;
125-
# mise shims would shadow the base image's npx, breaking Playwright installation.
124+
# Node is NOT installed via mise — it's provided by the base image; mise shims
125+
# would shadow the base image's npx, breaking global npm-installed CLIs.
126126
USER node
127127
RUN mkdir -p /home/node/.config/fish \
128128
&& starship preset no-runtime-versions -o /home/node/.config/starship.toml \
@@ -137,22 +137,6 @@ ENV MISE_TRUSTED_CONFIG_PATHS="/workspace"
137137
COPY --from=rtk-download /usr/local/bin/rtk /usr/local/bin/rtk
138138
COPY --from=ralphex-download /usr/local/bin/ralphex /usr/local/bin/ralphex
139139

140-
# ── Playwright (headless shell for browser testing) ───────────────────────────
141-
# Two-layer strategy for multi-project compatibility:
142-
# 1. System deps (install-deps) — heavy, needs root, version-stable across
143-
# nearby releases. Baked into the image so projects don't need sudo.
144-
# 2. Browser binary (install --only-shell) — light, version-specific.
145-
# Baked as a cache; projects MUST run `npx playwright install --only-shell chromium`
146-
# at container creation to ensure the binary matches their @playwright/test.
147-
# That command is idempotent: no-op when versions match, ~10s download if not.
148-
USER root
149-
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
150-
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
151-
npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
152-
USER node
153-
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install --only-shell
154-
ENV PLAYWRIGHT_VERSION=${PLAYWRIGHT_VERSION}
155-
156140
# ── Claude Code CLI ───────────────────────────────────────────────────────────
157141
# Using npm instead of the native installer (curl claude.ai/install.sh | bash).
158142
# The native installer is recommended for interactive use, but rate-limits (429)
@@ -161,10 +145,11 @@ ENV PLAYWRIGHT_VERSION=${PLAYWRIGHT_VERSION}
161145
# reasons" — Docker/CI parallel builds are exactly that reason.
162146
# See: https://code.claude.com/docs/en/getting-started#install-with-npm
163147
# Auto-updates don't matter here — the image rebuilds daily.
164-
USER root
165-
RUN --mount=type=cache,target=/root/.npm \
166-
npm install -g @anthropic-ai/claude-code
167-
USER node
148+
#
149+
# Install as the node user so all files land with node ownership from the start.
150+
# A later `chown -R` in another layer would duplicate every file (overlayfs
151+
# treats an ownership change as a rewrite), adding hundreds of MB.
152+
RUN npm install -g @anthropic-ai/claude-code
168153

169154
# ─── DEFAULT — full dev environment ───────────────────────────────────────────
170155
FROM base AS default
@@ -177,24 +162,32 @@ RUN echo "node ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/node-nopasswd \
177162
&& chmod 0440 /etc/sudoers.d/node-nopasswd
178163
USER node
179164

180-
# agent-browser: headless browser automation for AI agents
181-
# amd64: Chrome for Testing (Google's official automation channel)
182-
# arm64: system Chromium (Chrome for Testing has no ARM64 Linux builds)
183-
ARG AGENT_BROWSER_VERSION=latest
165+
# System Chromium + fonts for headless browser testing.
166+
# - chromium: used by Playwright and agent-browser via
167+
# PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH (see .claude/rules/dockerfile.md).
168+
# - fonts-freefont-ttf: baseline fonts for headless rendering.
169+
# Kept out of the base stage so the sandbox target stays lean.
184170
USER root
185-
RUN --mount=type=cache,target=/root/.npm \
186-
--mount=type=cache,target=/var/cache/apt,sharing=locked \
171+
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
187172
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
188-
npm install -g agent-browser@${AGENT_BROWSER_VERSION} \
189-
&& ARCH=$(dpkg --print-architecture) \
190-
&& if [ "$ARCH" = "amd64" ]; then \
191-
agent-browser install --with-deps; \
192-
else \
193-
apt-get update && apt-get install -y --no-install-recommends chromium; \
194-
fi \
195-
&& chown -R node:node /usr/local/share/npm-global
173+
apt-get update && apt-get install -y --no-install-recommends \
174+
chromium \
175+
fonts-freefont-ttf
196176
USER node
197177

178+
# Use the apt-installed chromium instead of Playwright-managed browsers.
179+
# Avoids version coupling between @playwright/mcp (alpha playwright-core builds)
180+
# and cached browser binaries. Projects using @playwright/test must point their
181+
# playwright.config.ts at process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH.
182+
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
183+
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
184+
185+
# agent-browser: headless browser automation for AI agents.
186+
# Uses the apt-installed chromium above.
187+
# Installed as the node user (see claude-code install above for rationale).
188+
ARG AGENT_BROWSER_VERSION=latest
189+
RUN npm install -g agent-browser@${AGENT_BROWSER_VERSION}
190+
198191
LABEL org.opencontainers.image.source="https://github.com/gatezh/devcontainers" \
199192
org.opencontainers.image.description="Claude Code devcontainer — full dev environment with agent-browser, Playwright, and passwordless sudo" \
200193
org.opencontainers.image.licenses="MIT" \

‎claude-code/.devcontainer/claude-sandbox/devcontainer.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,10 @@
120120
// injected from the host. See "Sandbox Authentication" section in README.
121121
"CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}"
122122
},
123-
// Runs before postStartCommand (firewall), so network is still available for browser downloads.
124123
// The find command chowns all node_modules volume mount points in one pass.
125-
"postCreateCommand": "sudo find /workspace -maxdepth 4 -name node_modules -type d -exec chown node {} + && sudo chown -R node /home/node/.claude && mise install && bun install && npx playwright install --only-shell chromium",
124+
// Note: the sandbox image does not include chromium — projects needing
125+
// browser testing should install it via a downstream Dockerfile.
126+
"postCreateCommand": "sudo find /workspace -maxdepth 4 -name node_modules -type d -exec chown node {} + && sudo chown -R node /home/node/.claude && mise install && bun install",
126127
// Firewall init — script is bind-mounted from the project
127128
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
128129
"waitFor": "postStartCommand"

‎claude-code/.devcontainer/devcontainer.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@
108108
// sudo chown fixes volume ownership — safety net in case Docker volume population didn't apply.
109109
// The find command chowns all node_modules volume mount points in one pass.
110110
// mise install reads .mise.toml and installs project-specific tool versions.
111-
// playwright install ensures the correct browser binary for the project's @playwright/test version
112-
// (idempotent — skips download if the image's cached binary already matches).
113-
"updateContentCommand": "sudo find /workspace -maxdepth 4 -name node_modules -type d -exec chown node {} + && sudo chown -R node /home/node/.claude && mise install && bun install && npx playwright install --only-shell chromium",
111+
// Chromium is baked into the image via apt — no playwright install step needed.
112+
// Projects' playwright.config.ts should use process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH.
113+
"updateContentCommand": "sudo find /workspace -maxdepth 4 -name node_modules -type d -exec chown node {} + && sudo chown -R node /home/node/.claude && mise install && bun install",
114114
"waitFor": "postCreateCommand"
115115
}

‎claude-code/README.md‎

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@ Projects consume these pre-built images and control their own tool versions via
2121
| Mise | The tool manager itself (not the tools) | Projects run `mise install` at container creation for their tool versions |
2222
| rtk, ralphex | Always-latest from GitHub Releases | Dev infrastructure (like Claude Code) — no version pinning needed in projects |
2323
| Claude Code | npm global install | npm avoids rate limiting that affects the native installer in parallel CI builds |
24-
| Playwright | System deps + browser binary at a pinned version | See [Playwright version strategy](#playwright-version-strategy) |
2524

26-
**Default-only:** passwordless sudo, agent-browser + full Chromium
25+
**Default-only:** passwordless sudo, agent-browser, system Chromium (used by Playwright via `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`)
2726

2827
**Sandbox-only:** iptables, ipset, iproute2, dnsutils, aggregate, firewall sudo rule
2928

@@ -56,7 +55,7 @@ Copy these to your project's `.devcontainer/`:
5655
- [`.devcontainer/docker-compose.yml`](.devcontainer/docker-compose.yml) — image reference with `pull_policy: always`
5756
- [`.devcontainer/devcontainer.json`](.devcontainer/devcontainer.json) — full config with VS Code extensions, fish shell, OXC formatter, node_modules volume isolation, and lifecycle commands
5857

59-
**Key settings included:** fish + bash terminal profiles, OXC formatter (with comments for switching to Biome/Prettier), node_modules/Claude config/fish history volume mounts, and `updateContentCommand` for mise/bun/Playwright setup.
58+
**Key settings included:** fish + bash terminal profiles, OXC formatter (with comments for switching to Biome/Prettier), node_modules/Claude config/fish history volume mounts, and `updateContentCommand` for mise/bun setup.
6059

6160
### Sandbox variant
6261

@@ -212,37 +211,46 @@ The template only mounts root `node_modules` by default. For monorepo projects,
212211

213212
The `sudo find` in `updateContentCommand` chowns all `node_modules` directories in one pass, so additional mounts are handled automatically.
214213

215-
## Playwright Version Strategy
214+
## Playwright Strategy
216215

217-
The image uses a two-layer strategy so that any project can use any `@playwright/test` version:
216+
The image uses the system `chromium` package (apt-installed in the `default`
217+
target) instead of Playwright-managed browser binaries. This avoids version
218+
coupling between `@playwright/mcp` (alpha `playwright-core` builds) and cached
219+
binaries, and keeps the image significantly smaller than shipping a
220+
Playwright-managed Chromium per rebuild.
218221

219-
| Layer | What | Baked in image? | Version-sensitive? |
220-
|-------|------|-----------------|--------------------|
221-
| System deps (`install-deps`) | OS libraries (libgbm, libnss3, …) | Yes (needs root) | Stable across nearby versions |
222-
| Browser binary (`install --only-shell`) | Headless Chromium shell | Yes, as cache | Exact match required |
223-
224-
**Prerequisite:** Your project must have `@playwright/test` in `devDependencies`. Without it, `npx` will prompt interactively to download the `playwright` package, which hangs container creation (no TTY). Pin it to the image's baked version (check `$PLAYWRIGHT_VERSION`) or your preferred version:
222+
The Dockerfile sets:
225223

226224
```bash
227-
bun add -d @playwright/test # or: npm install -D @playwright/test
225+
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
226+
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
228227
```
229228

230-
**One-time project setup** — add this to your container creation command (`updateContentCommand` or `postCreateCommand`, as shown in [Quick Start](#quick-start)):
231-
232-
```bash
233-
npx playwright install --only-shell chromium
229+
`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` is a project convention — Playwright
230+
does **not** read it automatically. Each project's `playwright.config.ts`
231+
must honor it:
232+
233+
```ts
234+
// playwright.config.ts
235+
export default defineConfig({
236+
use: {
237+
launchOptions: {
238+
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
239+
},
240+
},
241+
});
234242
```
235243

236-
This is **idempotent** — if the cached binary already matches, it's a no-op (~0s). If the project's `@playwright/test` version differs from the image, it downloads the correct binary (~10s, once at container creation).
237-
238-
The baked version is available at runtime via the `PLAYWRIGHT_VERSION` environment variable.
244+
**Sandbox note:** `chromium` is installed only in the `default` target.
245+
Projects using the `sandbox` image that need browser testing should add
246+
chromium themselves (via a thin downstream Dockerfile or container
247+
`postStart`).
239248

240249
## Build Args
241250

242251
| Arg | Default | Description |
243252
|-----|---------|-------------|
244253
| `GIT_DELTA_VERSION` | `0.18.2` | git-delta version |
245-
| `PLAYWRIGHT_VERSION` | `1.58.2` | Playwright browser binary version |
246254
| `AGENT_BROWSER_VERSION` | `latest` | agent-browser version (default target only) |
247255

248256
## Building Locally / Local Fallback
@@ -311,25 +319,16 @@ docker buildx build \
311319

312320
## Startup Timeline
313321

314-
### Warm start (Playwright version matches image)
315322
```
316323
Pull image ──────────────────────── (cached)
317324
mise install (bun, hugo, etc.) ──── (~15s, downloads pre-built binaries)
318325
bun install ─────────────────────── (~15s, cached in named volume)
319-
playwright install --only-shell chromium (~0s, binary already cached — no-op)
320326
project setup ───────────────────── (db:migrate, init-plugins, etc.)
321327
Total: ~45s warm
322328
```
323329

324-
### Playwright version mismatch
325-
```
326-
Pull image ──────────────────────── (cached)
327-
mise install (bun, hugo, etc.) ──── (~15s)
328-
bun install ─────────────────────── (~15s)
329-
playwright install --only-shell chromium (~10s, downloads correct browser binary)
330-
project setup ───────────────────── (db:migrate, init-plugins, etc.)
331-
Total: ~55s warm
332-
```
330+
Chromium is baked into the image via apt — no per-container browser
331+
download step required.
333332

334333
## Resources
335334

0 commit comments

Comments
 (0)