Küçük ve orta SaaS projeler için tek bir VPS, birden fazla uygulamayı barındırmak için fazlasıyla yeterli. Kubernetes, mesh, “cloud native” baskısı pek çok ekibi bu yoldan saptırıyor — ama eğer toplam yük 1 makineye sığıyorsa, dağıtım maliyeti neredeyse her zaman değmez.
Bu yazıda kullandığım örüntüyü adım adım göstereceğim.
Hedef
- 3–5 bağımsız Laravel uygulaması.
- Her uygulama kendi domain’i, kendi süreci, kendi kullanıcısı.
- PostgreSQL ve Redis paylaşılıyor: PostgreSQL’de role bazında izole, Redis’te prefix’le ayrılmış.
- Deploy bir komutla, zero-downtime.
- Backup ve monitoring tek yerden.
- Tüm bunlar < $40/aylık bir VPS’te.
Kullanıcı ve dosya yerleşimi
Her uygulama kendi unix kullanıcısını alıyor:
billing:billing /var/www/billing
notify:notify /var/www/notify
blog:blog /var/www/blog
www-data (Nginx) gruba dahil ediliyor, kod dizinine okuma erişimi için. Yazma sadece kendi uygulamasının kullanıcısında. Bu izolasyon hatalı davranan bir uygulama diğerlerine zarar veremesin diye değil; bir geliştirici hatasının diğer projeyi etkilemesini önlemek için — ki bu çok daha sık olan senaryo.
PHP-FPM pool’ları
Nginx + PHP-FPM Pool Ayrımı yazısında detaylı. Burada özet: her uygulama kendi pool.d/*.conf dosyasında pm.max_children, memory_limit ve unix kullanıcısıyla tanımlanıyor.
Veritabanı: paylaşılan PostgreSQL
Tek bir PostgreSQL cluster, her uygulama için ayrı bir database ve role:
CREATE ROLE billing_app LOGIN PASSWORD '...';
CREATE DATABASE billing OWNER billing_app;
REVOKE ALL ON DATABASE billing FROM PUBLIC;
pg_hba.conf:
host billing billing_app 127.0.0.1/32 scram-sha-256
host notify notify_app 127.0.0.1/32 scram-sha-256
host blog blog_app 127.0.0.1/32 scram-sha-256
Hiçbir uygulama diğerinin DB’sini göremez bile.
pgBouncer’la bağlantı havuzu
PHP-FPM bağlantıyı varsayılan olarak per-request açıp kapatıyor — kalıcı bağlantıyla (PDO::ATTR_PERSISTENT) bile bağlantı tek bir worker’a bağlı kaldığı için paylaşılan bir havuz yok. PostgreSQL bağlantı kurulumu (~10ms) kümülatifte ciddi. pgBouncer önünde pool_mode = transaction ile bunu çözüyoruz. auth_query yazısı detayı veriyor.
Redis: paylaşılan, namespace’le izole
Tek bir Redis instance. Her Laravel uygulaması farklı bir prefix:
'redis' => [
'cache' => [
'host' => '127.0.0.1',
'database' => 0,
'prefix' => 'prod:billing:cache:',
],
'queue' => [
'host' => '127.0.0.1',
'database' => 0,
'prefix' => 'prod:billing:queue:',
],
],
Prefix bir adlandırma sözleşmesi, bir sınır değil: üç uygulama da aynı instance’a, aynı database 0 üzerine, requirepass ve ACL olmadan bağlanıyor — yani biri diğerinin anahtarlarını okuyabilir. Bunun zorlanması gerekiyorsa her uygulamaya kendi Redis ACL kullanıcısını verin.
Detay namespace izolasyonu yazısında.
Queue worker’ları
Supervisor her uygulamanın queue worker’ını ayrı bir process grubu olarak yönetiyor:
/etc/supervisor/conf.d/
├── billing-worker.conf
├── notify-worker.conf
└── blog-worker.conf
Her dosyada uygulama kendi kullanıcısı ile çalışıyor:
[program:billing-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/billing/current/artisan queue:work rabbitmq --queue=default --sleep=1 --max-time=3600
user=billing
numprocs=4
autostart=true
autorestart=true
stopwaitsecs=3600
Deploy: symlink swap
Her uygulama dizininde:
/var/www/billing/
├── releases/
│ ├── 2026-05-12-1430/
│ ├── 2026-05-13-0900/
│ └── 2026-05-13-1100/ ← yeni release
├── shared/
│ ├── .env
│ ├── storage/
│ └── uploads/
└── current → releases/2026-05-13-1100
Deploy adımları:
- Yeni release klasörünü oluştur, git checkout, composer install, build.
shared/içeriklerini sembolik linkle.- Migrate (forward-compatible olmalı).
currentsymlink’i atomik olarak yeni klasöre çevir (ln -sfn).- PHP-FPM’e
kill -USR2ile graceful reload gönder (opcache invalidation). - Supervisor’ı sadece queue worker’ları için yeniden başlat.
Beşinci adımda bir uyarı var: USR2 FPM master’ına gidiyor ve bütün pool’ları graceful biçimde yeniden yüklüyor — tek bir pool’u yeniden başlatan bir sinyal yok. Yani bir projenin deploy’u bütün projelerin worker’larını geri döndürüyor ve ortak opcache’i boşaltıyor; yukarıdaki izolasyon çalışma zamanında geçerli, deploy yolunda değil.
Eski release’leri saklamak (örn. son 5) rollback’i bir komuta indirgiyor: symlink’i eski klasöre çevir, PHP-FPM reload.
Backup
- PostgreSQL: pgBackRest, repository S3-uyumlu kovaya yazıyor.
- Redis: AOF kapalı, RDB her saat. Cache-only kullanım için yeterli.
- Uploads (
shared/uploads): rclone ile saatlik S3 sync. /etc: günlük tar + S3 (config’in versiyon kontrolü).
Monitoring
Self-hosted bir Prometheus + Grafana abartı olur bu ölçek için. Tercih ettiğim:
- uptime-kuma — her domain’i HTTP check’le izliyor, Slack/Telegram alarmı.
- node_exporter + Cloudflare metrics — sunucu sağlığı.
- PHP-FPM
/statusendpoint — slow request sayısını izle. - logrotate —
/var/log/{nginx,php-fpm,laravel}/*.loggünlük.
Limitler ve büyüme sinyalleri
Bu mimari aşağıdaki sinyallerden biri görülünce kırılır:
- CPU sürekli > %70. Vertical scale (daha büyük VPS) bir kez yapılır, sonra uygulama ayrıştırması.
- Tek bir uygulama DB’de baskın hale gelirse. O uygulama kendi PostgreSQL’ine taşınır — replica veya managed servis.
- Coğrafi gecikme şikayetleri. Bu durumda CDN’e statik asset taşıma yeterli; app server’ı taşımak ekstrem.
Niye Kubernetes değil?
Üç sebep:
- Operasyon yükü. Tek başınıza bir k8s cluster’ı çalıştırmak, üzerinde uygulama yazmaktan daha fazla zaman alır.
- Maliyet. Control plane’i ayrıca ücretlendiren sağlayıcılarda — EKS cluster başına saatlik $0.10, yani tek node eklenmeden ~$73/ay — 3 küçük node’lu managed k8s, eşdeğer VPS’in 3–5 katı tutuyor. Ücretsiz verenlerde fark kapanıyor: DigitalOcean’da 3 × $12/ay node ücreti VPS parasına yakın.
- Karmaşıklık ödülü yok. Bu uygulamalar bağımsız ölçeklenmek zorunda değil.
Bu mimari “lazy-eval k8s” değil — başka bir paradigma. Sade, anlaşılır, operasyonel olarak küçük bir mimari. Lazım olduğunda parçalanır, ondan önce değil.
Yorumlar
Yorum yapmak için GitHub hesabınızla giriş yapmanız yeterli. Yorumlar GitHub Discussions üzerinde saklanır.