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

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ı:

  1. Yeni release klasörünü oluştur, git checkout, composer install, build.
  2. shared/ içeriklerini sembolik linkle.
  3. Migrate (forward-compatible olmalı).
  4. current symlink’i atomik olarak yeni klasöre çevir (ln -sfn).
  5. PHP-FPM’e kill -USR2 ile graceful reload gönder (opcache invalidation).
  6. 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 /status endpoint — slow request sayısını izle.
  • logrotate/var/log/{nginx,php-fpm,laravel}/*.log gü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:

  1. Operasyon yükü. Tek başınıza bir k8s cluster’ı çalıştırmak, üzerinde uygulama yazmaktan daha fazla zaman alır.
  2. 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.
  3. 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.