Skip to main content

Eight Failed Deploys to Ship FastAPI + Playwright on Railway

A row of stamped-red boarding passes lined up on a counter
Apr 18, 20264 min readCI/CD, Playwright, Nx, Monorepo, Lighthouse

The problem

I was shipping a new FastAPI service to Railway, a container that runs Lighthouse CLI and Playwright to audit pages. The builds went green; the deploys went red, eight times running. Each failure pointed somewhere specific, and each time it pointed somewhere wrong.

A single deploy cycle on Railway is about three minutes: pull the image, start the container, wait on the healthcheck. Eight of those is most of an evening. Here's the failure chain, plus the three root causes that are actually worth carrying forward.

The chain

#SymptomCause
1Chromium SIGSEGV on launchAPT-installed chromium, wrong libs
2lighthouse: not foundPlaywright base image has no Node
3pyiceberg source build failsuv grabbed Python 3.14, no wheels
4exec: uvicorn: Permission denied/root mode 700 blocks non-root
5Executable doesn't exist at /ms-playwright/...Playwright pypi vs base image skew
6CHROME_BIN empty, build failsLayout shifted under /ms-playwright
7scan_issues_scan_id_fkey violationEnv pointing at wrong Supabase
8(diagnosis) 20s silent fail on every scanUndetected row miss

The first two were squarely on me. Chromium needs all of Playwright's vetted system libs, so I should have started from the Playwright base image instead of apt-installing it myself. Lighthouse needs Node, and the Python-variant Playwright image ships Python only, so I added NodeSource and moved on.

The next three I hadn't run into before, and they generalize past this one project.

uv grabbed Python 3.14

I asked uv for a FastAPI service and it built me one; what I didn't clock was which Python it had reached for. uv defaults to the newest Python it can find, which at the time was 3.14. One of my transitive dependencies (pyiceberg, by way of supabase) doesn't publish wheels for 3.14, so uv kicked off a source build, and a source build wants a C toolchain the container doesn't have.

The fix was to pin Python at the Dockerfile layer, not the pyproject layer:

ENV UV_PYTHON_INSTALL_DIR=/app/.uv-python
RUN uv python install 3.11 \
    && uv sync --frozen --no-dev --no-editable --package audit-api --python 3.11

uv python install 3.11 materializes the managed interpreter before uv sync runs, and --python 3.11 forces the sync to use it. Leave off either flag and uv will happily pick a newer interpreter on the next build for no reason other than it's sitting in the cache.

The lesson: pinning a Python version in pyproject.toml (requires-python = ">=3.11") only tells uv the floor; it says nothing about which version to grab when several clear that floor. Pin the actual version in the Dockerfile.

The /root mode 700 problem

By default, uv installs its managed Python interpreters under $HOME/.local/share/uv/python. Run the container as root and that's /root/.local/share/uv/python. Run it as a non-root user (which you should), and the venv shebang still points at /root/..., except /root is mode 700. So:

sh: 1: exec: /app/.venv/bin/uvicorn: Permission denied

The venv shebang reads fine; the interpreter it points at doesn't. The healthcheck fails, and Railway kills the container.

The fix is to relocate uv's managed Python before the sync runs:

ENV UV_PYTHON_INSTALL_DIR=/app/.uv-python

A chown -R app:app /app at the end of the build sweeps up the relocated interpreter too. The venv shebang now points at /app/.uv-python/..., which the non-root app user can actually exec.

This bites anyone running uv-managed Python under a non-root user. The failure is loud (Permission denied the moment the container starts), but the cause isn't in any error message; it's in the default install location.

Playwright's version-locked browser paths

Playwright pre-installs browsers at version-specific paths: /ms-playwright/chromium_headless_shell-1208/ for Playwright 1.48, /ms-playwright/chromium_headless_shell-1172/ for a different version. The Playwright pypi package flat-out refuses to launch a browser at an unexpected path, because the protocol between the pypi client and the browser shifts from version to version.

My uv.lock had resolved playwright>=1.48 all the way up to 1.58 at install time, but my Dockerfile was still FROM mcr.microsoft.com/playwright/python:v1.48.0-jammy. So the pypi client went looking for 1.58's browser build while the image only had 1.48's, and launch failed on every scan.

The fix is to lock both in a single commit:

apps/audit-api/pyproject.toml
dependencies = [
  "playwright==1.58.0",
  # Dockerfile base image tag must match this version. See Dockerfile comment.
]
# IMPORTANT: tag MUST match the `playwright` version in uv.lock.
FROM mcr.microsoft.com/playwright/python:v1.58.0-jammy AS base

Pin the pypi package, pin the image tag, keep them next to each other, and bump them in lockstep. A floating range plus a pinned image is a time bomb with a long fuse.

The layout-agnostic binary lookup

Lighthouse's chrome-launcher follows CHROME_PATH, and I'd been pointing it at /ms-playwright/chromium-NNN/chrome-linux/chrome. Playwright 1.58's base image then rearranged the furniture: no chrome-linux/, no standalone chrome in some variants, just chrome-headless-shell. The glob came back empty, the ln -sf failed silently, and the scan crashed later when Lighthouse couldn't spawn Chrome.

Swap the glob for a find that matches any launcher-capable binary:

RUN CHROME_BIN="$(find /ms-playwright -path '*chromium*' -type f \
      \( -name chrome -o -name chrome-headless-shell \) -executable 2>/dev/null | head -1)" \
    && test -n "$CHROME_BIN" \
    && ln -sf "$CHROME_BIN" /usr/local/bin/chrome

test -n "$CHROME_BIN" fails the build loudly the moment nothing matches. A silent empty glob that produces an empty symlink looks fine right up until runtime; a build that breaks when the layout changes is one that tells you where to look.

Takeaway

Every failure in this chain was visible at build time if you were paying attention, and every one stayed quiet at runtime until the first real request. All three of the generalizable causes share a shape: a default that works fine in isolation (uv grabbing the newest Python, uv using the default home, a glob that evaluates to nothing) goes wrong the instant it hits a boundary where another system is expecting a contract. So pin the defaults, validate them in the build, and fail loud.