Laravel’i geliştirmek bir saat, production’a ciddi şekilde koymak bir hafta sürer. Bu yazıda kullandığım stack’i ve her bileşenin niye orada olduğunu anlatıyorum. “Her şey gerekli” demiyorum — gerekli olduğunu anlayana kadar minimum tutmayı savunan biriyim — ama her bileşenin kazandırdığı somut.

Neden sunucuları Cloudflare arkasına koyuyorum?

  • DDoS koruması, WAF, rate limiting gibi özellikler out-of-the-box.
  • Gerçek client IP’sini X-Forwarded-For ve CF-Connecting-IP header’larıyla geçiriyor; aşağıdaki real_ip yönergeleriyle uygulamanın gördüğü IP bu oluyor.
  • SSL termination’ı Cloudflare’da yapıp backend’e http ile konuşmak mümkün.
  • CDN özellikleriyle static asset’leri Cloudflare cache’inden servis etmek kolay.
  • Ücretsiz 15 yıllık SSL sertifikası sağlıyor, yönetimi basitleştiriyor.

Nginx — neden Apache değil?

İkisi de çalışıyor. Nginx tercih ediyorum çünkü:

  • Asenkron event loop modeli düşük gecikmede daha tutarlı.
  • TLS termination’ı, static asset serving’i, fastcgi’yi tek configde tertemiz hallediyor.
  • try_files $uri $uri/ /index.php?$query_string deyimi Laravel için kanonik.

Tipik server block:

# -----------------------------
# HTTP → HTTPS redirect
# -----------------------------
server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

# -----------------------------
# HTTPS
# -----------------------------
server {
    listen 443 ssl http2;
    server_name app.example.com;
    root /var/www/app/current/public;
    index index.php;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    client_max_body_size 25M;

    access_log /var/log/nginx/app.example.com.access.log;
    error_log  /var/log/nginx/app.example.com.error.log warn;

    # Cloudflare arkasında gerçek client IP, her aralık için bir satır: https://www.cloudflare.com/ips/
    set_real_ip_from 173.245.48.0/20;
    set_real_ip_from 103.21.244.0/22;
    # ... kalan Cloudflare aralıkları
    real_ip_header CF-Connecting-IP;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.4-fpm-app-example.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_buffering on;
        fastcgi_read_timeout 60s;
    }

    location ~* \.(css|js|png|jpg|jpeg|gif|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        # bu seviye kendi add_header'ını tanımlayınca üsttekiler miras alınmıyor
        add_header X-Frame-Options "SAMEORIGIN";
        add_header X-Content-Type-Options "nosniff";
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        access_log off;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

set_real_ip_from listesi bütün Cloudflare aralıklarını kapsamalı; kapsamazsa Nginx client’ın değil Cloudflare’in IP’sini loglamayı sürdürür. Nokta dosyası bloğunun tek olması da bilinçli: Nginx eşleşen ilk regex location’da durur, dolayısıyla bunun ardına konan istisnasız bir location ~ /\. bloğu /.well-known/ isteklerini de yutar ve ACME doğrulamasını kırar.

PHP-FPM — pool yapılandırması

Detayını ayrı yazıda ele aldım. Production’a özel opcache ayarları:

opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0   ; deploy'da reset gerek
opcache.preload=/var/www/app/current/preload.php
opcache.preload_user=app        ; php-fpm master root başlar, bu olmadan preload reddedilir

validate_timestamps=0 ile dosya değişikliklerini PHP otomatik algılamıyor — deploy sonrası kill -USR2 ile FPM reload zorunlu. Karşılığında saniyede yüzlerce request farkı.

PostgreSQL — neden MySQL değil?

İkisi de çalışıyor. PostgreSQL’i tercih etme sebebim:

  • JSON/JSONB desteği MySQL’inkinden olgun (GIN index, query operators).
  • CTE’ler, window function’lar, materialized view’lar native.
  • Logical replication ve PITR tooling’i daha temiz.
  • Transactional DDL — migration ortasında crash olsa veritabanı temiz kalır.

Karşılığında — kurulum biraz daha disiplin ister. Tipik postgresql.conf hassasiyetleri:

shared_buffers = 4GB                # toplam RAM'in ~%25'i
effective_cache_size = 12GB          # RAM'in ~%75'i
work_mem = 16MB
maintenance_work_mem = 256MB
wal_buffers = 16MB
max_connections = 500
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
random_page_cost = 1.1              # SSD için
effective_io_concurrency = 200

Yedekler pg_dump ile değil pgBackRest ile alınıyor: full + incremental, üstüne WAL arşivinden PITR.

pgBouncer — bağlantı havuzu

PHP per-request bağlantı açar, kapatır. PostgreSQL’de bağlantı başlatmak maliyetli (fork edilen process). Önüne pgBouncer koyup transaction mode ile bunu absorb ediyoruz. auth_query notu.

Redis — üç şapka

Aynı Redis instance üç rol oynuyor:

  1. CacheCache::remember(...).
  2. SessionSESSION_DRIVER=redis.
  3. LockCache::lock(...) ile distributed mutex.

Hepsi tek Redis’te güvenle çalışır — yeter ki:

  • Maxmemory policy: volatile-lru (yalnızca TTL’i olan key’leri LRU ile sil).
  • appendonly no (cache, session ve lock için RDB yeterli).
  • save 900 1 300 10 60 10000 (RDB snapshot kuralları).

RabbitMQ (opsiyonel)

Laravel’in queue driver’ı olarak rabbitmq kullanıyorum — Database ya da Redis yerine. Neden?

QueueQUEUE_CONNECTION=rabbitmq.

Redis queue driver’ı basit ama RabbitMQ’nun özellikleriyle karşılaştırıldığında eksik kalıyor:

  • Persistence — RabbitMQ kalıcı mesajları diske yazar; Redis kuyruğu bellekten servis eder, diske yalnızca RDB snapshot’ları ya da AOF ile iner.
  • Acknowledgements — RabbitMQ’da mesaj, consumer ack verdikten sonra siliniyor; Laravel’in Redis driver’ı da biten job’ı reserved kümesinden kendi siliyor. Fark temizlikte değil yeniden teslimde: Redis retry_after zaman aşımına, RabbitMQ consumer’ın bağlantısına bakıyor.
  • Routing — RabbitMQ’da mesajın hangi queue’ya düşeceğine exchange’ler karar veriyor; Redis’te onQueue() ile istediğiniz kadar adlandırılmış queue açabilirsiniz ama yönlendirme kararı uygulamada kalıyor.
  • Monitoring — RabbitMQ yönetim paneli queue’ları, mesajları, consumer’ları broker düzeyinde gösteriyor; Redis tarafında aynı görünürlüğü Horizon veriyor, o da uygulamanın dispatch ettiğiyle sınırlı.

Supervisor + Horizon

Queue worker’ları Supervisor altında çalışıyor. Horizon Laravel’in queue dashboard’u — worker’ları ona orchestrate ettirebilmek için Supervisor horizon process’ini çalıştırıyor:

[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/current/artisan horizon
user=app
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/horizon.log
stopwaitsecs=3600

Horizon kutudan çıktığı hâliyle yalnızca redis queue bağlantısını sürüyor; yukarıdaki rabbitmq bağlantısında vladimir-yuldashev/laravel-queue-rabbitmq paketinin Horizon modu gerekiyor — RABBITMQ_WORKER=horizon, bağlantı böylece Horizon’un okuduğu event’leri yayıyor. Horizon kendi içinde worker process’lerini fork ediyor — siz config/horizon.php ile her queue’nun worker sayısını ve memory limit’ini ayarlıyorsunuz.

Scheduler

Tek bir cron yetiyor:

* * * * *  app  cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1

Laravel kendi scheduler’ını içeride yönetiyor. withoutOverlapping() ve onOneServer() modifier’larına dikkat — çoklu sunucuda race condition önler.

Logging

JSON formatında structured log:

// config/logging.php
'channels' => [
    'production' => [
        'driver' => 'stack',
        'channels' => ['daily', 'stderr'],
    ],
    'daily' => [
        'driver' => 'daily',
        'path' => storage_path('logs/laravel.log'),
        'level' => env('LOG_LEVEL', 'info'),
        'days' => 14,
        'formatter' => Monolog\Formatter\JsonFormatter::class,
    ],
],

/etc/logrotate.d/laravel-app:

/var/www/app/shared/storage/logs/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0640 app app
    sharedscripts
}

.env management

.env her zaman shared/ dizininde, deploy’lara symlink. Hiçbir zaman repo’da değil. Secrets için bunu basitliğin gücüyle yapıyorum — vault gerekirse ekleriz, başta gerek yok.

Deploy

Symlink swap modeli. Detaylar çoklu proje mimarisi yazısının “Deploy” bölümünde.

Hangi parçası kaldırılırsa ne kaybedersiniz?

BileşenÇıkarınca olan
pgBouncerConnection storm’larında PostgreSQL bağlantısı tükenir, request’ler 500 atar
SupervisorWorker’lar crash sonrası geri gelmez, alarm kurmak zorundasınız
HorizonGörünürlük çöker, “queue neden yavaş” sorusunu kör cevaplarsınız
opcacheHer request PHP dosyasını disk’ten okur ve parse eder, ~5x yavaşlama
pgBackRestpg_dump kalır — PITR kaybedersiniz, gerçek bir incident’ta hata payınız tek bir günlük yedek
Redis lockRace condition’lara açıksınız, distributed-safe mutex’iniz kalmaz

Boring stack’in gücü tam burada: her parça nicel olarak değerli ve değişimi kolay. “Bütün cluster çöktü” senaryosu yerine “Redis maxmemory ayarını kaçırmışım” senaryosu yaşarsınız — ki bunu da düzeltmek beş dakika.