~/zafer
Tüm yazılara dön
5 dk okuma

Turborepo ve Docker ile üretime hazır bir monorepo kurmak

Turborepo ve Docker ile üretime hazır bir monorepo kurmak

Bir web projesi büyüdüğünde yalnızca uygulama kodu çoğalmaz. API, yönetim paneli, paylaşılan tipler, build süreçleri ve dağıtım ayarları da birlikte hareket etmeye başlar. Bunları ayrı depolara bölmek ilk anda kolay görünse de, zamanla sürüm uyumsuzluğu ve tekrarlanan yapılandırma maliyeti üretir.

Bu yazıda kullandığım yapı; tek depoda Next.js tabanlı web sitesi, NestJS API, Vite + React yönetim paneli ve paylaşılan paketlerden oluşuyor. Amaç, yerelde hızlı geliştirme yaparken production'da küçük, güvenli ve birbirini doğru adresleyen container'lar çalıştırmak.

Monorepo'nun sınırlarını baştan çizmek

Dizin yapısı uygulamalar ile paylaşılan kodu birbirinden ayırıyor:

apps/
  web/       # Next.js: herkese açık site
  api/       # NestJS + Prisma: API
  admin/     # Vite + React: yönetim paneli
packages/
  schemas/   # Zod şemaları ve ortak tipler
  api-hooks/ # İstemci tarafı veri alma yardımcıları
  typescript-config/

Bu ayrımın önemli bir faydası var: Post, Project veya ayar verisinin şekli tek bir yerde tanımlanır. API bu şemayla isteği doğrular; admin paneli aynı sözleşmeye göre form gönderir; web uygulaması da gelen cevabı aynı sözleşmeyle kontrol eder. Bir alanı değiştirdiğimizde üç ayrı uygulamada sessizce farklılaşan tipler yerine, derleme aşamasında görünür bir hata alırız.

Workspace'ler pnpm-workspace.yaml ile tanımlanır. Paket yöneticisi, ortak paketi uzak registry'den indirmek yerine workspace:* üzerinden yerel pakete bağlar. Turborepo ise hangi görevin hangisine bağlı olduğunu bilir. Örneğin web uygulaması derlenmeden önce @myself/schemas ve @myself/api-hooks paketlerinin derlenmesi gerekir.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    }
  }
}

Buradaki ^build, mevcut paketin bağımlı olduğu workspace paketlerinin önce build edilmesini söyler. Bu küçük kural, elle komut sıralama ihtiyacını ortadan kaldırır. Yerelde tek komut yeterlidir:

pnpm install
pnpm dev

pnpm dev, turbo run dev çalıştırır ve web, API ile admin süreçlerini birlikte başlatır. Geliştirici tarayıcıda tek bir adres kullanır: Next.js, /api/* isteklerini API'ye; /admin/* isteklerini Vite sunucusuna yönlendirir. Böylece geliştirme ortamı üretime daha çok benzer ve CORS/port karmaşası kullanıcı arayüzüne taşmaz.

Docker build context neden depo kökü olmalı?

Monorepo'da her uygulamanın Dockerfile'ı olsa da docker build komutunun context'i uygulama klasörü olmamalı. API'nin packages/schemas paketine erişmesi gerekir; web uygulaması da schemas ve api-hooks paketlerini kullanır. Context'i apps/api yaptığınızda Docker bu klasörün dışındaki dosyaları göremez.

Bu nedenle image'lar repo kökünden üretilir:

docker build -f apps/api/Dockerfile -t myself-api .
docker build -f apps/web/Dockerfile -t myself-web .

Noktadaki anlam bütün depodur. Dockerfile yalnızca gerçekten gerekli olan manifest ve kaynak klasörlerini COPY eder. Bu, hem doğru workspace bağımlılıklarını sunar hem de Docker cache'ini verimli kullanır.

Çok aşamalı build: build araçları runtime'a taşınmasın

Production image'ında TypeScript derleyicisi, Prisma CLI veya paket yöneticisi bulunmak zorunda değildir. Bunlar yalnızca image üretilirken gerekir. Bu projedeki Dockerfile'lar bu nedenle çok aşamalıdır:

  1. base: Corepack ve pnpm'in bulunduğu build ortamı.
  2. deps: Sadece package.json dosyaları ve lockfile kopyalanır; pnpm install --frozen-lockfile çalışır.
  3. build: Kaynak kodu kopyalanır, ortak paketler ve uygulama derlenir.
  4. runtime: Sadece derlenmiş çıktı ve gerekli production bağımlılıkları taşınır.

Özellikle ikinci aşama önemlidir. Kaynak kodu her değiştiğinde bağımlılık katmanı yeniden kurulmaz; yalnızca paket manifest'i veya lockfile değişirse cache bozulur. CI sürelerinde bu fark hızlıca hissedilir.

API image'ında pnpm deploy --filter=@myself/api --prod /repo/out ile yalnızca API'nin ihtiyaç duyduğu production bağımlılıkları izole bir klasöre alınır. Next.js tarafında ise output: "standalone", sunucunun ihtiyaç duyduğu izlenmiş dosyaları .next/standalone içine yerleştirir. Runtime katmanına .next/static ve public klasörlerini ayrıca kopyalamak gerekir; bunlar standalone çıktısına otomatik dahil edilmez.

Bu iki yaklaşımın ortak sonucu basit: image daha küçük olur, saldırı yüzeyi azalır ve container kendi işini yapmayan araçlarla taşınmaz.

Build-time ve runtime değişkenlerini ayırmak

Container mimarisinde en sık yapılan hatalardan biri bütün environment değişkenlerini aynı anda düşünmektir. Oysa bazı değerler derleme sırasında çıktıya gömülür. Bu projede APP_URL buna örnek: Next.js statik sayfaları canonical URL ve Open Graph metadata'sını build aşamasında üretir. Yanlış APP_URL ile build alırsanız, container çalışıyor olsa bile sayfanın SEO meta verisi yanlış kalır.

Bu yüzden web image'ı build argümanları alır:

web:
  build:
    args:
      APP_URL: ${APP_URL}
      API_ORIGIN: http://api:3901
      ADMIN_ORIGIN: http://admin:3903

Buradaki api ve admin, Docker Compose servis adlarıdır. Bir container içinden localhost yazmak kendi container'ını ifade eder; yanındaki container'ı değil. Web container'ı API'ye http://api:3901, admin'e http://admin:3903 üzerinden ulaşır. Tarayıcı içinse her şey Next.js üzerinden tek origin'de görünür.

Gizli bilgiler — veritabanı bağlantısı, MinIO erişim anahtarları ve admin token'ı — image içine kopyalanmaz. Bunlar .env.production ile container çalışırken enjekte edilir. Bu dosya repoya eklenmez; yalnızca sunucuda bulunur.

Docker Compose ile sistemi kaldırmak

Compose dosyası üç uygulamayı aynı varsayılan ağda birleştirir:

cp .env.production.example .env.production
# değerleri sunucuda doldurun
docker compose --env-file .env.production up -d --build

Bu kurulumda port eşleşmeleri nettir: web 3900, API 3901, admin 3903. Web servisi depends_on ile API ve admin'den sonra başlatılır. restart: unless-stopped ise makine yeniden başladığında veya uygulama beklenmedik şekilde sonlandığında container'ın geri gelmesini sağlar.

Veritabanı ve MinIO Compose dosyasına bilerek dahil edilmez. İkisi de self-hosted altyapı olarak ayrı işletilir ve bağlantı bilgileri environment üzerinden verilir. Bu karar her proje için zorunlu değil; ancak veri katmanını uygulama dağıtımından ayırdığı için yedekleme, yükseltme ve erişim yetkilerini bağımsız yönetmeyi kolaylaştırır.

Yayına çıkmadan önce kontrol listem

Dağıtımı tamamlanmış saymak için up -d çıktısı yeterli değildir. Şunları kontrol ederim:

  • docker compose ps ile üç servisin de running olması
  • docker compose logs --tail=100 api içinde Prisma veya bağlantı hatası olmaması
  • Site, /api/posts ve /admin/ rotalarının ters proxy üzerinden cevap vermesi
  • APP_URL değerinin gerçek HTTPS alan adı olması
  • .env.production dosyasının git tarafından izlenmemesi
  • MariaDB ve MinIO için düzenli, geri yüklemesi test edilmiş yedeklerin bulunması

Sonuç

Turborepo burada sihirli bir araç değil; bağımlılık sırasını ve görevleri görünür kılan bir koordinasyon katmanı. Docker ise build ortamını runtime'dan ayırıp aynı sistemi her sunucuda tekrarlanabilir biçimde çalıştırıyor. Birlikte kullanıldıklarında, üç uygulamalı bir yapıyı tek komutla geliştirebilir ve kontrollü image'larla production'a taşıyabilirsiniz.

İyi bir monorepo'nun asıl ölçütü klasör sayısı değil: ortak sözleşmelerin tek kaynağı olması, build adımlarının deterministik çalışması ve production ortamında her servisin neye bağlandığının açıkça görülebilmesidir.