Why I Use Turborepo to Build and Deploy to My Own Server
Cloud

Why I Use Turborepo to Build and Deploy to My Own Server

My repo has four apps and three shared packages, and most commits only touch one of them. Rebuilding everything on every push would mean paying compute for work that didn't need doing — Turborepo's whole job here is refusing to do that.

Published September 6, 202611 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

What does Turborepo actually save when you're deploying to your own server instead of a platform that builds for you?

It saves CI time and, indirectly, protects the server from ever having to run a build at all. Turborepo's task graph hashes each package's inputs so unchanged packages don't rebuild; remote caching makes that true across CI runs, not just one machine; and `--filter` is the flag that turns 'what changed' into an actual command CI can run. None of that touches the VPS — the box never runs `turbo build`, it only ever pulls a finished image, which is the point.

  • A task hash is two hashes: a global hash (lockfile, root config, listed env vars) and a package hash (that package's own files) — if neither changed, the task doesn't rerun
  • Remote caching is a CI and local-dev optimization, not a production one — the server this deploys onto never runs a build, so caching never touches its CPU or RAM
  • --filter, especially combined with the ... operator and a git range, is what turns 'only what changed' from a manual judgment call into a command CI can run unattended
  • The build belongs in CI because a Next.js production build is a short, heavy, unpredictable compute spike, and the box on the other end is a shared 4 vCPU / 8 GB server also running Postgres

Evidence notes

Turborepo caching and hashing

Task fingerprints combine a global hash and a package hash; a match restores cached output files and replays the original terminal log instead of rerunning the task, checked September 2026.

Turborepo --filter reference

Filters accept package names, directory globs, and git specifiers; the ... operator selects dependents (before a name) or dependencies (after a name), and combines with a commit range like --filter=...[HEAD^1], checked September 2026.

Contabo Cloud VPS 4 pricing

4 vCPU, 8 GB RAM, 100 GB SSD, €5.50/month for the first 24 months (promotional, EUR, verified against Contabo's own pricing page), checked September 2026.

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.

  1. 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

  2. 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

  3. 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

  4. 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

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.

What is the build tool actually saving?

Four questions on where monorepo build time goes, and why the answer changes what you deploy.

1 / 4
What decides whether a task can be replayed from cache instead of run?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

TurborepomonorepoCI/CDself-hostingGitHub Actionscloud

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

Git Push to Production: My Self-Hosted Deployment Workflow End to End

No platform button, no black box. A monorepo commit turns into a running container on my own server through Turborepo, GitHub Actions, Docker Hub and Coolify — here's every step, including the ones that broke on me first.

Navigate

My Complete Self-Hosted Stack for SaaS in 2026

Contabo, Coolify, Docker, Cloudflare, Postgres, R2, Resend, Uptime Kuma, Turborepo and Docker Hub. Here's every piece of the stack I actually run, what each one replaced, and why I picked it over the alternatives.

Navigate

Preview Environments Without Vercel: Branch Deploys on Your Own VPS

A preview URL per branch is the one Vercel feature people miss most after leaving. It's buildable on your own server, and it's genuinely harder than Vercel makes it look — mostly because of the database, which nobody's marketing page mentions.

Navigate

Why I Push to Docker Hub Instead of Building on My $10 Server

Build-elsewhere-pull-here isn't a preference, it's the only version of this that doesn't put a compile job in direct competition with the app for the same 8 GB. Here's what that split actually buys, and where a private registry earns its keep instead.

Navigate

How to Modernize a Legacy Monorepo Without Freezing Delivery

Modernization fails the moment it becomes a pause button on shipping. Here's how to fix monorepos, workflows, and service boundaries without freezing delivery to do it.

Navigate

How I Self-Host PostgreSQL for My SaaS (and When I Wouldn't)

Running Postgres in a container is easy. Running it in a way that survives a redeploy, a full disk, and an eventual major-version upgrade is the actual job. Here's the setup, tuned against Postgres's own defaults, and the honest list of where managed wins outright.

Navigate

10 Mistakes That Break a Self-Hosted SaaS

None of these ten show up as a single dramatic outage. They show up as a disk that quietly fills, a rollback that turns out to be impossible, a backup nobody ever restored. Here's the mechanism behind each one, and the fix.

Navigate

The Backup Strategy Every Self-Hosted SaaS Needs (3-2-1, Applied)

3-2-1 is easy to nod along to and easy to get wrong in the specific way that only shows up on the day you need it. Here's what it actually means for one VPS running Postgres and Docker volumes, not the generic version you've already skimmed past twice.

Navigate

My Rule for Deciding What to Self-Host and What to Keep Paying For

Self-hosting everything is a bad idea, and I can point to the exact service where I decided that on purpose. Here's the actual rule I use, not a survey of options — and the one counterexample that explains why the rule exists.

Navigate

The Simplest Production Architecture for a Bootstrapped SaaS

One box, a handful of managed pieces around the edges, and a very short list of things you're not allowed to build yet. Here's the architecture, priced out to $20 a month, and the exact signal that tells you when to add each thing you skipped.

Navigate