Teknoloji

VPS’e Sıfır Kesinti CI/CD Nasıl Kurulur? rsync, Sembolik Sürümler ve systemd ile Sıcacık Bir Yolculuk

Giriş: Bir Gecenin Sessizliğinde Dağıtım Panikleri

Hiç gece yarısı bir güncelleme atayım, kimse fark etmeden biter diye düşünüp, tam o sırada site kafasına göre çöküverdi mi? Ben bir akşam, “iki dakikalık iş” diye tarif ettiğim bir dağıtımda, beklenmedik bir dosya izni ve ufak bir yapılandırma hatası yüzünden anasayfayı bembeyaz bırakmıştım. O an öğrendim ki, dağıtım dediğin şey akışı bozmadan yapılmadığında, en sakin saat bile stresin en canlı hâline dönüşebiliyor. Bir noktadan sonra amacım “çabuk dağıtayım” değil, “hiç kimse fark etmeden dağıtayım” oldu. İşte o kapıdan girince, sembolik sürümler, rsync, systemd ve küçük ama etkili ritüellerle tanışıyorsunuz.

Bu yazıda, bir VPS üzerinde sıfır kesinti CI/CD’yi adım adım kuracağız. GitHub Actions ya da GitLab CI ile kodu inşa edip, rsync ile sunucuya taşıyacağız. Sunucuda versiyonlanmış klasör yapısı kullanıp sembolik bağ ile “current” işaretini yeni sürüme atomik şekilde çevireceğiz. Ve elbette işin kalbinde systemd servisleri olacak; servis yeniden yüklemeyle, mümkün olan en yumuşak geçişi sağlayacağız. Arada ufak anekdotlar, pratik tarifler, bir-iki kestirme komut; hepsi beraber. Hadi başlayalım.

Neden Sıfır Kesinti? Hani Şu Parmak İzi Gibi Dağıtımlar

İşin özünde şu var: ziyaretçi sayısı en sonda bile sıfıra düşmüyor. Gece üçte bile birileri bir ürün sayfasının resmini inceliyor veya bir blog yazısının ortasında kalmış olabiliyor. Dağıtım yaptığınız anda sayfalar kırılmıyor olabilir ama hız düşüyor, oturumlar kopuyor, anlık bir hata görünebiliyor. Sıfır kesinti yaklaşımı ise treni durdurmadan vagon değiştirmek gibi; hazırlığı arka planda yapıp, geçişi tek harekette tamamlıyorsunuz.

Benim için dönüm noktası, dosyaları doğrudan “/var/www/app” içine kopyalamayı bırakıp, “/releases/tarih-saat” şeklindeki klasörlere yüklemek oldu. Çünkü yeni sürüm hazır olduğunda tek bir sembolik bağlantı değişimi ile sistemi yeni dizine yönlendirebiliyorsunuz. Bu hareket o kadar hızlı ki, üzerine kahve içecek kadar bile zaman kalmıyor. Kopyalama, bağımlılıkların kurulması, yapı araçlarının çalışması; hepsi arkada. Önde ise tek bir klik hissi.

Bu yaklaşımı bir kez kurduktan sonra, başarısız dağıtım korkusu da azalıyor. Yeni sürüm olmamışsa, eski sürüme geri dönmek bir ln -sfn komutu kadar yakın. Üstelik loglar, paylaşılan yükleme klasörleri, cache ve anahtar dosyaları gibi ortak alanları akıllıca dışarı aldığınızda, hem yükseltmek hem de geri almak rahatlıyor.

Sembolik Sürüm Düzeni: releases, current ve shared’ın Ufak Sırrı

İşe dizin düzeniyle başlayalım. Mesela “/var/www/myapp” bizim kök dizin olsun. Bunun altında “releases”, “shared” ve “current” adında üç yapı taşı kuracağız. “releases” her yeni dağıtımda zaman damgasına sahip bir klasör alacak. “shared” değişmeyecek, ortak dosyalar için güvenli alan olacak. “current” ise her zaman canlı uygulamanın kökü, yani asıl referans noktamız.

Gözünüzde canlansın diye bir örnek:

/var/www/myapp
  ├─ releases/
  │   ├─ 2025-11-07_21-04-01/
  │   └─ 2025-11-07_22-10-32/
  ├─ shared/
  │   ├─ storage/
  │   ├─ .env
  │   └─ uploads/
  └─ current -> /var/www/myapp/releases/2025-11-07_22-10-32

Dağıtım sırasında yeni sürüm “releases” altına yüklenir. Gerekli bağlantılar “shared” içindeki dosyalara kurulur (mesela “storage” veya “uploads” gibi yazılabilir klasörler). En sonda “current” sembolik bağ yeni sürüme çevrilir. İşin sırrı burada: sembolik bağın değişmesi atomik bir işlemdir ve “bir an”da olur. Yani canlı trafik, eski dizinden yeni dizine ışık hızıyla geçer.

Ben genellikle “shared” altına yapı çıktılarını değil, yazılabilir ve dağıtımlar arasında değişmeyen şeyleri koyarım. Çevre dosyası, kullanıcı yüklemeleri, log klasörü gibi. Mesela PHP dünyasında “storage” ve “.env”, Node dünyasında “.env” ve yükleme klasörleri, statik sitelerde ise “uploads” ya da “media”. Bu arada PHP ve Nginx ile çalışan projeler için Nginx, PHP‑FPM ve sıfır kesinti dağıtımın sıcacık yol haritasını ayrı bir rehberde konuşmuştuk, ona da göz atabilirsiniz.

CI Tarafı: GitHub Actions / GitLab CI, rsync ve Minik Bir Dans

Gelelim inşa ve paylaşıma. Kod depoya gelince CI sistemi build alacak, çıktıyı paketleyecek ve rsync ile VPS’e gönderecek. Bu işlemde iki küçük kurala dikkat etmek işinizi kolaylaştırır: build’i CI üzerinde yapıp sadece gereken dosyaları yollamak ve rsync’i “idempotent” davranacak şekilde çağırmak. Yani “bir daha çalışsa da aynı sonucu verecek” tarzda.

GitHub Actions kullanıyorsanız, resmi belgeler gayet anlaşılır; detaylar için GitHub Actions dokümantasyonuna denk getirebilirsiniz. Benim küçük bir iskeletim şöyle olmuştu:

name: deploy

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: |
          npm ci --legacy-peer-deps
          npm run build
          # ya da composer install --no-dev --optimize-autoloader
      - name: Pack
        run: |
          tar -czf release.tgz --exclude=node_modules --exclude=.git 
            dist/ public/ package.json .env.example scripts/
      - name: Upload via rsync
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
          SSH_KEY: ${{ secrets.SSH_KEY }}
          APP_DIR: "/var/www/myapp"
          REL: "$(date +"%Y-%m-%d_%H-%M-%S")"
        run: |
          echo "$SSH_KEY" > key.pem && chmod 600 key.pem
          ssh -i key.pem -o StrictHostKeyChecking=yes $SSH_USER@$SSH_HOST "mkdir -p $APP_DIR/releases/$REL $APP_DIR/shared"
          rsync -az --delete-delay -e "ssh -i key.pem -o StrictHostKeyChecking=yes" 
            ./ $SSH_USER@$SSH_HOST:$APP_DIR/releases/$REL/
          ssh -i key.pem -o StrictHostKeyChecking=yes $SSH_USER@$SSH_HOST 
            "bash -lc 'cd $APP_DIR/releases/$REL && ln -sfn $APP_DIR/shared/.env .env && ln -sfn $APP_DIR/shared/uploads uploads'"
          ssh -i key.pem -o StrictHostKeyChecking=yes $SSH_USER@$SSH_HOST 
            "ln -sfn $APP_DIR/releases/$REL $APP_DIR/current && sudo systemctl reload myapp.service || sudo systemctl restart myapp.service"

Burada yapılan şey net: yeni sürüm klasörü yaratılıyor, dosyalar rsync ile taşınıyor, shared bağlantıları yenileniyor, sonra da “current” yeni sürüme dönüyor. reload komutu başarısız olursa kibarca restart deneniyor. Uygulamanız reload’u destekliyorsa, geçiş genelde fark edilmez.

GitLab tarafı da benzer. Merak edenler için resmi sayfa olan GitLab CI belgeleri rehberlik ediyor. Aşağıdaki ufak parça da fikir versin:

deploy:
  stage: deploy
  only:
    - main
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh rsync bash
  script:
    - export REL=$(date +"%Y-%m-%d_%H-%M-%S")
    - mkdir -p ~/.ssh && echo "$SSH_KEY" > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa
    - ssh -o StrictHostKeyChecking=yes $SSH_USER@$SSH_HOST "mkdir -p $APP_DIR/releases/$REL $APP_DIR/shared"
    - rsync -az --delete-delay -e "ssh -o StrictHostKeyChecking=yes" ./ $SSH_USER@$SSH_HOST:$APP_DIR/releases/$REL/
    - ssh $SSH_USER@$SSH_HOST "bash -lc 'cd $APP_DIR/releases/$REL && ln -sfn $APP_DIR/shared/.env .env && ln -sfn $APP_DIR/shared/uploads uploads'"
    - ssh $SSH_USER@$SSH_HOST "ln -sfn $APP_DIR/releases/$REL $APP_DIR/current && sudo systemctl reload myapp.service || sudo systemctl restart myapp.service"

rsync kısmında “–delete-delay” sevdiğim bir detay; hedefte artık olmayan dosyaları bir kerede temizler ve kopyalama tamamlanana dek acele etmez. “-a” arşiv kipidir, izin ve zaman bilgilerini güzel taşır. “-z” sıkıştırmayı açar, özellikle uzak sunucuya gönderirken faydalı olur. Bir de “StrictHostKeyChecking” sizi ortadaki adam risklerinden korur; bilinen anahtarlarınızla konuşursunuz, kafanız rahat eder.

systemd ile Nazik Geçişler: Reload, Health Check ve Atomik Anahtar

Dağıtımın kalbi systemd tarafı. Orada servis nasıl başlıyor, nasıl yeniden yükleniyor, hata anında nasıl toparlıyor; hepsi bir davranış meselesi. Basit bir servis tanımıyla başlayalım, sonra küçük iyileştirmeler katarız.

[Unit]
Description=MyApp Service
After=network.target

[Service]
User=www-data
WorkingDirectory=/var/www/myapp/current
ExecStart=/var/www/myapp/current/bin/server
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=3
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target

Bu dosyayı “/etc/systemd/system/myapp.service” içine koyduğunuzu düşünün. “ExecStart” canlı dizini işaret ediyor, çünkü “current” hep son sürüm. “ExecReload” kibarca sürece bir sinyal gönderiyor; uygulama o sinyalle ayak değiştirmeyi biliyorsa, sihir gibi. “Restart=always” ise gecikmeden toparlanmak için iyi bir ağ.

Şimdi küçük bir dokunuş daha ekleyelim: dağıtımı bitirince “current” yeni sürümü göstermiş olacak, ama uygulamanın hazır olduğundan emin olmak istiyoruz. Ben bazen “ExecStartPost” yerine, symlink’i çevirmeden önce bir “health check” bekletirim. Yani yeni sürüm dizininde bir mini HTTP sunucusu ayağa kalktı mı, “/health” 200 döndü mü, öyleyse geçiş gelsin. Bunun için ufak bir betik iş görür:

#!/usr/bin/env bash
set -euo pipefail
URL=${1:-"http://127.0.0.1:8080/health"}
for i in {1..30}; do
  if curl -fsS "$URL" >/dev/null; then
    echo "healthy"; exit 0
  fi
  sleep 1
done
exit 1

CI adımlarınızda bu betiği yeni sürüm dizininde çalıştırır, sağlıklı sinyal aldıktan sonra “ln -sfn” ile “current”i çevirirsiniz. Böylece hem systemd hem uygulama ritmini bulmuş olur. Nginx kullanıyorsanız, onun da yeniden başlatmaya gerek kalmadan konfigürasyonları nazikçe reload edebildiğini bilirsiniz; bunun kurulumu ve daha ileri hız dokunuşları için HTTP/2 ve HTTP/3’ü uçtan uca etkinleştirme rehberine göz atmak güzel olur.

Bu arada daha teknik meraklıları için, systemd’nin servis birim seçenekleri dünyası epey geniş; resmi sayfa olan systemd.service dokümantasyonu “Type=notify”, “ExecStartPre” gibi ayrıntıları anlaşılır anlatıyor. Uygulamanız hazır sinyali gönderiyorsa “notify” tipi daha da pürüzsüz bir geçiş sağlar.

Rollback: Bir Komutla Dünkü Huzura Dönmek

En sevdiğim anlardan biri şudur: yeni sürüm yayınlandı, sorun çıktı, ama panik yok. Çünkü dağıtım klasörü yaklaşımı buna hazır. “releases” altında önceki sürümler duruyor; “current”in işaretini bir önceki sürüme çevirmek tek hareket. İşte o küçük güven duygusu, “hata yaparsam nolur” korkusunu azaltıyor.

Küçük bir geri alma betiği işinizi daha da pratik hale getirir. Mesela “/var/www/myapp/scripts/rollback.sh”:

#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/var/www/myapp
PREV=$(ls -1dt $APP_DIR/releases/* | sed -n '2p')
if [ -z "$PREV" ]; then
  echo "Önceki sürüm bulunamadı"; exit 1
fi
ln -sfn "$PREV" "$APP_DIR/current"
sudo systemctl reload myapp.service || sudo systemctl restart myapp.service

Bu betik, en son sürümden bir öncekinin yolunu yakalayıp, “current”i ona çevirir ve servisi nazikçe yeniden yükler. Elbette “ls” sıralamasına güvenmek yerine, dağıtım anında bir “releases.json” ya da “latest” dosyası tutmak da tercih edilebilir; ben her iki yöntemi de farklı projelerde kullandım, ikisi de gayet iş görüyor.

Geri almadan sonra veritabanı şemasına dikkat etmek gerekir. Eğer ileri sürümde geri dönüşü olmayan bir şema değişikliği yaptıysanız, kodu geri almak yetmez. O yüzden migration mantığını yazarken “ileri” kadar “geri”yi de hesaplamak iyi bir alışkanlık. Planı dağıtım anından önce netleştirince, gece uykuları daha derin oluyor.

Güvenlik, İzinler ve Küçük Parlatmalar: Dağıtımın Kolay Unutulan Köşeleri

Dağıtım boru hattını kurarken gizli anahtarların ve erişim bilgilerinin nasıl saklandığı önemli. CI tarafında “secrets” alanlarını kullanıp, kod deposuna şifreli bile olsa kimlik koymamayı tercih ediyorum. Sunucu tarafında dağıtım için ayrı bir kullanıcı açıp, “sudo” yetkisini sadece gerekli komutlarla sınırlamak iyi bir pratik. SSH’de anahtar doğrulamayı zorunlu kılmak, “known_hosts” ile sunucunun parmak izini sabitlemek, hatta gerekiyorsa “ForceCommand” ile yalnızca belirli betiklere izin vermek işinizi sağlamlaştırır.

Dosya izinleri de sessiz hataların kaynağı olabiliyor. Yazılabilir klasörleri “shared” altında toplamak, “www-data” ya da kullandığınız servis kullanıcısına sahipliğini vermek ve yalnızca gerekli minimum izinleri atamak kulağa sıkıcı gelse de çok iş kurtarır. rsync ile “-a” parametresi izinleri taşımaya yardımcı olur, ama gerekirse hedefte “chown” ve “chmod” ile ince ayar yapın.

Yedekleme tarafını da dağıtımdan koparmıyorum. Bir sürüm yanlış giderse kodu geri alıyorsunuz; peki ya veri? Yedeklerinizi hem yerel hem uzak tutmak, sürümlendirmek ve gerektiğinde geri yüklemeyi prova etmek önemli. Bunu akıcı biçimde ele aldığımız Restic ve Borg ile S3 uyumlu uzak yedekleme rehberi tam burada iş görüyor. Kodla veri birbirini kolladığında dağıtım daha cesur hale geliyor.

Performans tarafında, önbellekleri dağıtımla birlikte nazikçe temizlemek güzel olur. Edge tarafında CDN varsa “invalidation” yapmak, uygulama tarafında ise yalnızca değişen anahtarları temizlemek akıllıca. WordPress gibi sık ziyaret edilen sistemlerde medya ve statik dosya trafiğini dışarı taşımak isterseniz, medyayı S3’e taşıma ve imzalı URL’lerle önbellek geçersizleştirme hakkında anlattıklarım hoşunuza gidebilir. Trafiği CDN’e verdikçe dağıtımlar sahne arkasında daha da görünmez olur.

Son olarak, dağıtım sürecinden hemen sonra gözünüzü kulağınızı açan küçük izleme kancaları kurun. Birkaç dakika boyunca hata oranını, CPU ve bellek değişimini, tepkime sürelerini izlemek alışkanlık olmalı. Bunun için Prometheus, Grafana ve Uptime Kuma ile izleme ve alarm kurulumuna başlangıç rehberi güzel bir eşlikçi. Canlıyı izlerken dağıtımlarınızın aslında ne kadar görünmez olduğunu bizzat görürsünüz.

Dağıtım Betikleri: Küçük Dokunuşlar Büyük Huzurlar

Bir süre sonra CI yaml’ınızın şiştiğini hissedebilirsiniz. Ben çoğu zaman VPS üzerinde küçük betikler tutuyorum. “scripts” klasöründe “activate.sh”, “cleanup.sh”, “healthcheck.sh” gibi. CI tarafı bu betikleri uzaktan çağırıyor, günlük iş orada dönüyor. Böylece CI dosyası kısa kalıyor, sunucu tarafında tekrar kullanılabilir ufak bir kütüphane oluşuyor.

Örneğin “activate.sh” şöyle olabilir:

#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/var/www/myapp
REL=${1:?"release dizini gerekli"}
# paylaşılanlar
cd "$APP_DIR/releases/$REL"
ln -sfn "$APP_DIR/shared/.env" .env
ln -sfn "$APP_DIR/shared/uploads" uploads
# sağlık kontrolü (opsiyonel)
"$APP_DIR/scripts/healthcheck.sh" "http://127.0.0.1:8080/health"
# atomik geçiş
ln -sfn "$APP_DIR/releases/$REL" "$APP_DIR/current"
sudo systemctl reload myapp.service || sudo systemctl restart myapp.service

“cleanup.sh” ise gereksiz büyümeyi önler. Eski sürümler bir noktada disk şişirmeye başlayabilir. Basit bir “en son 5 sürümü tut” yaklaşımı iş görür. Ben genelde log döndürme ve yedekleme politikasıyla uyumlu gidiyorum. Özetle, her dağıtımdan sonra küçük bir bahar temizliği.

Bir not da network için: rsync bazı ortamlarda SSH üzerinden beklediğinizden yavaş olabilir. Bu durumda sıkıştırma seviyesini düşürmek, gereksiz dosyaları paket dışı bırakmak ve mümkünse build çıktılarını minimal hale getirmek etkili. Kimi projelerde “dist” klasörünü tek arşive alıp rsync yerine “scp” ile gönderdikten sonra sunucuda açmak bile daha hızlı sonuç verebiliyor. Ama rsync’in delta aktarım kabiliyeti değişmeyen dosyalarda büyük kazanç sağlıyor; bence varsayılan denemeye değer.

CI/CD’yi Takvime Bağlamak: Oto Dağıtım mı, Elle Onay mı?

Bir başka tatlı karar noktası şu: her “main” push’unda otomatik dağıtım mı olsun, yoksa bir onay adımı mı girsin? Küçük ekiplerde otomatik akış işi hızlandırıyor. Büyük ekiplerde ya da yoğun trafiğin olduğu projelerde, “manual” onay veya “scheduled” dağıtımlar daha huzurlu. GitHub Actions’ta “workflow_dispatch” ile elle tetikleme, GitLab’ta “when: manual” ve “environments” özellikleri tatlı bir denge kuruyor. Tartı, ekibin ritmine göre değişiyor.

Canlıya çıkmadan önce staging ortamı kurmak ve aynı düzeni orada da uygulamak ayrı bir ferahlık. Bir iki gerçek kullanıcı davranışını taklit eden test, gözden kaçan ufak köşeleri ortaya çıkarır. Bazen tek bir “cache-control” başlığı ya da “gzip/brotli” ayarı bile etkileyici fark yaratıyor; bu konuda web katmanını incelerken ele aldığımız rehberler keyifli vakit geçirtiyor.

Kapanış: Sessiz Dağıtımların Rahat Uykusu

Toparlayalım. Sıfır kesinti dağıtım, sihirli bir kutu değil; birkaç iyi fikrin küçük, güvenilir adımlarla birleşmiş hâli. releases/current/shared düzeni dosya tarafını sadeleştiriyor. rsync ile dosyaları güvenli, hızlı ve tekrarlanabilir biçimde taşıyorsunuz. systemd servisleriyle nazik bir reload ya da kontrollü bir restart, yeni sürüme ışık hızında geçiş sağlıyor. Geri alma bir komut, izleme birkaç grafik, yedekleme ise iç huzur.

Buradan sonrası pratik ve ritim. Küçük başlayın, önce staging’de deneyin, sonra canlıya taşıyın. Betikleri ufaltın, tekrar eden işleri sunucuya yerleştirin, CI dosyasını basit tutun. Bağımlılıkları build aşamasında çözün, canlıda sadece çalıştırın. Sorun olduğunda geri dönmekten çekinmeyin; her geri alma aslında ileri doğru bir çentik bırakır. Dilerseniz bu akışı güçlendirmek için GitHub Actions’ın ayrıntılarını ve GitLab CI’nin ortam yönetimi özelliklerini kurcalayın; küçük detaylar büyük konfor getiriyor.

Umarım bu yolculuk size cesaret vermiştir. Bir akşam bir güncelleme daha atarken, kahveniz soğumadan dağıtımın bittiğini görmek kadar keyifli az şey var. Sorularınız olursa yazın, birlikte bakarız. Bir dahaki yazıda belki Nginx katmanında başka ufak hız hilelerini konuşuruz; o zamana kadar temiz dağıtımlar, sessiz geçişler, huzurlu geceler.

Sıkça Sorulan Sorular

Doğru bayraklarla pek zor. --partial yerine --delete-delay ve atomik symlink geçişi kullanınca, önce kopyalama biter sonra temizlik yapılır. Geçiş bir anda olduğu için canlı trafik yarım kalmaz.

Elbette mümkün. Yine de systemd, tek yerden servis yönetimi, log entegrasyonu ve reload davranışlarıyla işleri sadeleştiriyor. Mevcut yapınıza uyanı seçin, mantık aynı kalıyor: current dizini ve nazik geçiş.

İkisi de işinizi görür. Hangi ekosistemde rahat ettiğinize ve depo nerede durduğuna bakın. Önemli olan build’i CI’da yapıp, sunucuya sadece gerekli çıktıyı rsync ile taşımak ve symlink’i atomik çevirmek.