Vercel çoğu proje için doğru cevap: sıfır yapılandırma, otomatik önizleme, küresel ağ. Bu yazı onu bırakmanı önermiyor. Ama bırakman gereken bir an varsa — sunucu maliyeti öngörülemez hâle geldiyse, veritabanı aynı makinede olmalıysa ya da veri yurt içinde kalmak zorundaysa — işin gerçekte neye benzediğini anlatıyor.
Aşağıdakiler student.czay.dev'i kendi sunucumda yayınlarken karşılaştığım
şeyler. Kolay kısmı Docker imajı yazmak; zor kısmı kapsayıcının açılış
sırası.
Dokploy ne yapıyor?
Kendi sunucuna kurduğun bir PaaS: git deposunu bağlıyorsun, her push'ta imajı derleyip yayına alıyor. Arkada Docker ve Traefik var — alan adı yönlendirmesi ve Let's Encrypt sertifikası otomatik. Yani Vercel'in yaptığı işin büyük kısmını tek bir VPS üzerinde yapıyor.
Açıklama: Ne kazanıyorsun, ne kaybediyorsun
Kazanç: Sabit ve öngörülebilir maliyet, veritabanı ile uygulamanın aynı ağda olması, veri konumu üzerinde tam kontrol, çalışma süresi sınırı yok.
Kayıp: Küresel kenar ağı yok, ölçekleme senin işin, sunucunun güncellemesi ve yedeği senin sorumluluğun. Tek bir makine tek bir arıza noktası.
1. output: "standalone"
Bu satır olmadan imajın gereksiz yere yüzlerce megabayt oluyor.
// next.config.ts
const nextConfig = {
output: "standalone" as const,
};Next.js böylece yalnız gerçekten kullanılan node_modules dosyalarını
toplayıp .next/standalone altında kendi kendine çalışan bir klasör üretiyor.
Çalıştırma imajına node_modules'ın tamamını kopyalaman gerekmiyor.
Monorepo'daysan bir satır daha lazım:
import path from "node:path";
const nextConfig = {
output: "standalone" as const,
outputFileTracingRoot: path.join(import.meta.dirname, "../.."),
};Bunu atlarsan imaj derleniyor ama çalışma anında modül bulunamadı hatasıyla patlıyor: pnpm bağımlılıkları depo köküne hoist ediyor, Next.js ise varsayılan olarak yalnız uygulama klasörüne bakıyor.
2. Çok aşamalı Dockerfile
Üç aşama: bağımlılıklar, derleme, çalıştırma.
Örnek: Bağımlılık aşaması
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /repo
FROM base AS deps
# Önce yalnız manifest dosyaları: kaynak kod değiştiğinde bu katman
# önbellekten geliyor ve pnpm install tekrar çalışmıyor.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/student/package.json apps/student/
COPY packages/ui/package.json packages/ui/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter student...İki ayrıntı derleme süresini dakikalardan saniyelere indiriyor:
- Manifest'leri önce kopyala. Docker katman önbelleği dosya değişimine
bakıyor. Kaynak kodu
pnpm install'dan önce kopyalarsan her kod değişikliğinde bütün bağımlılıklar baştan kuruluyor. --mount=type=cacheile pnpm deposunu derlemeler arasında sakla.
3. Çalıştırma imajı: küçük ve yetkisiz
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3003
ENV HOSTNAME=0.0.0.0
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
EXPOSE 3003HOSTNAME=0.0.0.0 şart: varsayılan localhost olursa sunucu yalnız
kapsayıcının içinden erişilebilir oluyor ve Traefik bağlanamıyor. "Kapsayıcı
çalışıyor ama 502 alıyorum" hatasının en sık sebebi bu.
USER nextjs de öyle: kapsayıcıyı root çalıştırmak, bir açıkta zararın
büyüklüğünü belirleyen şey.
Uyarı: Yalnız temiz klonda çıkan hata
public/ klasörüm boştu ve git boş klasörleri izlemiyor. Yerelde klasör
durduğu için her şey çalışıyordu; sunucudaki temiz klonda klasör hiç yoktu
ve yukarıdaki COPY "not found" ile patlıyordu. Derleme aşamasına tek satır:
RUN mkdir -p apps/student/publicYerel ortamın durumu ile temiz klonun durumu arasındaki fark, dağıtımda yakalanan hataların iyi bir kısmını açıklıyor.
4. Asıl mesele: açılış sırası
Uygulamanın bir veritabanı varsa şema göçlerini ne zaman uygulayacaksın?
Derleme sırasında değil. İmaj derlenirken veritabanına erişimin yok ve olmamalı da: aynı imaj başka bir veritabanına da açılabilmeli. Ayrıca Dokploy'un yeniden başlatması, ölçeklemesi ve geri alması hep aynı imajı kullanıyor — şemayı imaja gömemezsin.
Doğrusu bir açılış betiği:
Örnek: docker-entrypoint.sh
#!/bin/sh
set -e
cd /app/apps/student
echo "[giris] şema göçleri uygulanıyor…"
node scripts/migrate.cjs
# Bu ikisi dağıtımı DÜŞÜRMÜYOR
set +e
node scripts/seed-content.cjs --dagitim
node scripts/create-admin.cjs --dagitim
set -e
echo "[giris] sunucu açılıyor…"
cd /app
exec node apps/student/server.jsBuradaki üç karar da bilinçli:
Hangi hata dağıtımı düşürmeli? Şema göçü başarısızsa kapsayıcı hiç açılmamalı — eski şemayla açılan sunucu ilk istekte patlar ve sebebi görünmez olur. İçerik tohumu ya da yönetici hesabı başarısızsa dağıtım devam etmeli: içeriksiz bir panel açılabilir, ikisi de sonradan elle düzeltilebilir.
set -e / set +e bu ayrımı kuran şey: ilk blokta herhangi bir hata
betiği durduruyor, ikinci blokta durdurmuyor.
Dikkat: exec olmadan Dokploy kapsayıcıyı öldürüyor
Son satırdaki exec süslemesi değil. exec olmadan Node, kabuk betiğinin
alt süreci olarak çalışıyor ve Docker'ın gönderdiği SIGTERM kabuğa
gidip Node'a hiç ulaşmıyor.
Sonuç: durdurma isteği zaman aşımına düşüyor, Docker kapsayıcıyı SIGKILL
ile öldürüyor. Her dağıtımda uygulaman düzgün kapanmıyor — açık veritabanı
bağlantıları ve yarım kalan istekler öyle kalıyor.
exec süreci değiştiriyor: Node PID 1 oluyor ve sinyali doğrudan
alıyor.
5. Sağlık denetimi göçleri beklemeli
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3003)+'/giris').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"--start-period=40s kritik: şema göçleri sürerken uygulama henüz cevap
vermiyor. Bu süre olmadan Dokploy kapsayıcıyı "sağlıksız" sayıp yeniden
başlatıyor, göçler yeniden başlıyor ve bir döngüye giriyorsun.
Denetimin gerçek bir sayfayı istemesi de bilinçli: kök adres yönlendirme dönebiliyor ve uygulamanın çalıştığını kanıtlamıyor.
6. Ortam değişkenleri: derleme mi, çalışma anı mı?
Bu ayrım Docker'da çok daha çok canını yakıyor.
Önemli: NEXT_PUBLIC_ derleme anında gömülür
NEXT_PUBLIC_ önekli değişkenler istemci paketine derleme sırasında
yazılıyor. Kapsayıcıya çalışma anında verirsen hiçbir etkisi olmuyor —
imajın içindeki JavaScript'te eski değer duruyor.
Bunları Dokploy'da derleme argümanı (build arg) olarak geçmen gerekiyor. Değiştirdiğinde de yeniden derleme şart; yeniden başlatma yetmiyor.
Sunucu tarafı değişkenleri (DATABASE_URL, API anahtarları) ise tam tersi:
çalışma anında verilmeli ve imaja asla girmemeli.
.dockerignore bu yüzden önemli:
node_modules
**/node_modules
**/.next
**/.turbo
.git
# Ortam dosyaları imaja girmez
.env
.env.*
**/.env
**/.env.*
!**/.env.example.env dosyasını imaja kopyalarsan gizli anahtarların imajın katmanlarında
kalıyor — imajı silsen bile kayıtta duruyor.
Kontrol listesi
Özet: Yayına almadan önce
output: "standalone"açık mı? Monorepo'daysanoutputFileTracingRootda?HOSTNAME=0.0.0.0verildi mi? (Yoksa 502)- Kapsayıcı root olarak mı çalışıyor? (Çalışmamalı)
- Şema göçü açılışta mı, derlemede mi? (Açılışta)
- Son satırda
execvar mı? (Yoksa her dağıtımda SIGKILL) --start-periodgöçler için yeterli mi?NEXT_PUBLIC_*build arg olarak mı geçiyor?.env.dockerignoreiçinde mi?- Veritabanı yedeği kurulu mu? (Vercel'de bu senin işin değildi, artık öyle)
Son madde en çok atlanan. Kendi sunucuna geçmek maliyeti düşürüyor ama sorumluluğu sana devrediyor: güncelleme, yedek ve izleme artık senin işin. Bunu yapacak zamanın yoksa Vercel'de kalmak daha ucuz — para değil, zaman olarak.
İlgili yazı: Turborepo ile monorepo yönetimi
İnteraktif Yol Haritası & Araç