Every build your platform runs starts with the same invisible step: getting the tenant's source onto a builder. Nobody benchmarks it, nobody puts it on the pricing page, and on a repo with real history it is routinely the single slowest thing that happens before a compiler starts. GitHub's own data-driven study of cloning behaviors measured four strategies against three real repositories, and the spread is not subtle:
| Repo (size, commits) | Full clone | Shallow --depth=1 | Treeless --filter=tree:0 | Blobless --filter=blob:none |
|---|---|---|---|---|
jquery/jquery (40 MB, 7.9k) | 2.0 s | 0.3 s | 0.9 s | 0.9 s |
apple/swift (750 MB, 132k) | 50 s | 8 s | 16 s | 22 s |
torvalds/linux (4 GB, 969k) | 5.0 min | 1.2 min | 2.4 min | 3.0 min |
Shallow wins the clone, every time, by 4-6x. Which is exactly why actions/checkout defaults to depth=1, why GitLab CI ships GIT_DEPTH=20, and why Vercel hard-codes --depth=2 with no way to change it.
It is also why so many builds mysteriously produce version 0.1.dev1 instead of 2.7.3.
The clone table above is the number everyone quotes. The rest of this post is the three numbers that decide whether shallow is actually the right default for a platform that runs someone else's builds — the cost that arrives after the clone, the fraction of builds that need history before shallow stops paying, and what the PaaSes you are benchmarking against actually do.
What each strategy actually omits
Git has four ways to not download your whole repository, and they fail differently because they cut along different axes.
- Full clone — every commit, tree, and blob in history. Self-contained; nothing is ever fetched again.
- Shallow (
--depth=N) — the last N commits, with the commit graph deliberately truncated by a.git/shallowgrafts file. Cuts along history. Also fetches no tags by default when combined with--single-branch. - Treeless (
--filter=tree:0) — every commit object, but no trees and no blobs. Cuts along directory structure. You get a completegit log; anything that touches a file path triggers a network round trip. - Blobless (
--filter=blob:none) — every commit and every tree, no file contents. Cuts along file contents.git log,git log --raw, and pathspec filtering all work offline; reading an actual file's historical content downloads it on demand.
The critical structural difference: partial clones are complete but lazy — the repository knows about every object and fetches what it lacks from a promisor remote. A shallow clone is incomplete and lying — it has a fabricated view of history where certain commits have no parents. Every subsequent operation inherits that lie.
The cost that shows up after the clone
A build is not one git clone. It is a clone, a checkout, possibly a fetch, and whatever the app's own tooling does with .git. GitHub's study measured those too, and the ranking inverts.
Operation (on torvalds/linux) | Full | Blobless | Treeless | Shallow |
|---|---|---|---|---|
| Clone | 5.0 min | 3.0 min | 2.4 min | 1.2 min |
git reset --hard | 250 ms | 500 ms | 1,250 ms | 250 ms |
Subsequent git fetch (server CPU) | 350 ms | ~350 ms | ~350 ms | ~6 s |
That last row is the one to internalize. Fetching into a shallow clone costs roughly 25x the server-side CPU of an ordinary fetch — 6 seconds versus 350 milliseconds — because the server has to walk the real history to work out what the client's fabricated history is missing. GitHub's guidance is blunt about it: "Never fetch from a shallow clone."
For a platform, that is not an abstract warning. It is a description of the incremental-build optimization you will eventually want to ship. The moment you cache a workspace between builds and fetch new commits into it, a shallow cache turns your cheapest strategy into your most expensive one — and the cost lands on your Git host's CPU, not the builder's, so it shows up as mysterious latency rather than a line on a graph.
Treeless is the mirror-image trap: fastest partial clone, but git reset --hard gets 5x slower because every path resolution is a network call. If your build path does a clone and then a checkout of a specific SHA — which every PaaS build does — treeless spends its winnings immediately.
Where shallow bites back in a build
The failure mode is rarely an error. It is a wrong answer, produced confidently.
git describeand everything built on it. A shallow clone with--single-branchfetches no tags.setuptools_scm, which derives a Python package version fromgit describe, silently reports a garbage version rather than failing — your artifact ships as0.1.dev1+g4f2a91cand nothing in the log says why.- Release automation.
semantic-releasewalks back to the last release tag to decide the next version. No tags, no prior release, so every build looks like1.0.0. - Monorepo affected-graphs. Nx, Turborepo, and Lerna compute "what changed since base" by diffing against a merge base. Depth 1 has no merge base; depth 20 has one only if the branch is short.
- Static site generators. VuePress and Docusaurus derive per-page "last updated" timestamps from
git logon each file. On Render, users reported this breaking and then discovered thatgit fetch --unshallowinside the build command silently does nothing. - Submodules.
--shallow-submodulescompounds the problem one level down, and a submodule pinned to a commit older than the shallow window fails outright.
None of these are exotic. They are the default tooling of Python packaging, JS release management, and monorepos — three of the most common shapes of tenant repo a PaaS will ever see.
What the platforms actually do
Here is the part that reframes the question. Sort real platforms by how they get the source, and they fall into two groups that face completely different problems.
Group 1 — the platform owns the receive path. The tenant runs git push at the platform itself.
| Platform | Source acquisition | History available | Shallow policy |
|---|---|---|---|
| Heroku | Tenant pushes to git.heroku.com; a pre-receive hook builds | Full | Rejects pushes originating from a shallow clone |
| Dokku | Tenant pushes to your own box; pre-receive hook runs the buildpack | Full | Pushing from a shallow clone is unsupported |
These platforms have no clone strategy because they never clone. The objects arrive in the push. Note that both of them went out of their way to reject shallow sources — Heroku's changelog says it "will no longer attempt to build projects unless the Git history is complete," which broke every team whose Bitbucket Pipeline used the default clone depth of 50.
Group 2 — the platform pulls from a forge. This is where a clone strategy has to be chosen, and everyone chose differently.
| Platform | Clone strategy | Overridable? |
|---|---|---|
| Render | git fetch --depth=1 | No. --unshallow in the build command silently fails |
| Vercel | Custom clone at --depth=2, no remote configured | Not officially; an undocumented VERCEL_DEEP_CLONE=true exists |
| Netlify | Full clone into a cached workspace | GIT_DEPTH appears to be a no-op |
GitHub Actions (actions/checkout) | --depth=1, no tags | Yes — fetch-depth: 0 |
| GitLab CI | GIT_DEPTH=20 (max 1000) | Yes — empty or 0 for full |
| Bitbucket Pipelines | depth 50 | Yes — clone: depth: |
Railway belongs in this group and gets its own line, because it is the one platform whose answer you cannot look up: Railpack, its BuildKit-based successor to Nixpacks, documents builders, caching, and image size in detail and says nothing at all about how the repository is fetched. That is its own kind of finding — you cannot reason about a build step your platform declines to describe, and "unknown depth" is the worst input to a version-derivation bug hunt at 2 a.m.
Two things stand out. First, not one of them uses a partial clone. The entire industry defaults to the strategy Git's own maintainers describe as the one you must never fetch from. Second, the managed PaaSes are the least configurable: the CI vendors all give you a knob, while Render and Vercel give you a number you cannot change and a workaround that doesn't work.
Depot's telemetry says 98.5% of organizations never touch the default checkout settings and only 1.47% use sparse-checkout — and that tuning the fetch (sparse checkout plus a partial-clone filter plus compression settings) took one repo's checkout from 60 s to 2 s, a 96.6% cut. The defaults are load-bearing precisely because almost nobody changes them.
What a self-hosted build path should copy
Start with the economics, because they set the size of the prize. Take an apple/swift-scale repo — 750 MB, 132k commits — and a platform running 200 builds a day against it:
| Strategy | Per build | Per day | Per 30 days |
|---|---|---|---|
| Full clone | 50 s | 2 h 46 m | 83 h |
| Blobless | 22 s | 1 h 13 m | 37 h |
| Shallow | 8 s | 27 m | 13 h |
Shallow saves 70 hours of builder wall time a month over a full clone. That is real money on owned hardware and real queue depth for tenants.
Now the sensitivity that decides the default. Assume some fraction f of builds discover they need history and have to unshallow, which costs at least a full clone (50 s) on top of the 8 s already spent:
- Shallow's average cost is
8 + 58fseconds. - Blobless is a flat 22 s.
- Break-even: f = 24%.
So shallow beats blobless right up until about one build in four needs real history — and past 72%, shallow is worse than just doing a full clone. If your tenants are a random sample of GitHub, the share of repos running setuptools_scm, semantic-release, Nx, or a git-timestamped static site is comfortably inside that window. Blobless is the better default not because it is faster, but because it is never catastrophically wrong.
And the honest counterpoint: on jquery-scale repos, the entire debate is worth 1.7 seconds. If your tenants are mostly sub-100 MB app repos, pick blobless, stop thinking about it, and go optimize the dependency-install step instead — that is where the minutes are.
The concrete recipe:
# Default: complete history, no file contents, no tag noise.
git clone --filter=blob:none --single-branch --branch "$BRANCH" \
"$REPO_URL" /workspace
git -C /workspace checkout --detach "$COMMIT_SHA"At scale, do not clone at all — keep a per-repo bare mirror on the builder's cache volume and hand each build a worktree:
# Once per repo, on the cache volume:
git clone --mirror --filter=blob:none "$REPO_URL" /cache/$REPO_ID.git
# Every build after that:
git -C /cache/$REPO_ID.git fetch --prune origin
git -C /cache/$REPO_ID.git worktree add --detach /workspace "$COMMIT_SHA"This is the strategy a shallow cache forecloses: a normal fetch into a complete repository costs the ~350 ms from the table above, not the ~6 s a shallow fetch costs. The trade-off is honest — lazy blob fetches during worktree add add a round trip on cold paths, and the cache volume grows — but it is the only shape that gets cheaper the more builds you run.
Three more rules worth stealing:
- Never
--depth. If you need to bound the download, bound it with--filter=blob:noneor--filter=blob:limit=1m, which keep the commit graph intact and stay repairable in place withgit backfill. A shallow clone is not repairable in place — you pay a full clone to undo it. - Inject build metadata; don't make the build read
.git. Heroku'sSOURCE_VERSIONis the right pattern: hand the buildpack the commit SHA, ref, and message as environment variables. Most of the tooling that "needs git history" actually needs one string, and giving it that string decouples your clone strategy from your tenants' build scripts. - Ship the knob anyway. Detection heuristics — scanning
pyproject.tomlforsetuptools_scm,package.jsonforsemantic-release,nx.jsonfor an affected graph — will catch most cases and never all of them. A per-appfetch: full | bloblesssetting costs a day to build and saves a support queue. This is precisely the escape hatch Render and Vercel don't have.
The design lesson underneath
The reason Heroku and Dokku have no clone strategy is not that they solved the problem. It is that owning the receive path makes the problem disappear: when the tenant pushes to you, you keep a bare repository per app that accumulates history naturally, and every build is a worktree checkout against a warm local object store. No forge round trip, no depth flag, no --unshallow that silently does nothing.
That is the model worth copying, and it generalizes past Git. A platform that pulls artifacts from someone else's system inherits that system's latency on every single operation, and starts optimizing by guessing what it can safely skip. A platform that owns the store optimizes by keeping things warm. The first approach produces --depth=1 and a decade of mysterious version strings. The second produces a fetch that gets faster as the cache fills.
The checkout is 8 seconds or 50 seconds or 5 minutes depending on a flag nobody reviews, on a code path nobody profiles, running on every build forever. That is worth twenty minutes of attention exactly once.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with AI agents as first-class operators. Star the repo on GitHub or deploy your first app today.



