Dev Infra

Git-push deploys for monorepos that don't fight you

Three apps in one repo, three independent deploys, and a CI bill that only charges for what actually changed — here's the wiring.

By Tishan David 6 min read

One repo, one commit, three deploys you didn’t want

I have a monorepo with three sites in it: an Angular storefront, a Next.js portfolio, and a small tools site. They share nothing at runtime and everything in version control. The first deploy pipeline I wrote treated them as one blob — every push rebuilt all three, pushed all three to Dokku, and burned roughly nine minutes of Actions time to ship a one-line copy change in a single app.

That is the default failure mode of a monorepo. The repo wants to be one thing; your deploy targets are several things. The gap between those two facts is where CI minutes go to die. The fix isn’t a monorepo build tool with a graph database and a learning curve — it’s three small, boring mechanisms that compose: path-filtered triggers, git subtree pushes, and per-app build directories on the target. None of them are new. The trick is wiring them so they don’t step on each other.

The problem: change detection, not orchestration

The instinct is to reach for orchestration — a tool that understands the dependency graph and runs the right tasks. For most small-to-mid monorepos that’s over-engineered. What you actually need is change detection: did anything under app-x/ move? If not, do nothing.

GitHub Actions has this built in. A workflow’s push trigger accepts a paths filter, and the workflow simply does not start if the pushed commits touch nothing matching. This is the cheapest possible win because a skipped workflow consumes zero runner minutes — it never schedules. That’s the distinction people miss: a job guarded by an if: condition still spins up a runner to evaluate the condition; a workflow filtered by paths never enters the queue at all.

The caveat worth knowing: paths filtering only applies to push and pull_request events, and it keys off the files in the commit range, not the diff against main. On a feature branch with twenty commits, it’s the union of all of them. For per-job conditionals inside a single workflow — “build the frontend job but skip the backend job” — the built-in filter can’t help, because it operates at the workflow level. That’s the actual reason dorny/paths-filter exists: it computes a boolean per path group at runtime and exposes it as a job output you can gate if: on. If your apps are clean enough to live in separate workflow files, you don’t need it. One workflow per app, each with its own paths block, is simpler and skips earlier.

Here’s the real trigger block from one of my apps:

name: Deploy dev-site to Dokku

on:
  push:
    branches: [main]
    paths:
      - "tishandaviddev/**"
      - ".github/workflows/deploy-dev.yml"
  workflow_dispatch:

concurrency:
  group: deploy-dev-${{ github.ref }}
  cancel-in-progress: false

Two details earn their place. The workflow watches its own file (.github/workflows/deploy-dev.yml) so that editing the pipeline triggers a redeploy — otherwise a CI fix sits dormant until the app code happens to change. And cancel-in-progress: false is deliberate: you don’t want a second push aborting a deploy that’s already pushing a git ref to your server and leaving it half-applied. For test pipelines you’d flip that to true; for anything that mutates a remote, leave it false.

The deep dive: subtree split, then force-push a ref

Path filtering decides whether to deploy. The next problem is what to send. Dokku, like Heroku before it, deploys whatever lands on its master branch and expects a buildable app at the repo root — a Dockerfile or a buildpack-detectable project. Your app isn’t at the root; it’s in tishandaviddev/.

git subtree solves this without a plugin. git subtree split --prefix <dir> walks history and synthesises a commit (and the ancestry behind it) as if that subdirectory had always been the repo root. It prints a SHA. You push that SHA to the target’s master, and Dokku sees a clean root-level app:

SPLIT_SHA="$(git subtree split --prefix tishandaviddev)"
git push --force "dokku@$DOKKU_HOST:$DOKKU_APP" "$SPLIT_SHA:refs/heads/master"

The --force is not a smell here — it’s required. Each split produces a fresh synthetic history that won’t fast-forward over the last one, so the push to the deploy remote is always a force. That’s fine: the Dokku app’s master is a deploy artifact, not a branch anyone collaborates on. The thing to watch is performance. git subtree split recomputes the rewritten history on every run, and on a large repo with deep history that’s measurably slow — it’s the one part of this setup that gets worse over time rather than staying flat. Two mitigations matter: run actions/checkout with fetch-depth: 0 so the full history is present (a shallow clone makes split fail or produce wrong results), and accept that split is the cost you pay for keeping the deploy artifact pristine.

There’s a second route worth naming because it shifts the work to the server. Dokku 0.24+ ships builder:set build-dir, which tells the target which subdirectory to build:

dokku builder:set tddev build-dir tishandaviddev

Now you can push the whole repo and Dokku builds only that folder. It’s less CI machinery — no subtree split — but it means shipping the entire repo over the wire on every deploy and trusting the server-side config. The documented gotcha: if the directory doesn’t exist in the pushed tree, the build fails outright, and setting a custom build-dir discards top-level properties like git.keep-git-dir. I prefer subtree split for the .com and the side projects because the deploy artifact is exactly the app and nothing else, which makes “what’s actually running” trivial to reason about. Build-dir is the right call when subtree split has gotten slow enough to hurt. I’ve written up the full split-versus-build-dir tradeoff with timings in my deployment case studies.

Real-world impact: the bill, and the blast radius

The CI-time win is the obvious one. A one-app change now triggers exactly one workflow instead of three, and the two untouched apps never schedule a runner. Across a normal week where most commits land in a single app, that’s roughly a two-thirds cut in Actions minutes — not because anything got faster, but because most of the work stopped happening.

The less obvious win is blast radius. With independent path-filtered workflows, a broken build in one app cannot fail the deploy of another. Before, a flaky install in the Angular app would red-X the whole pipeline and block a portfolio fix that touched none of it. Now each app’s deploy is its own green or red. That isolation is worth more than the minutes — it’s the difference between “ship the urgent fix now” and “wait for the unrelated thing to go green.” If you’re prototyping this pattern, a throwaway local Dokku box is the fastest way to feel the subtree-versus-build-dir difference before you commit; the local tooling notes on Sydney Dev Hub cover spinning one up.

Why it matters

Monorepo deploys get sold as a tooling problem with a tooling answer — adopt the graph-aware build system, learn its config language, restructure to fit it. For a handful of unrelated apps that’s a tax with no return. Path filters, subtree split, and build-dir are primitives that already ship with the tools you have. They compose into a deploy pipeline where each app is independent, CI only charges for real changes, and a failure in one app stays in that app. The mechanisms are old and unglamorous, which is exactly why they keep working. What I’m testing next: whether dorny/paths-filter with a single matrix-driven workflow beats three separate files once the app count climbs past five — at which point the duplication across workflow files starts to cost more than it saves.