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.
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?
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ı.
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?
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.jsonBağımlılık grafiği düz: web → ui + mdx, fit → ui, 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.
packages:
- "apps/*"
- "packages/*"{
"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.
{
"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:
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
{
"$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.
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
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
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
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ızstudentve bağımlılıkları kuruluyor.fit'in veweb'in bağımlılıkları imaja hiç girmiyor.
3. Yalnız gerekeni çalıştırma imajına taşı
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ı.
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:
RUN mkdir -p apps/student/publicMonorepo'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ç
- Monorepo'yu paylaşılan kod varsa kur; "ileride lazım olur" diye kurma
workspace:*ile bağla, paketleri derleme — JIT paket +transpilePackagesdaha hızlı bir geliştirme döngüsü veriyorturbo.json'daenvallowlist'i eksikse yanlış önbellek okunur ve uygulaman eski değerlerle canlıya çıkaroutputseksikse ö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.