Laravel Production Stack: Her Bileşen Neden Orada?
Nginx + PHP-FPM + Redis + PostgreSQL + Supervisor + Horizon ile production-grade bir Laravel kurulumu. Her bileşenin işi ve değiştirildiğinde ne kaybedileceği.
Kısaca
Production'a koyduğum Laravel stack'inde — Cloudflare'dan Nginx, PHP-FPM, PostgreSQL, pgBouncer ve Redis'e, Supervisor ile Horizon'a kadar — her bileşenin somut bir karşılığı var. pgBouncer çıkarsa connection storm'da PostgreSQL bağlantıları tükenir, pgBackRest çıkarsa PITR kaybolur; yine de gerekli olduğu anlaşılana kadar minimumda kalmak varsayılan.
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-ForveCF-Connecting-IPheader’larıyla geçiriyor; aşağıdakireal_ipyönergeleriyle uygulamanın gördüğü IP bu oluyor. - SSL termination’ı Cloudflare’da yapıp backend’e
httpile 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_stringdeyimi 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:
- Cache —
Cache::remember(...). - Session —
SESSION_DRIVER=redis. - Lock —
Cache::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?
Queue — QUEUE_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_afterzaman 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 |
|---|---|
| pgBouncer | Connection storm’larında PostgreSQL bağlantısı tükenir, request’ler 500 atar |
| Supervisor | Worker’lar crash sonrası geri gelmez, alarm kurmak zorundasınız |
| Horizon | Görünürlük çöker, “queue neden yavaş” sorusunu kör cevaplarsınız |
| opcache | Her request PHP dosyasını disk’ten okur ve parse eder, ~5x yavaşlama |
| pgBackRest | pg_dump kalır — PITR kaybedersiniz, gerçek bir incident’ta hata payınız tek bir günlük yedek |
| Redis lock | Race 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.