Turborepo ile monorepo: dört uygulamayı tek depoda yönetmek

Turborepo ile monorepo: dört uygulamayı tek depoda yönetmek

pnpm workspace + Turborepo kurulumunu gerçek bir depo üzerinden anlatıyorum — 4 uygulama, 4 paket, derlenmeyen paketler, önbelleği sessizce bozan outputs hatası ve monorepo içinden tek uygulamayı Docker'a alma.

Furkan Özay·12 Haziran 2025·7 dk okuma·2 Eyl 2026'de güncellendi

Monorepo anlatan yazıların çoğu boş bir dizinde başlıyor ve "işte bu kadar kolay" diye bitiyor. Zor kısmı orada değil: paylaşılan paketi derleyecek misin derlemeyecek misin, önbelleğin gerçekten çalıştığını nereden bileceksin, ve tek bir uygulamayı Docker'a nasıl alacaksın.

Bu yazı çalışan bir depo üzerinden gidiyor — şu an okuduğun sitenin de içinde olduğu depo. İçinde dört uygulama ve dört paylaşılan paket var, biri Next.js bile değil.

Önce: monorepo gerekli mi?

💡Gerekli olduğu yer

Birden fazla uygulama aynı kodu paylaşıyorsa ve bu kod ikisinde birden değiştiğinde tek bir commit'te değişmesi gerekiyorsa monorepo doğru karar. Tasarım tokenları, ortak bileşenler, ortak tip tanımları, ortak lint kuralları.

⚠️Gerekli olmadığı yer

Uygulamalar birbirinden bağımsızsa monorepo yalnız karmaşıklık ekler: daha uzun CI, daha karmaşık dağıtım, git geçmişinde birbirine karışan işler. "İleride paylaşırız" beklentisiyle monorepo kurmak, ihtiyaç doğmadan bedelini ödemek demek.

Benim durumumda paylaşım gerçekti: czay.dev ile fit.czay.dev aynı tasarım tokenlarını kullanıyor. Bir renk değiştiğinde iki depoda ayrı ayrı güncellemek ve sürümlerin ayrışması kaçınılmazdı.

Depo neye benziyor?

plaintext
czay.dev/
├── apps/
│   ├── web/          → czay.dev          (Next.js)
│   ├── student/      → student.czay.dev  (Next.js + Drizzle + Postgres)
│   ├── fit/          → fit.czay.dev      (Next.js)
│   └── links/        → links.czay.dev    (Astro)
├── packages/
│   ├── ui/           → @czay-dev/ui           (tokenlar + ortak bileşenler)
│   ├── mdx/          → @czay-dev/mdx          (MDX seçenekleri, slug üretimi)
│   ├── tsconfig/     → @czay-dev/tsconfig     (paylaşılan TS yapılandırması)
│   └── eslint-config/→ @czay-dev/eslint-config
├── pnpm-workspace.yaml
├── turbo.json
└── package.json

Bağımlılık grafiği düz: webui + mdx, fitui, student ve links hiçbir paketi kullanmıyor. Bu önemli, çünkü Turborepo'nun bütün hızı bu grafiği bilmesinden geliyor — ui değişmediyse fit'i yeniden derlemesine gerek yok.

links'in Astro olması da kasıtlı bir örnek: Turborepo hangi framework'ü kullandığını umursamıyor. O yalnız görev grafiğini ve önbelleği yönetiyor; derlemeyi her uygulama kendi aracıyla yapıyor.

pnpm workspace kurulumu

İki dosya yetiyor.

🧪pnpm-workspace.yaml
YAML
packages:
  - "apps/*"
  - "packages/*"
🧪Uygulamanın package.json'ı
JSON
{
  "name": "web",
  "dependencies": {
    "@czay-dev/ui": "workspace:*",
    "@czay-dev/mdx": "workspace:*"
  }
}

workspace:* protokolü kritik: pnpm bu bağımlılığı npm'den indirmek yerine depodaki klasöre sembolik bağ kuruyor. Yayınlanan bir pakete geçtiğinde protokol otomatik olarak gerçek sürüm numarasına çevriliyor, ama yayınlamıyorsan hiç düşünmene gerek yok.

Paketleri derlemeyin (çoğu zaman)

Monorepo kurulumlarında en çok zaman kaybettiren tercih bu. İki yol var:

Derlenmiş paket: Paket kendi build adımını çalıştırıp dist/ üretir, uygulamalar derlenmiş çıktıyı kullanır. Avantajı: paket npm'e yayınlanabilir. Bedeli: her değişiklikte paketi yeniden derlemen gerekiyor, dev sırasında izleme (watch) süreçleri kurmak zorundasın ve kaynak haritaları karışıyor.

JIT paket (just-in-time): Paket TypeScript kaynağını doğrudan dışa aktarır, derleme işini onu kullanan uygulama yapar.

🧪packages/ui/package.json
JSON
{
  "name": "@czay-dev/ui",
  "exports": {
    "./styles/globals.css": "./src/styles/globals.css",
    "./components/button": "./src/components/button.tsx",
    "./lib/utils": "./src/lib/utils.ts"
  }
}

Dikkat: dist/ yok, main yok, build betiği yok. .tsx dosyasını doğrudan gösteriyor.

Karşılığında uygulamanın derleyicisine "bu paketi de sen derle" demen gerekiyor:

🧪apps/web/next.config.ts
TypeScript
const nextConfig: NextConfig = {
	transpilePackages: ["@czay-dev/ui", "@czay-dev/mdx"],
};

Bu tercihin kazancı dev sırasında görünüyor: pakette bir dosyayı değiştirdiğinde uygulamanın sıcak yenilemesi anında tepki veriyor — arada derlenip beklenecek bir adım yok. Paketi npm'e yayınlamayacaksan JIT neredeyse her zaman doğru seçim.

turbo.json

🧪turbo.json
JSON
{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "env": ["DB_POSTGRES_URL", "RESEND_API_KEY", "NEXT_PUBLIC_*"]
    },
    "lint": { "dependsOn": ["^lint"] },
    "typecheck": { "dependsOn": ["^typecheck"] },
    "dev": { "cache": false, "persistent": true }
  }
}

Dört ayrıntı:

dependsOn: ["^build"] — Baştaki ^ "bağımlılıklarımın aynı görevi" demek. web'i derlemeden önce ui ve mdx derlenmeli. JIT paketlerde bu görev boş geçiliyor (paketlerin build betiği yok) ama grafiği doğru yazmak, ileride bir paket derlenmeye başladığında hiçbir şeyi değiştirmemek anlamına geliyor.

dev: { cache: false, persistent: true } — Geliştirme sunucusu hiç bitmeyen bir görev. persistent bunu Turborepo'ya söylüyor; cache: false de sonucunu önbelleğe almasını engelliyor. Bunu yazmazsan turbo görevin bitmesini bekleyip takılıyor.

env allowlist — Bu, en sinsi hataların kaynağı. Turborepo önbellek anahtarını girdilerden üretiyor; bir ortam değişkeni bu listede değilse anahtara girmiyor. Yani NEXT_PUBLIC_API_URL'i değiştirip yeniden derlediğinde turbo "girdiler aynı" deyip eski çıktıyı geri veriyor ve uygulaman eski adresle canlıya çıkıyor. Derlemeye giren her değişken bu listede olmalı.

outputs — Ve burada kendi hatamı anlatayım.

🚨Sessizce bozulan önbellek

outputs uzun süre şöyleydi: [".next/**", "!.next/cache/**"]. Dört uygulamanın üçü Next.js olduğu için doğru görünüyordu. Ama links bir Astro uygulaması ve çıktısını dist/ klasörüne yazıyor.

Sonuç: turbo links derlemesini önbelleğe alıyor, ama kaydedecek hiçbir dosya bulamıyor. Sonraki çalıştırmada "cache hit" diyor, günlükleri yeniden oynatıyor ve dist/ klasörü ortada yok. Hata mesajı yok; sadece beklediğin dosyalar yok.

Doğrusu: [".next/**", "!.next/cache/**", "dist/**"]

Buradan çıkan kural: önbellek "çalışıyor gibi" görünebilir. Bir görevi önbelleğe aldığında, çıktı klasörünü silip turbo run build çalıştır ve dosyaların geri geldiğini gözünle doğrula.

Günlük kullanım

Bash
pnpm dev                      # bütün uygulamalar aynı anda
pnpm build                    # grafiğe göre sırayla
pnpm typecheck                # her pakette tsc --noEmit
 
turbo run dev --filter=web    # yalnız czay.dev
turbo run build --filter=web... # web ve bağımlılıkları
turbo run build --filter=...ui  # ui ve ona bağlı olan her şey

--filter sözdizimindeki noktalar yön belirtiyor: web... "web ve bağımlılıkları", ...ui "ui ve ona bağımlı olanlar". İkincisi bir pakette değişiklik yaptığında neyin kırılabileceğini görmenin en hızlı yolu.

Portlar da çakışmasın diye ayrılmış: web 3000, fit 3002, student 3003.

Monorepo'dan tek uygulamayı Docker'a almak

Burası monorepo'nun en çok tökezlenen yeri. student uygulamasını Dokploy ile kendi sunucuma dağıtıyorum; imaj depo kökünden derleniyor çünkü pnpm-lock.yaml ve paylaşılan paketler orada.

1. Next.js'e standalone çıktı ve doğru kök

🧪apps/student/next.config.ts
TypeScript
import path from "node:path";
 
const nextConfig = {
	output: "standalone" as const,
	outputFileTracingRoot: path.join(import.meta.dirname, "../.."),
};

output: "standalone" Next.js'in yalnız gerçekten kullanılan node_modules dosyalarını toplayıp tek bir çalıştırılabilir klasör üretmesini sağlıyor.

outputFileTracingRoot ise monorepo'ya özgü ve atlarsan imaj çalışma anında patlıyor: pnpm bağımlılıkları depo kökündeki node_modules'a hoist ediyor, Next.js varsayılan olarak yalnız uygulama klasörüne bakıyor ve o dosyaları bulamıyor. Kökü göstererek izlemenin yukarı çıkmasını söylüyorsun.

2. Yalnız gereken paketleri kur

🧪Dockerfile — bağımlılık katmanı
dockerfile
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /repo
 
# Önce yalnız manifest dosyaları: kaynak kod değiştiğinde bu katman
# önbellekten gelir, pnpm install tekrar çalışmaz.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/student/package.json apps/student/
COPY packages/ui/package.json packages/ui/
COPY packages/mdx/package.json packages/mdx/
COPY packages/tsconfig/package.json packages/tsconfig/
COPY packages/eslint-config/package.json packages/eslint-config/
 
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --filter student...

İki numara var:

  • Manifest'leri önce kopyalamak. Docker katman önbelleği dosya değişimine bakıyor; kaynak kodu pnpm install'dan sonra kopyalarsan her kod değişikliğinde bağımlılıklar baştan kuruluyor. Bu ayrım derleme süresini dakikalardan saniyelere indiriyor.
  • --filter student... — Yalnız student ve bağımlılıkları kuruluyor. fit'in ve web'in bağımlılıkları imaja hiç girmiyor.

3. Yalnız gerekeni çalıştırma imajına taşı

dockerfile
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
 
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/static ./apps/student/.next/static
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/public ./apps/student/public
 
USER nextjs

Çalıştırma imajında ne pnpm var, ne kaynak ağacı, ne de diğer uygulamalar — yalnız Node ve standalone çıktı.

💬Beni yalnız sunucuda yakalayan hata

public/ klasörü boştu ve git boş klasörleri izlemiyor. Yerelde klasör durduğu için her şey çalışıyordu; temiz bir klonda klasör hiç yoktu ve çalıştırma aşamasındaki COPY "not found" ile patlıyordu. Çözüm derleme adımına bir satır eklemek oldu:

dockerfile
RUN mkdir -p apps/student/public

Monorepo'da bu tür hatalar sık: yerel ortamın durumu ile temiz klonun durumu arasındaki fark, tek uygulamalı depolara göre daha çok yerde ortaya çıkıyor.

Sonuç

📋Özet
  • Monorepo'yu paylaşılan kod varsa kur; "ileride lazım olur" diye kurma
  • workspace:* ile bağla, paketleri derleme — JIT paket + transpilePackages daha hızlı bir geliştirme döngüsü veriyor
  • turbo.json'da env allowlist'i eksikse yanlış önbellek okunur ve uygulaman eski değerlerle canlıya çıkar
  • outputs eksikse önbellek sessizce boş döner — çıktı klasörünü silip doğrula
  • Docker'da kökten derle, manifest'leri önce kopyala, --filter uygulama... ile yalnız gerekeni kur
  • Next.js standalone'da outputFileTracingRoot'u depo köküne ayarla, yoksa imaj çalışma anında patlar

Turborepo'nun asıl değeri hız değil, grafiği doğru bilmek. Hangi uygulamanın hangi pakete bağlı olduğunu bir kez tarif ettiğinde, neyin derleneceğine ve neyin önbellekten geleceğine o karar veriyor. Yanlış tarif edersen — benim outputs hatamda olduğu gibi — sana hiçbir şey söylemeden yanlış cevabı hızlıca veriyor.

Bu yazıyı faydalı bulduysan paylaşabilirsin.

Furkan Özay

Yazılım geliştirici ve eğitmen; Next.js, React ve altyapı üzerine yazıyor.

czay.dev

Bunu da oku

Yorumlar