karawaci.kode

2026-08-25 · 10 min

Turborepo + GitHub Actions Cache: CI Monorepo 3x Lebih Cepat Tanpa Bayar Lebih

Pipeline CI monorepo kami dulu makan 18 menit per push karena rebuild semua package dari nol. Setelah setup Turborepo remote cache dengan GitHub Actions yang benar, angkanya turun ke 5-6 menit — tanpa upgrade runner, tanpa bayar lebih ke Vercel.

Kenapa CI Monorepo Lambat Secara Struktural

Monorepo menyatukan banyak package dalam satu repo. Sisi positifnya jelas: satu PR bisa menyentuh frontend, backend, dan shared library sekaligus, dependency antar-package eksplisit, dan tidak ada koordinasi antar-repo. Masalahnya muncul saat CI.

CI konvensional dengan npm run build atau pnpm build --recursive menjalankan setiap package dari awal setiap kali ada push, terlepas dari package mana yang benar-benar berubah. Untuk monorepo dengan 15 package, satu commit kecil di satu service tetap memicu build 15 package. Waktu bertambah linear dengan jumlah package.

Alternatif yang sering dicoba pertama: filter manual dengan --filter flag di pnpm, atau script yang membandingkan git diff lalu hanya build package yang berubah. Pendekatan ini bekerja di permukaan tapi sering salah: ia tidak otomatis memperhitungkan transitive dependency — kalau package-b berubah dan package-a bergantung ke package-b, package-a juga perlu di-build ulang. Mengelola ini secara manual rapuh.

Turborepo menyelesaikan masalah ini di lapisan yang lebih dalam: ia membangun dependency graph dari workspace configuration, menghitung hash per-task berdasarkan semua input relevan, dan skip task yang hash-nya sudah ada di cache. Ia tahu secara otomatis bahwa build package-a perlu diulang saat package-b berubah.

Anatomi Setup yang Bekerja

Berikut struktur monorepo yang saya pakai sebagai referensi:

apps/
  web/          # Next.js frontend
  api/          # Express API
packages/
  ui/           # Shared components
  utils/        # Shared utilities
  config/       # ESLint, TypeScript configs
turbo.json
package.json    # root (pnpm workspaces)

1. turbo.json — Pipeline Definition

Ini yang paling sering salah dikonfigurasi. Tiap task perlu mendefinisikan dependsOn, inputs, dan outputs secara eksplisit agar hash-nya akurat:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json", "package.json"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "lint": {
      "inputs": ["src/**", "*.ts", ".eslintrc.*"],
      "outputs": []
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "test/**", "vitest.config.*"],
      "outputs": ["coverage/**"],
      "cache": true
    },
    "type-check": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"],
      "outputs": []
    }
  }
}

Penjelasan kunci:

  • "^build" di dependsOn artinya “build semua package yang menjadi dependency dulu sebelum build package ini”. Ini yang menangani transitive dependency secara otomatis.
  • inputs eksplisit mencegah Turborepo memasukkan file yang tidak relevan ke kalkulasi hash. Tanpa ini, perubahan README pun memicu cache miss.
  • "!.next/cache/**" di outputs mengecualikan Next.js internal cache dari Turborepo cache — dua sistem ini tidak perlu saling menimpa.

2. Self-Hosted Remote Cache di Cloudflare R2

Vercel Remote Cache gratis untuk skala kecil, tapi saya lebih suka self-hosted untuk kontrol penuh dan biaya yang lebih prediktabel. Solusi yang saya pakai: ducktors/turborepo-remote-cache — server Express ringan yang mengekspos endpoint kompatibel dengan protokol Turborepo.

Deploy ke Cloudflare Workers atau VPS kecil, sambungkan ke R2 bucket:

# Environment variables untuk cache server
TURBO_TOKEN=your-secret-token-here
STORAGE_PROVIDER=s3
S3_ACCESS_KEY=your-r2-access-key
S3_SECRET_KEY=your-r2-secret-key
S3_ENDPOINT=https://your-account.r2.cloudflarestorage.com
S3_BUCKET=turborepo-cache

Biaya R2 untuk cache artefak build tim 8 engineer: sekitar $0.01-0.05/bulan. Nyaris gratis.

3. GitHub Actions Workflow

Ini workflow lengkap yang kami pakai di production:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, staging]
  pull_request:

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_REMOTE_ONLY: "true"
  TURBO_TEAM: "tangerang-network"

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # Turborepo butuh minimal 2 commit untuk diff

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"  # Cache node_modules via actions/cache

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Lint
        run: pnpm turbo run lint --remote-cache-timeout=120

      - name: Type check
        run: pnpm turbo run type-check

      - name: Build
        run: pnpm turbo run build

      - name: Test
        run: pnpm turbo run test --concurrency=4

Dua detail yang penting di sini:

TURBO_REMOTE_ONLY: "true" — memaksa Turborepo hanya pakai remote cache, tidak membuat local cache di runner. Ini menghindari konflik antara cache di-restore oleh actions/cache (yang berbasis branch) dengan local cache Turborepo yang mungkin stale.

fetch-depth: 2 — Turborepo butuh riwayat git untuk menentukan file yang berubah. Default fetch-depth: 1 (shallow clone) kadang menyebabkan Turborepo tidak bisa menghitung diff dengan benar.

4. Pointing Turborepo ke Cache Server

Di root package.json atau lewat .turbo/config.json:

{
  "teamId": "tangerang-network",
  "apiUrl": "https://your-cache-server.workers.dev"
}

Atau via environment variable di CI (yang saya prefer karena tidak ada URL internal di repo):

env:
  TURBO_API: "https://your-cache-server.workers.dev"
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: "tangerang-network"

Verifikasi Cache Bekerja

Setelah setup, push dua commit berturut-turut tanpa mengubah kode apapun. Lihat output Turborepo di log CI:

Tasks:    12 successful, 12 total
Cached:   12 cached, 12 total    ← semua dari cache
Time:     1.843s >>> FULL TURBO  ← 18 menit jadi 2 detik

Untuk run pertama (cache cold atau setelah ada perubahan), outputnya seperti:

Tasks:    12 successful, 12 total
Cached:   9 cached, 12 total
Time:     4m 32s

Artinya 9 dari 12 task di-skip, hanya 3 yang benar-benar dijalankan. Di monorepo kami yang 15 package, angka khas setelah warm-up: 11-13 task di-cache dari total 15.

Trade-off yang Perlu Diakui

Setup awal lebih kompleks. Membuat turbo.json yang benar membutuhkan pemahaman tentang dependency graph dan apa yang relevan untuk setiap task. Konfigurasi inputs yang salah (terlalu luas atau terlalu sempit) menyebabkan cache miss palsu atau — lebih berbahaya — cache hit palsu saat ada perubahan yang tidak terdeteksi.

Cache invalidation edge case. Turborepo tidak otomatis mengetahui kalau ada file generated di luar inputs yang ikut mempengaruhi output. Misalnya, kalau proses build membaca database schema yang di-generate oleh package lain tapi tidak masuk ke inputs, hasilnya bisa stale. Perlu audit teliti.

Remote cache server adalah single point of failure. Kalau server down, CI tetap jalan — Turborepo fallback ke run tanpa cache — tapi waktu build balik ke 18 menit. Pastikan cache server punya monitoring basic (saya pakai Cloudflare Workers analytics bawaan).

Tidak menggantikan optimasi individual. Turborepo hanya menghemat waktu task yang sudah pernah dijalankan sebelumnya. Kalau build individual package-api sendiri sudah 12 menit, Turborepo tidak mempercepatnya — ia hanya memastikan kita tidak menjalankannya ulang kalau tidak perlu. Optimasi bundle, TypeScript incremental compilation, dan test parallelization tetap perlu dilakukan terpisah.

Catatan Tambahan: turbo run vs nx affected

Nx punya fitur nx affected yang konsepnya mirip — hanya build package yang terpengaruh oleh perubahan. Perbedaan pendekatan: Nx affected berbasis git diff untuk menentukan package mana yang dijalankan, Turborepo menjalankan semua task tapi restore output dari cache untuk yang tidak berubah.

Di praktik, hasil akhirnya mirip, tapi Turborepo lebih toleran terhadap monorepo yang tidak semua package terdaftar rapi di dependency graph — karena ia tetap menjalankan semua task, hanya meng-cache hasilnya. Untuk tim yang baru migrasi ke monorepo dan dependency graph-nya belum sempurna, Turborepo lebih mudah dimulai.

Verdict

Setup Turborepo + remote cache worth it kalau monorepo kamu sudah punya lebih dari 5-6 package dengan build time kolektif lebih dari 10 menit. ROI-nya nyata: engineer tidak menunggu 18 menit per PR, dan biaya Cloudflare R2 untuk cache artefak nyaris nol.

Yang paling penting: investasikan waktu untuk turbo.json yang benar di awal. Konfigurasi inputs/outputs yang salah adalah sumber masalah terbesar — bisa menyebabkan build yang seharusnya berjalan di-skip (karena cache hit palsu) atau sebaliknya selalu dijalankan ulang tanpa alasan. Validasi dengan turbo run build --dry=json sebelum merge ke main.

Untuk monorepo kecil dengan 2-3 package dan total build time di bawah 5 menit, overhead setup ini belum sebanding. Tapi begitu kamu sudah di 8+ package atau tim yang sama repo-nya dipakai lebih dari 5 engineer dengan ritme push tinggi, Turborepo remote cache adalah salah satu investment DevOps paling murah dengan dampak paling langsung yang pernah saya setup.

Ditulis oleh Reza Pradipta