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/...
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:
- Install Playwright as
rootduring the build - Run the app as a non-root user (like
rails,node, orappuser)
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
The key pieces:
-
ENV PLAYWRIGHT_BROWSERS_PATH— tells Playwright where to install AND where to look -
chmod -R o+rx— makes the directory readable by all users - 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:
libnss3libatk1.0-0libgbm1libpangocairo-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"]
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"
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.
Top comments (0)