The problem
I wanted to add a FastAPI service to a pnpm + Nx monorepo, and Nx has no idea what Python even is. The affected graph can't see Python imports, pnpm install can't install Python packages, and the default Nx CI path runs lint, test, and build only against the projects it recognizes.
The service is real work: a Greenhouse scraper, a scoring engine, a poller, database migrations. It needs its own linter, formatter, type checker, and test runner, and I didn't want to bolt a Poetry install onto every CI step or ask pnpm to pretend it's a Python installer.
The approach
Four escape hatches, each one small:
- uv workspace at the monorepo root, with the Python package as a member.
- Docker build from the repo root, not the service directory, so the workspace lockfile is reachable.
- A dedicated
ci-pythonCI job that runs alongside the Node jobs and uses the samepnpm nxCLI to dispatch uv-backed tasks. - Project-level Nx targets (
dev,test,lint,mypy) that shell out touv run --package job-api ....
None of this asks Nx to understand Python; Nx just dispatches commands, and uv owns the Python side of things.
uv workspace
pyproject.toml at the monorepo root declares the workspace and nothing else:
[project]
name = "danieljoffe-com"
version = "0.0.0"
description = "Workspace root — not a publishable package"
requires-python = ">=3.11"
[tool.uv.workspace]
members = ["apps/job-api"]The service pyproject.toml in apps/job-api/ declares its dependencies normally. The single uv.lock lives at the root and locks every Python dependency across every member. uv is fast enough (Rust, same author as Ruff) that uv sync --frozen finishes in a few seconds even in CI.
Docker from the monorepo root
This is the gotcha. The service's Dockerfile lives at apps/job-api/Dockerfile, but the build context has to be the monorepo root, because that's where the workspace lockfile sits. Build from the service directory and it can't see uv.lock at all.
FROM python:3.11-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
# Build context is the monorepo root so the workspace lockfile is available.
COPY pyproject.toml uv.lock ./
COPY apps/job-api/pyproject.toml ./apps/job-api/pyproject.toml
RUN uv sync --frozen --no-dev --no-editable --package job-api
COPY apps/job-api/app ./apps/job-api/app
WORKDIR /app/apps/job-api
EXPOSE 8000
CMD ["uv", "run", "--package", "job-api", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]On Railway that means setting the service's build context to the repo root and the Dockerfile path to apps/job-api/Dockerfile; locally the equivalent is docker build -f apps/job-api/Dockerfile .. Two flags, and I had to relearn the lesson the hard way the first time CI ran docker build . inside the service directory and handed me a uv.lock not found error.
Project targets that pretend to be JavaScript
The project.json exposes the Python work as Nx targets so the rest of the monorepo can run everything through the same pnpm nx entrypoint:
{
"name": "job-api",
"sourceRoot": "apps/job-api",
"projectType": "application",
"targets": {
"dev": {
"executor": "nx:run-commands",
"options": {
"command": "uv run --package job-api uvicorn app.main:app --reload --port 8000",
"cwd": "apps/job-api"
}
},
"test": {
"executor": "nx:run-commands",
"options": {
"command": "uv run --package job-api pytest -v",
"cwd": "apps/job-api"
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "uv run --package job-api ruff check .",
"cwd": "apps/job-api"
}
},
"mypy": {
"executor": "nx:run-commands",
"options": {
"command": "uv run --package job-api mypy app/",
"cwd": "apps/job-api"
}
}
}
}One quiet rename matters here: the Python type-check target is mypy, not typecheck. The @nx/js/typescript plugin auto-infers a typecheck target on every project with a tsconfig, so if the service also defined typecheck, the name would collide with the workspace-wide TypeScript typecheck sweep and one would silently overwrite the other. Naming it mypy sidesteps the whole conflict.
A dedicated ci-python job
Nx affected can't see Python changes, so dropping the Python work into the main affected pipeline would make every Python commit look like a no-op to Nx and quietly skip the tests. The fix is a parallel GitHub Actions job that always runs when a non-docs PR touches the workspace:
ci-python:
needs: preflight
if: ${{ needs.preflight.outputs.docs-only != 'true' && !inputs.update-snapshots && github.event.pull_request.draft != true }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with: { filter: tree:0, fetch-depth: 0 }
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
with: { node-version-file: .nvmrc, cache: pnpm }
- run: pnpm install --frozen-lockfile
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- uses: astral-sh/setup-uv@v6
- name: Install Python dependencies
run: uv sync --frozen
- name: Run Python tasks
run: pnpm nx run-many -t lint test mypy --nxBail -p job-apiTwo details matter here. First, the job installs both pnpm and uv: pnpm to get the Nx CLI, uv to actually execute the Python work Nx dispatches. Second, pnpm nx run-many -t lint test mypy -p job-api runs the three Python targets in parallel, and --nxBail kills the run on the first failure.
The ci-status gate treats ci-python as a required check alongside fast and full, so if mypy fails, the PR can't merge.
The takeaway
Nx doesn't have to understand every language in the repo; it only has to dispatch the work. Hand uv the lockfile, let it own dependency resolution, and expose the Python tasks as nx:run-commands targets so the rest of the pipeline speaks one CLI.
The two things that catch you are the Docker build context (has to be the repo root) and the typecheck name collision (rename the Python target). Once you're past those, a Python service in a JS monorepo is boring infrastructure, which is exactly what you want.
