Why one repo
Farcholabs runs on three pieces: the public site, an admin with the CRM, and an API with a background worker. The site and the admin are one Next.js app. The API is Python, with FastAPI and MongoDB. All of it lives in one repo.
The other option was a repo for the website and another for the API. We wrote down why we didn't take it when we started: a change to the contract between them would touch two repos and need two pull requests, and the admin and the API would drift apart in between. In one repo, the change that adds a field to the API also adds it to the screen that shows it, in the same pull request.
We counted how often that happens. From the pull request that set up the repo (#3, September 25, 2026) to #121 (October 18), we merged 119 pull requests. 37 of them changed both apps, and 29 changed the API's contract file.
| Only the web app | 59 PRs |
|---|---|
| Only the API | 10 PRs |
| Neither (docs, infra) | 13 PRs |
| Both apps | 37 PRs |
About a third of the work crossed the line between the apps. With two repos, each of those 37 would have been two pull requests, merged in the right order.
What each folder owns
Each top-level folder has one job, and the rules for it are written in the repo's CLAUDE.md, so a person or an AI agent reads them before changing anything.
There is no shared package between the apps. The only thing the two apps share is a file: FastAPI writes apps/api/openapi.json from the API's routes and models, and the web app generates its TypeScript types from it into apps/web/lib/api/schema.d.ts. A change to a route regenerates both in the same commit, and CI fails when either is stale.
docs/ holds the plan, with the decisions only the owner makes listed as open items, and one ADR per architecture decision, from hosting to how the blog is published. When an agent writes code here, those files are what it reads first, next to the code.
CI that runs only what changed
Each app has its own GitHub Actions workflow, and each workflow lists the paths it cares about. A change to the admin runs the web checks. A change to the API runs Python's linter, type checker and tests against a real MongoDB. Neither waits for the other.
Before npm run build, the web workflow checks the generated API types, the types and the lint, the contrast of every color pair in both themes, the length of every page's title and description, that every language has the same pages, and that every article's figures are safe.
name: web
on:
pull_request:
paths:
- "apps/web/**"
- "apps/api/openapi.json"
- ".nvmrc"
- ".github/workflows/web.yml"
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/webThe second path links the two: the web workflow also watches the API's contract file, so a change to the API that changes the contract runs both workflows, and the web side checks that its generated types still match. GitHub's rule is that a workflow runs when at least one changed file matches one of its paths.
| The change | web.yml | api.yml |
|---|---|---|
apps/web/** | runs | skipped |
apps/api/**, contract unchanged | skipped | runs |
apps/api/openapi.json | runs | runs |
docs/, infra/, tools/ | skipped | skipped |

One catch
GitHub says a workflow skipped by its path filter leaves its checks "Pending", and a pull request that requires them can't be merged. If you make the checks required, plan for the pull requests that skip them.
One command for the backend
docker compose up --build starts MongoDB, the API and the worker. MongoDB runs as a single-node replica set, because the API writes to several collections in one transaction and a plain MongoDB server can't do that. CI starts it the same way.
services:
mongo:
image: mongo:8
command: ["--replSet", "rs0", "--bind_ip_all"]
api:
build: ./apps/api
ports:
- "8000:8000"
worker:
build: ./apps/api
command: ["python", "-m", "app.worker"]The API and the worker build from the same folder. The worker is the same code started with another command, so a job and the route that queues it can never be on different versions. The web app runs outside Docker, with npm run dev.
Built on every merge, deployed on request
GitHub Actions only validates; nothing in it deploys. Deploys live in infra/gcp, next to the code they ship:
- On every merge to main, Cloud Build builds two images, one from
apps/weband one fromapps/api, tagged with the commit, and deploys nothing. - A second, manual trigger runs the same file and then rolls both out to Cloud Run. Only the owner runs it.
- A small script fills the Cloud Run specs from the trigger's values and refuses to run when one is empty. Secrets never pass through it: the specs name them in Secret Manager.
Both images come from one commit, so the web and the API that go live together were tested together, and we never track which web version works with which API version.
What stays out
The only thing the two apps share is a file.
The repo is a monorepo for this platform only. Every product Farcholabs builds lives in its own repo, with its own pipeline, and stays there. Putting them here would make every product's CI and deploy depend on the company website's.
- Products: each in its own repo.
- Secrets: in Secret Manager.
.envfiles are ignored by git, and each app keeps only the names in its.env.example. - Things that never deploy, like the social video tool, live under
tools/, outside both build contexts.
If you are setting up a product with a web app and an API, our web platforms service starts from this same layout, or you can see what fits first.


