Sunucuda Build Alma: GitHub Actions ile Docker Deploy
Kendi sunucusunda uygulama çalıştıran çoğu kişinin güncelleme akışı aynıdır: sunucuya bağlan, git pull, docker compose up -d --build. AWS Lightsail'de Django deploy yazısında ben de bu akışı anlatmıştım, çünkü tek başına çalışan bir sunucu için en kısa yol bu.
Sorun şu ki bu akış proje büyüdükçe sessizce bozuluyor. Bozulduğunda da "sunucu çöktü" gibi görünmüyor; sadece deploy'lar uzuyor, ara sıra yarıda kalıyor ve bir gün "dünkü hâline dön" demen gerektiğinde yapacak bir şeyin olmuyor.
Bu yazı o akışın yerine ne koyduğumu anlatıyor: imajı sunucuda değil CI'da üret, sunucuya sadece indirt.
Sunucuda build almanın üç bedeli
Birincisi bellek. docker compose up -d --build komutunu çalıştırdığında build, o anda çalışan konteynerlerle aynı RAM'i paylaşır. Next.js derlemesi bellekte tutumlu bir iş değildir; veritabanı ve uygulama zaten ayaktayken küçük bir sunucuda derleme swap'e düşer, kötü durumda OOM killer tarafından öldürülür. Dışarıdan görünen tek şey yarıda kesilmiş bir deploy ve hâlâ eski sürümü servis eden bir site olur.
İkincisi süre. Build sunucuda çalıştığı sürece CPU ve disk o işe gider. Site ayakta kalır ama yavaşlar. Üstelik bu maliyeti her deploy'da yeniden ödersin, çünkü tek sunucudaki build önbelleği CI'daki kadar güvenilir değildir; imaj temizliği yapan bir prune komutu önbelleği de götürür.
Üçüncüsü ve en önemlisi: geri dönemezsin. --build ile üretilen imajın anlamlı bir etiketi yoktur. Bir saat sonra bir hata fark ettiğinde "önceki imaja dön" diyemezsin; git'i geri alıp yeniden build almak zorunda kalırsın. Yani hatayı düzeltme süren, build süren kadardır. Oysa geri alma işleminin saniyeler sürmesi gerekir.
Üçünün de tek bir ortak sebebi var: imaj bir yapı çıktısıdır, ama onu çalıştığın makinede üretiyorsun.
Doğru sıra: CI üretir, sunucu indirir
Düzgün akış şöyle:
masterdalına push edilir.- GitHub Actions imajı derler ve commit hash'iyle etiketleyip bir imaj deposuna (registry) gönderir.
- Sunucuya SSH ile bağlanılır, o etiket indirilir, konteynerler yeni imajla ayağa kaldırılır.
Bu üç adımın en kritik kısmı ikinci maddedeki commit hash etiketi. latest etiketi kullanma: latest her deploy'da başka bir imajı gösterir, yani sunucuda o an tam olarak hangi kodun çalıştığını söyleyemezsin ve geri alacak bir adresin olmaz. Commit hash'i ise değişmez. Sunucunun hangi sürümde olduğu tek bir satırda okunur.
İmaj deposu için GitHub Container Registry (GHCR) ayrı bir hesap açmayı gerektirmediği ve aynı token'la çalıştığı için en az sürtünmeli seçenek.
GitHub Actions tarafı
Aşağıdaki iş akışı .github/workflows/deploy.yml dosyasına gider. Dockerfile ve Compose yapısı için Docker Compose ile Next.js ve Django production kurulumu yazısındaki düzeni varsayıyorum.
name: Deploy
on:
push:
branches: [master]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Frontend imajını derle ve gönder
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: ghcr.io/${{ github.repository }}/frontend:${{ github.sha }}
build-args: |
NEXT_PUBLIC_SITE_URL=https://example.com
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Backend imajını derle ve gönder
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ghcr.io/${{ github.repository }}/backend:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxBirkaç ayrıntı gözden kaçıyor:
concurrency bloğu. Arka arkaya iki push yaparsan iki dağıtım işi paralel çalışır ve sunucuda hangisinin son sözü söyleyeceği belli olmaz. cancel-in-progress değerini false bırakmak, çalışan dağıtımın yarıda kesilmemesini sağlar; ikinci iş sırasını bekler.
packages: write izni. GHCR'a yazmak için gerekli. Varsayılan token izinleri depoya göre değişir, bu satırı açıkça yazmak sonradan "denied" hatasıyla uğraşmanı önler.
type=gha önbelleği. Buildx katman önbelleğini GitHub Actions önbelleğinde tutar. Bu olmadan her çalıştırmada npm ci ve derleme sıfırdan yapılır. Sunucuda build almaktan kurtulup CI'da her seferinde sıfırdan derlemek, kazancın yarısını geri vermek olur.
NEXT_PUBLIC değişkenlerini build arg olarak geçmek
Yukarıdaki build-args satırı tesadüf değil. Next.js, NEXT_PUBLIC_ önekli değişkenleri next build sırasında JavaScript paketinin içine gömer; çalışma zamanında .env dosyasına yazman hiçbir şeyi değiştirmez. Bu davranışın ayrıntısını ve tek imajı birden çok ortama taşımak istediğinde ne yapman gerektiğini Next.js'i kendi sunucunda çalıştırmak yazısında anlatmıştım.
Pratik sonucu şu: build sunucudan CI'ya taştığı anda, bu değişkenlerin de CI'da tanımlı olması gerekir. Dockerfile tarafında karşılığı:
FROM node:22-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run buildBuild arg'a sır koyma. Build arg değerleri imaj geçmişinde (docker history) görünür. API anahtarı, veritabanı parolası, token gibi şeyler buraya değil, çalışma zamanında sunucudaki ortam değişkenlerine girer. Zaten NEXT_PUBLIC_ önekiyle tarayıcıya gönderilen bir değer tanımı gereği sır değildir; build arg olarak geçmesi bu yüzden sorun değil.
Sunucu tarafı: etiketi dışarıdan al
Compose dosyasında build bloğu yerine image satırı olacak ve etiket sabit değil, değişken olacak:
services:
frontend:
image: ghcr.io/kullanici-adi/depo-adi/frontend:${APP_TAG}
env_file: .env
expose:
- "3000"
restart: unless-stopped
backend:
image: ghcr.io/kullanici-adi/depo-adi/backend:${APP_TAG}
env_file: .env
expose:
- "8000"
restart: unless-stoppedSunucudaki .env dosyasına da bu satır eklenir:
APP_TAG=9f2c1ab...Artık "sunucuda hangi sürüm çalışıyor" sorusunun cevabı bu satır. Deploy işlemi de bu satırı değiştirip konteynerleri yenilemekten ibaret.
Sunucunun GHCR'dan imaj çekebilmesi için bir kerelik giriş yapması gerekir. Bunun için dağıtım kullanıcısının hesabında yalnızca okuma yetkisi olan bir token kullan; CI'daki yazma yetkili token'ı sunucuya kopyalama:
echo "<SALT_OKUNUR_TOKEN>" | docker login ghcr.io -u kullanici-adi --password-stdinDağıtım betiği
Sunucuda /usr/local/bin/deploy.sh olarak duran, tek işi olan bir betik:
#!/usr/bin/env bash
set -euo pipefail
: "${APP_TAG:?APP_TAG verilmedi}"
cd /srv/uygulama
sed -i "s/^APP_TAG=.*/APP_TAG=$APP_TAG/" .env
docker compose pull
docker compose run --rm backend python manage.py migrate --noinput
docker compose up -d
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3000/api/health > /dev/null; then
docker image prune -f
echo "dagitim tamam: $APP_TAG"
exit 0
fi
sleep 2
done
echo "yeni surum saglik kontrolunden gecemedi" >&2
exit 1set -euo pipefail burada süs değil. Bu satır olmadan docker compose pull başarısız olsa bile betik devam eder, up -d eski imajla çalışır ve CI sana yeşil tik gösterir. Dağıtımın sessizce hiçbir şey yapmaması, patlamasından kötüdür.
Sağlık kontrolü döngüsü de aynı sebeple var: docker compose up -d konteyner başladığında döner, uygulama hazır olduğunda değil. Araya bu döngüyü koymazsan bozuk bir sürüm başarılı deploy gibi raporlanır.
CI'daki karşılık adımı:
- name: Sunucuda dağıt
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
SSH_HOST: ${{ secrets.DEPLOY_HOST }}
SSH_USER: ${{ secrets.DEPLOY_USER }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -i ~/.ssh/id_ed25519 "$SSH_USER@$SSH_HOST" \
"APP_TAG=${{ github.sha }} /usr/local/bin/deploy.sh"ssh-keyscan ile anahtarı uçuşta almak yerine sunucunun ana bilgisayar anahtarını bir secret olarak saklıyorum. ssh-keyscan, karşı tarafın kim olduğunu doğrulamadan güvenir; bir kereliğine kendin alıp secret'a koymak bu körlemesine güveni ortadan kaldırır.
Dağıtım kullanıcısı root olmasın ve sadece bu işi yapabilsin. Betiğe sudo gerekiyorsa sudoers içinde yalnızca o komuta izin ver.
Migration'lar hangi sırada
Yukarıdaki betikte migrate, up -d işleminden önce çalışıyor. Yani migration'ı yeni imajla çalıştırıyorsun ama uygulama hâlâ eski sürüm. Bu, migration'ların geriye dönük uyumlu olmasını şart koşar: yeni şema, eski kod tarafından da okunabilmeli.
Pratikte kuralı şöyle uygulayabilirsin: önce ekle, sonra kullan, en son sil.
- Sütun eklerken
null=Trueya da varsayılan değerle ekle; eski kod o sütunu bilmese de yazmaya devam edebilsin. - Sütun silmeyi ve yeniden adlandırmayı iki dağıtıma böl: birinci dağıtımda kod o sütunu kullanmayı bırakır, ikinci dağıtımda sütun düşer.
Migration'ı sonraya bırakmak da mümkün ama o zaman yeni kod olmayan bir sütunu isteyeceği için aradaki saniyelerde 500 alırsın. Sıralamayı değiştirmek yerine migration'ları uyumlu yazmak daha az acı veriyor.
"Kesintisiz" iddiasının gerçeği
Bu kurulum deploy süresini ciddi biçimde kısaltır ama tek bir konteyner çalıştırıyorsan sıfır kesinti vermez. docker compose up -d değişen servisin konteynerini durdurup yenisini oluşturur; arada birkaç saniyelik bir boşluk vardır ve nginx o aralıkta 502 döner.
Değişen şey şu: eskiden bu boşluk build süresi kadardı ve build başarısız olursa boşluk kalıcıydı. Şimdi imaj zaten hazır olduğu için boşluk konteyner başlatma süresine iner.
Gerçekten sıfır kesinti istiyorsan yükü aynı anda iki örneğe dağıtman ve onları sırayla yenilemen gerekir. O noktaya geldiğinde ortaya çıkan yeni sorunlar — sürüm kayması, Server Actions şifreleme anahtarı, paylaşımlı önbellek — kendi sunucunda Next.js çalıştırma yazısında ayrıca anlatılıyor. Tek sunuculu projelerin çoğunda birkaç saniyelik kesinti bu karmaşıklığa değmiyor.
Geri alma
Asıl kazanç burada. Bir sürümün bozuk olduğunu fark ettiğinde:
APP_TAG=<onceki-commit-hash> /usr/local/bin/deploy.shİmaj zaten registry'de duruyor, indirilmesi bile gerekmeyebilir. Geri alma süresi konteyner başlatma süresine eşit.
Bunun çalışması için eski imajların silinmemiş olması gerekir. Betikteki docker image prune -f sadece hiçbir etikete bağlı olmayan (dangling) katmanları siler, etiketli eski sürümlere dokunmaz. prune -a kullanma: geri döneceğin imajı silersin.
Migration içeren bir sürümden geri dönmek ayrı bir konu — şema değişikliği kendiliğinden geri gelmez. Yukarıdaki "önce ekle, sonra kullan" kuralına uyduysan eski kod yeni şemayla çalışmaya devam eder ve geri alma sorunsuz olur. Bu kuralın asıl değeri de burada ortaya çıkıyor.
Özet
- Sunucuda build alma. Build bir CI işidir; sunucunun işi çalıştırmaktır.
- İmajı commit hash'iyle etiketle.
latest, "hangi sürüm çalışıyor" sorusunu cevapsız bırakır. NEXT_PUBLIC_değişkenleri build arg olarak CI'da tanımlı olmalı; sırlar oraya girmez.- Dağıtım betiğine
set -euo pipefailve bir sağlık kontrolü döngüsü koy, yoksa başarısız deploy başarılı görünür. - Migration'ları geriye dönük uyumlu yaz; geri alma imkânını asıl bu sağlar.
- Tek örnekle kesinti sıfırlanmaz, saniyelere iner. Çoğu proje için doğru takas bu.