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.
Yorumlar
Yorum yapmak için GitHub hesabınızla giriş yapmanız yeterli. Yorumlar GitHub Discussions üzerinde saklanır.