Playwright in Docker: The Browser Path Gotcha That'll Waste Your Afternoon

# playwright# docker# devops# webdev
Playwright in Docker: The Browser Path Gotcha That'll Waste Your AfternoonMack

If your Playwright screenshots work locally but fail in Docker with 'Executable doesn't exist', here's the fix.

You build your screenshot service. It works perfectly on your machine. You deploy it to Docker and get:

browserType.launch: Executable doesn't exist at
/home/rails/.cache/ms-playwright/chromium_headless_shell-1208/...
Enter fullscreen mode Exit fullscreen mode

Classic.

The Problem

Playwright installs browser binaries to a user-specific cache directory. By default, that's ~/.cache/ms-playwright/ on Linux.

In Docker, you typically:

  1. Install Playwright as root during the build
  2. Run the app as a non-root user (like rails, node, or appuser)

The browsers get installed to /root/.cache/ms-playwright/, but your app looks in /home/youruser/.cache/ms-playwright/. Empty directory. Crash.

The Fix

Set PLAYWRIGHT_BROWSERS_PATH to a shared location that both users can access:

# In your final stage
ENV PLAYWRIGHT_BROWSERS_PATH="/opt/playwright-browsers"

RUN npm install -g playwright@latest && \
    npx playwright install --with-deps chromium && \
    chmod -R o+rx /opt/playwright-browsers && \
    npm cache clean --force

# ... later ...
USER rails:rails  # Your non-root user can now find the browsers
Enter fullscreen mode Exit fullscreen mode

The key pieces:

  1. ENV PLAYWRIGHT_BROWSERS_PATH — tells Playwright where to install AND where to look
  2. chmod -R o+rx — makes the directory readable by all users
  3. Set the ENV before the install so it takes effect during npx playwright install

Why --with-deps Matters

On Debian/Ubuntu-based images, Chromium needs system libraries. --with-deps installs them automatically:

  • libnss3
  • libatk1.0-0
  • libgbm1
  • libpangocairo-1.0-0
  • ... about 20 more

Without --with-deps, you'll get a different error about missing shared libraries. Fun.

The Full Pattern

Here's what a clean multi-stage Dockerfile looks like for a service that renders screenshots:

FROM ruby:4.0-slim AS base
WORKDIR /app

# Base deps
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y nodejs npm && \
    rm -rf /var/lib/apt/lists/*

# Build stage (install gems, assets, etc.)
FROM base AS build
# ... your build steps ...

# Final stage
FROM base

# Playwright browsers in a shared path
ENV PLAYWRIGHT_BROWSERS_PATH="/opt/playwright-browsers"
RUN npm install -g playwright@latest && \
    npx playwright install --with-deps chromium && \
    chmod -R o+rx /opt/playwright-browsers && \
    npm cache clean --force

COPY --from=build /app /app

# Non-root user
RUN useradd -m appuser
USER appuser

CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Quick Sanity Check

If you're debugging this, SSH into your container and verify:

# Check where Playwright expects browsers
echo $PLAYWRIGHT_BROWSERS_PATH

# List what's actually installed
ls -la /opt/playwright-browsers/

# Run as your app user
su - appuser -c "npx playwright install --dry-run"
Enter fullscreen mode Exit fullscreen mode

One More Thing

First render after a cold container start will be slow (~2-5s) because Chromium needs to spin up. Subsequent renders reuse the warm process and are much faster. If you need consistent sub-second times, keep a warm browser instance pool.


I hit this exact bug while building Rendly, a screenshot and OG image API. Wasted 30 minutes staring at logs before the obvious hit me. Now you don't have to.