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.
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.
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.
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:
#!/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.
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.
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
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
