My repo has four apps and three shared packages in it, and most commits only touch one of them. If I let CI rebuild everything on every push, I'd be paying compute for three apps and two packages that didn't change, on every single commit, forever. Turborepo's whole job in this pipeline is refusing to do that — and doing it in a way that doesn't depend on me remembering which apps import which packages, because that's exactly the kind of bookkeeping a person eventually gets wrong.
None of this is about the tool being clever for its own sake. A monorepo makes sharing code between the portfolio, a couple of small SaaS apps, and a CMS trivial — one `tsconfig`, one lint config, one place to update a shared component. The cost of that convenience is that a naive CI setup treats every commit as a reason to rebuild the entire thing, and on a five-stage pipeline that ends in a single small VPS, that cost compounds in a way it wouldn't on a platform doing the build for you somewhere you never see the bill. The revenue-side version of self hosting is something XenGrowth, who work on the commercial side of this writes about in more operational detail than I go into here.
What does a task graph actually give you that a plain monorepo doesn't?
A monorepo without a task runner is just a folder with more package.json files in it. `npm run build` at the root either builds everything sequentially, or you write your own script to work out what changed — and that script ends up being a worse, unmaintained version of what Turborepo already does. Turborepo builds a dependency graph from the workspaces, then fingerprints every task as two combined hashes: a global hash (the lockfile, root-level config, and any environment variables you've explicitly listed in `globalEnv`) and a package hash (that package's own source files, its `package.json`, its own `turbo.json` settings). If neither hash has changed since the last run, the task simply doesn't run again.
What gets restored on a hit is two things: the output files defined under that task's `outputs` key, and the terminal log from the run that produced them, replayed as if it just happened. That second part is easy to skip past, but it's the reason a cache hit doesn't feel like a black box swallowing a step — a green check in CI still shows the exact build log from whenever this input combination last succeeded, even if that was three days and eleven unrelated commits ago.
Why doesn't remote caching help the server this deploys onto?
This is the part people get backwards. Remote caching is a CI and local-development optimization — it has nothing to do with the box the app eventually runs on, because that box never runs `turbo build` at all. In this pipeline the VPS pulls a finished Docker image; the build step, cache or no cache, happens entirely inside GitHub Actions before an image even exists. Turning remote caching on saves CI minutes, and it saves a colleague's laptop from rebuilding a shared package that already built somewhere else five minutes earlier. It does not touch the server's CPU or RAM, because the server was never part of that loop to begin with. The XenGrowth resource library approaches this from the the operations side of this side.
Setting it up is genuinely small — `turbo login` and `turbo link` once, then a token pair stored as repository secrets so every CI run authenticates the same way a local machine would. Skip it and nothing breaks; every CI run just quietly reverts to rebuilding from scratch, which costs minutes, not correctness.
A TURBO_TOKEN and TURBO_TEAM pair set as GitHub Actions repository secrets, never committed to the repo itself
turbo login and turbo link run once locally to connect the workspace to a remote cache provider before CI can use it
Every task's outputs key actually listing what gets produced — an empty or wrong outputs array means a 'hit' that restores nothing useful
The same Node version and lockfile across every machine writing to the cache, since the lockfile's contents are part of the global hash
Do environment variables bust the cache too, or only files?
Files aren't the only input that can make a cached build wrong. If a task's output depends on an environment variable — an API base URL baked in at build time, say — and that variable isn't declared anywhere Turborepo can see it, changing it in CI doesn't invalidate the cache. The hash didn't change, so Turborepo hands back yesterday's build, built against yesterday's value, and calls it correct. This is exactly the `NEXT_PUBLIC_` trap in a different outfit: build-time env vars get compiled into the output, and a cache system that doesn't know a variable exists can't know it changed.
The fix is naming the variable in `globalEnv` (affects every task's hash) or a task's own `env` key (affects just that task) in `turbo.json`. Once it's declared, a changed value is treated exactly like a changed file — it busts the hash and the task reruns. Left undeclared, it's invisible to the one system whose entire job is knowing what changed.
Isn't this just a wrapper around a bash script that greps git diff?
Sort of, and the gap between the two is the whole argument. A hand-rolled version has to reimplement the dependency graph by hand — if package A imports package B, the script needs to know a change in B means A also needs rebuilding, and it needs to keep knowing that correctly as the graph grows and packages get added, removed, and re-wired. That's a maintenance burden with no test suite of its own, running a comparison against raw file paths rather than content hashes, which is a cruder question than 'did this input actually change.' Turborepo's graph is derived directly from the workspace's own `package.json` dependencies, so it can't quietly drift out of sync with the code the way a script maintained by hand eventually will — the graph and the codebase are, by construction, the same source of truth. XenGrowth on governed AI marketing workflows works through AI agents and marketing automation in more operational detail.
How does --filter stop CI from touching apps that didn't change?
--filter is the flag doing the actual work of 'only build what changed.' Pointed at a package name it runs the task for just that package: `turbo run build --filter=web`. Pointed at a directory glob it runs the task for every package under that path. The operator that makes it genuinely useful in CI is `...`: placed before a name, it pulls in every package that depends on it, so a shared UI package changing correctly rebuilds every app that imports it; placed after a name, it pulls in that package's own dependencies instead. Combined with a git range — `--filter=...[HEAD^1]` — it becomes 'everything that changed since the last commit, plus everything downstream of that change,' which is the actual question CI needs answered on every push, not 'rebuild everything' and not 'rebuild nothing.'
Filter | What it selects | When I'd reach for it |
|---|---|---|
--filter=web | Just the web app package | Force a rebuild of one app regardless of what changed elsewhere |
--filter=./apps/* | Every package under apps/ | Run a task across every deployable app, skip internal-only libraries |
--filter=...[HEAD^1] | Packages changed since the last commit, plus everything downstream of them | The actual CI query on every push — what needs rebuilding right now |
--filter=!./apps/admin | Everything except one named package | Exclude an app mid-migration from a repo-wide task |
Why does the build happen in CI instead of on the €5.50/month box it deploys to?
Because a Next.js production build is a short, heavy, bursty compute job, and the server this pipeline deploys onto is a Contabo Cloud VPS 4 — 4 vCPU, 8 GB RAM, 100 GB SSD, shared between the running app and Postgres, €5.50/month for the first 24 months as of September 2026 (verified against Contabo's own pricing page, not a third-party review site). Compiling a full app on that box means CPU pinned at or near 100% for however long the build takes, right alongside whatever's already serving traffic and whatever the database happens to be doing mid-query. GitHub Actions runners exist precisely to absorb that spike somewhere that isn't answering anyone's requests.
What actually crosses from CI to the server afterward is small: a finished container image, pulled from Docker Hub, nothing else. The server's job shrinks to 'pull an image, start a container, run a health check' — a flat, predictable resource cost — instead of 'compile an entire application,' which is neither flat nor predictable. What that spike can turn into if it lands on a server already sitting close to its limit isits own failure mode, and it's worth understanding before assuming a small box can just absorb an occasional build. There is a longer treatment of AI search, GEO and discovery in XenGrowth on building one SEO and GEO content system.
Build on the VPS | Build in CI, deploy the image | |
|---|---|---|
CPU cost at deploy time | Full build competes with the app and Postgres for the same 4 vCPUs | None — the server only pulls and starts an already-built container |
RAM risk | A memory-heavy compile step can starve or crash whatever else is running | None; RAM is spent only on running the finished app |
Build cache reuse | Local disk only, gone the moment the box is rebuilt, resized, or replaced | Shared across every CI run and contributor through remote caching |
Failure blast radius | A crashing build can take the live app down with it | A failed build never reaches the server; the last good image keeps serving traffic |
Where does this actually go wrong?
Not by Turborepo lying to you — by an `outputs` key that doesn't match what the build actually produces. A 'hit' restores an empty folder, and the deploy fails two steps later for a reason that looks completely unrelated to caching. The fix is boring: every task that writes files needs an `outputs` entry listing them, and a task whose real effect is a side effect — deploying, publishing, notifying — needs `cache: false` in `turbo.json` so Turborepo doesn't try to skip it based on a hash match it shouldn't be trusting.
The other place this surprises people is a wide-diff commit — a lockfile bump, say — invalidating everything downstream at once. That's not a bug to work around. The lockfile genuinely changed the global hash, and rebuilding every package correctly is the graph doing exactly its job, even on the one day it's annoying to watch happen.
Declare every task's outputs explicitly, and mark side-effect-only tasks cache: false so a hash match never skips something that needed to actually run
Put every build-time environment variable a task reads into globalEnv or that task's own env key, or the cache will happily serve a build made against a stale value
Set TURBO_TOKEN and TURBO_TEAM as CI secrets before assuming remote caching is doing anything — a missing token fails silently back to local-only caching, not with an error
Treat a full-repo invalidation from a lockfile change as a signal, not a bug — it's the graph telling you the blast radius of that change was genuinely repo-wide
A monorepo without a task graph is just several projects zipped into one repo. The graph is the only reason building one of them correctly changes what happens to the others, instead of accidentally.
Where this fits in the rest of the pipeline
This is one stage of a five-stage pipeline — Turborepo decides what needs building, but what happens to the resulting image is a separate set of decisions with its own trade-offs. The full pipeline, git push to production covers where this step sits alongside the registry, the deploy step and rollback. What happens to the image once it leaves CI is its own post: why I push to Docker Hub instead of building on the server. And if the box on the other end of all this still needs sizing before any of it matters, vCPU or RAM? How to size a VPS is the place to start.
Further reading from XenGrowth
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
Where this work meets go-to-market
the XenGrowth practice covers the go-to-market side of self hosting, which this piece deliberately leaves alone.
Four questions on where monorepo build time goes, and why the answer changes what you deploy.











