惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

GbyAI
GbyAI
博客园_首页
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 司徒正美
V
V2EX
Cloudbric
Cloudbric
Hugging Face - Blog
Hugging Face - Blog
腾讯CDC
量子位
博客园 - 三生石上(FineUI控件)
博客园 - 叶小钗
K
Kaspersky official blog
博客园 - 【当耐特】
T
Tenable Blog
L
Lohrmann on Cybersecurity
The Cloudflare Blog
S
Schneier on Security
A
Arctic Wolf
Latest news
Latest news
C
Cyber Attacks, Cyber Crime and Cyber Security
罗磊的独立博客
T
The Exploit Database - CXSecurity.com
Cisco Talos Blog
Cisco Talos Blog
小众软件
小众软件
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
Simon Willison's Weblog
Simon Willison's Weblog
雷峰网
雷峰网
NISL@THU
NISL@THU
人人都是产品经理
人人都是产品经理
月光博客
月光博客
J
Java Code Geeks
V
Visual Studio Blog
S
Security Affairs
博客园 - Franky
T
Tailwind CSS Blog
Apple Machine Learning Research
Apple Machine Learning Research
H
Heimdal Security Blog
有赞技术团队
有赞技术团队
V2EX - 技术
V2EX - 技术
AWS News Blog
AWS News Blog
G
GRAHAM CLULEY
T
Troy Hunt's Blog
SecWiki News
SecWiki News
Spread Privacy
Spread Privacy
宝玉的分享
宝玉的分享
www.infosecurity-magazine.com
www.infosecurity-magazine.com
博客园 - 聂微东

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Inside a 3-app Turborepo monorepo: parallelism, caching, and CI that stays fast
Ajeet Chaula · 2026-05-13 · via DEV Community

Originally published on ajeetchaulagain.com on May 13, 2026.

I've been building ShipWindow for a few months now — deliberately slowly, with a production mindset from day one. No users yet, but real architecture, real CI, infrastructure-as-code.

"Ship fast, refactor later" might be the usual call for a side project like this. But I wanted to try balancing it with a production mindset as I went — still shipping, but thinking a bit further ahead while I did.

The result has been a mix. Some of the production-minded choices have paid off — the CI work I'm about to walk through is one of them. This post is mostly about the part that paid off.

CI is where that mindset showed up early. When the project was in its early phase, I had a minimal workflow validating each PR — lint, type-check, and tests running one after another, sequentially. It was fine for the time. But as the project grew, so did the workflow. As of writing this, the same CI runs across 3 apps and 4 packages in around 2 minutes 30 seconds on most pushes — and it's set up to scale with the project rather than slow down as more code lands.

Three apps, a few shared packages, every push rebuilding everything. You'd expect CI to be slow on a setup like this — that was certainly my starting point. It turned out it doesn't have to be, and the confidence that gives me when merging changes across stacks is honestly the bigger win. I'll walk through how it works, and the decisions that got it there.

The shape of the repo

The high-level structure of the repo looks like this:

shipwindow/
├── apps/
│   ├── web/          # Next.js 16 — authenticated dashboard
│   ├── site/         # Next.js 16 — landing page
│   └── api/          # NestJS — webhook ingestion + auth
├── packages/
│   ├── ui/           # Shared component library (Tailwind v4)
│   ├── shared-types/ # Types shared web ↔ api
│   ├── eslint-config/
│   └── typescript-config/
├── infra/            # AWS CDK stacks
├── turbo.json        # Task graph + cache config
└── package.json      # Yarn workspaces declaration

Enter fullscreen mode Exit fullscreen mode

Three apps live under apps/ — each one is something that gets deployed independently. Four shared packages live under packages/ — these are libraries the apps import from, but nothing ships them on their own. Infrastructure lives in infra/ (stacks written with AWS CDK), kept separate from the application code because it has its own lifecycle and tooling.

Yarn workspaces stitch the whole thing together as one repo — when apps/web imports @shipwindow/ui, it resolves to the local source directly, no publish step in between. Turborepo sits on top of workspaces and orchestrates the task running — knowing what to build in what order, what to cache, and what to skip.

Why a monorepo

When I started thinking about ShipWindow's setup, my first instinct was actually to split into multiple repos. It felt safer — less tooling to figure out, less to think about on day one. I'd worked in a monorepo before and knew the upfront cost: the first few weeks of "what goes where" decisions, the conventions to enforce on a project.

But eventually I was willing to invest that time upfront, knowing it would pay off as the project grew. A few things pushed me in that direction.

Past experience. I'd worked in a monorepo on a previous project and it had served me well. I also remembered the alternative — publish a package, bump the version, install, redeploy, every time anything shared changed. Not something I wanted to live through again on a side project where I wanted to move fast without the overhead of versioning and publishing.

Atomic refactors. Shipping solo, I wanted to move quickly without juggling contracts across repos. When I add a new field to a type in packages/shared-types, both apps/web and apps/api get the change in the same PR. No version bump, no broken contracts in production. One PR, done.

One review, one diff. Every change shows up against the full picture. If a frontend change needs an API endpoint, both land in the same PR — the contract is visible in one diff, not split across two repos with two CI runs.

Shared design tokens stay in sync. packages/ui exports brand colors, components, and CSS tokens. The day I rebrand and edit brand.css, every app updates on the next build. No copy-paste, no drift.

Working in a monorepo, the honest cost is discipline. Without it, everything starts depending on everything, and you stop knowing what's safe to change. I've worked on a monorepo project before, and it's a pattern I've seen play out — especially if you haven't worked in one before and are still getting your head around it. The discipline lives in being deliberate about what belongs in a shared package versus what stays in an app, and honest about what each package is actually responsible for.

Why Turborepo

Once I'd decided on a monorepo, the next question was how to actually run things across it. Yarn workspaces handles the dependency graph — when apps/web imports @shipwindow/ui, it resolves to the local source without any publish step. That part is solved.

But workspaces alone doesn't handle task orchestration — what to build first, what to cache, what to skip. For that, build tools like Lerna, Nx, or Turborepo are generally used. They sit on top of workspaces, not in place of them — you use both.

Turborepo describes itself as "the build system for JavaScript and TypeScript codebases" — and it's maintained by Vercel, which matters here because their free remote cache is one of the reasons I picked it. It's written in Rust, configured through a single turbo.json file, and built around the task graph and caching model that most monorepo tools have converged on.

On a previous project, I worked in a monorepo that used Lerna. I didn't pick it — the project had been set up before I joined — but I lived with it long enough to get a feel for it. Lerna was widely used for JS monorepos at the time.

Turborepo is newer and its ecosystem is still growing, but its focus is squarely on builds and caching — which, for a side project where I don't publish anything externally but care a lot about CI speed, lined up better with what I needed.

Worth flagging: Turborepo's Crafting your repository docs cover structuring a monorepo, managing dependencies, configuring tasks, caching, and more — in real depth. Start there if you're setting up your first one.

The apps and packages

  • apps/web — authenticated dashboard, Next.js 16 App Router
  • apps/site — landing page, statically rendered except for one server action
  • apps/apiNestJS backend, ingests GitHub webhooks, hosts auth endpoints
    Each app runs on a different port in dev, deploys to a different platform, and has its own scaling profile.

  • packages/ui — shared component library built with Tailwind v4, consumed directly from source by both Next.js apps

  • packages/shared-types — single source of truth for the wire shape between web and api: WebhookEvent, PullRequest, Review, etc.

  • packages/eslint-config and packages/typescript-config — shared lint and TS configs; apps extend these so the rules stay consistent
    None of the packages have a publish step. They're consumed through the workspace graph at build time — which is the whole point of using workspaces in the first place.

How Turborepo orchestrates everything

Turborepo's job is to figure out what work needs doing, in what order, and what can be skipped. It does all of that based on a single config file at the root of your repo: turbo.json.

turbo.json is where you describe the task graph — what tasks exist, what they depend on, what their inputs and outputs are. Here's a trimmed version of mine:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**", "generated/**"],
      "env": ["DATABASE_URL", "NEXT_PUBLIC_BACKEND_URL"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "env": ["CI", "RESEND_API_KEY", "VERCEL_ENV"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

A few things in here are doing most of the work:

dependsOn: ["^build"] — the caret means "build all upstream packages first." So when I run turbo run build in apps/web, Turbo first builds packages/ui and packages/shared-types, then apps/web itself. I never order tasks manually. Turbo walks the workspace graph for me.

The env array — this is the easy one to get wrong. Any environment variable a task reads but doesn't declare here gets silently ignored when Turbo computes the cache key.

This env array was something that troubled me initially. The cache had quietly lied to me more than once before I understood what was happening — CI coming back green when it shouldn't have, stale results being served without anything flagging it.

The fix: list every env var your task actually reads to make a cache reliable and predictable.

Running apps locally

To run every app, it's one command:

yarn dev                # all apps in parallel
yarn dev --filter=web   # just one

Enter fullscreen mode Exit fullscreen mode

The hot reload across packages is what makes the monorepo feel worth it day to day. Change a button in packages/ui, and the apps using it update immediately — no build step, no npm link, no publish. It just works.

Under the hood, Yarn workspaces points each app at the package's actual folder on disk rather than a copy. So edits in packages/ui count the same as edits inside the app — the dev server picks them up like any other file change.

If you've worked in a multi-repo setup before, this is the part that quietly justifies the rest of the complexity.

What this looks like in CI: parallelism and caching

Three apps, four packages, lint and type-check and tests on every push. Initially, my CI ran these sequentially — that's just how I'd set it up. While I was just starting out and in the early phase of adding features, it didn't matter much. The project was small, and sequential was simpler to reason about.

The shift to parallel jobs wasn't really about speed. Sequential CI was still fast enough at three apps and a handful of tests. The real reason was headroom for later: as features and tests grow, sequential adds up. Splitting tasks into parallel jobs also keeps the workflow predictable, and lets Turborepo handle the actual task ordering inside each one. That's cleaner than chaining steps in YAML and hoping the order holds.

In practice, that means lint, type-check, tests, and cdk synth are each their own GitHub Actions job, depending on a shared install step. Once install finishes, all four run in parallel.

GitHub Actions workflow showing install job followed by lint, type-check, test, and cdk-synth jobs running in parallel

The four jobs after install all run side by side, so the pipeline finishes in roughly the time of the longest job, not the sum.

Inside each of those jobs is where Turborepo does its work. In my setup, lint, type-check, and tests all dependsOn: ["^build"] — meaning each of them needs the upstream packages built before it can run. I don't have to think about that. Turborepo walks the task graph, builds whatever's needed (or pulls it from the cache if it's already built), then runs lint, type-check, and tests on top. It figures out the right order so I don't have to script it.

On the caching side, that's where the actual savings come from. Turborepo tracks each task's inputs — source files, env vars, dependencies — and skips work it's seen before with identical inputs. The Vercel Remote Cache makes this work across CI. Setup is two environment variables in your workflow: TURBO_TOKEN (a Vercel access token) and TURBO_TEAM (your team slug). When npx turbo run <task> runs, Turbo reads those env vars from the environment and connects to the remote cache automatically. No other config needed.

Here's what one of the parallel jobs looks like in my workflow — the lint job, for example:

# .github/workflows/pr-validate.yml

# ... yarn install job...

lint:
  name: Lint
  needs: install
  runs-on: ubuntu-latest
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}
  steps:
    - uses: actions/checkout@v4

    - uses: actions/setup-node@v4
      with:
        node-version: 24

    - run: npx turbo run lint

# ... type-check, test and cdk-synth jobs ...

Enter fullscreen mode Exit fullscreen mode

The structure is the same for each parallel job — needs: install, then run a single Turbo task. The TURBO_TOKEN and TURBO_TEAM env vars are what let Turbo talk to the remote cache; without them, the job would run everything from scratch.

For a typical PR in ShipWindow — say I add a new type GitHubEvent in packages/shared-types and update apps/web to render it — Turborepo realizes apps/site isn't affected. Its build is already cached, its lint result is already cached, its tests haven't changed. So those get pulled from the cache instead of being rerun. Only the work that actually changed runs.

For a solo project, the cache is already paying off — I can see most PRs hitting at least some cached tasks. With more contributors, the win gets bigger: builds run for one person's PR can be reused on someone else's, so the same work doesn't happen twice. Turborepo's job is to skip work that doesn't need redoing and to handle task order. GitHub Actions runs the jobs in parallel. Together, the CI stays relatively quick.

What I'd improve

One thing on the list for later: affected-only builds with --filter. Turborepo supports running tasks only for the workspaces affected by changed files — turbo run build --filter=...^... skips unaffected workspaces entirely instead of having Turbo do a cache lookup on each one.

I'd be happy to explore it as the project grows. For now, caching is doing the heavy lifting and the savings would be marginal — it earns its place once the workspace is big enough that even the lookups start to add up.

What I learned

Most of what I've described here is roughly what I believe most Turborepo projects converge on. The patterns aren't novel — they just take a while to feel obvious.

The bigger thing I'm taking from a few months of this: the engineering decisions that matter on a side project aren't really about being objectively right. They're about giving yourself room to keep going — and to keep improving as you do. Monorepo over multi-repo, Turborepo over rolling my own scripts, caching over hoping CI stays fast — none of these are universal answers. They were the ones that kept me building features instead of fighting tooling.

If you're curious what I'm building, take a look at ShipWindow. Still pre-launch, waitlist's open if you want to be one of the first to try it.


If you found this useful, more posts at ajeetchaulagain.com on side projects and software.