Kişisel Finans Uygulamasını VPS'e Taşımak: Zenth Prod Notları
Kişisel finans takibini Excel'den kurtarmak için küçük bir uygulama yazmaya başladım. Bir yıl sonra Go backend, Next.js frontend, PostgreSQL, Redis, Ollama tabanlı AI chat ve bir adet worker mesh orkestrasyon sistemi olan bir şeye dönüşmüştü. Bu yazı, o sistemi bir VPS üzerinde Docker Compose ile ayağa kaldırma sürecinin gerçek notları.
"Production" diyorum ama kastettiğim şunu: benim ve eşimin gerçek portföy verilerimiz orada, gerçek hatalar gerçek kayıp sayesinde keşfediliyor ve her gecenin 02:00'sinde sistem down olduğunda fark eden ben oluyorum. Ölçek küçük ama stres gerçek.
Neden VPS, Neden Docker Compose?
Homelab'imde K8s var (Mac Mini M4 + Docker Desktop). Zenth'i oraya deploy etmedim; çünkü homelab'im 192.168.1.x adresinde, dışarıdan erişilemiyor. Uygulamayı her yerden kullanmak istiyordum.
İki seçenek vardı:
- Cloudflare Tunnel + homelab — 90 günlük deneyimim var (ayrı yazı), özellikle WebSocket bağlantıları için sorunlu davranıyordu. Ollama'nın uzun AI yanıtları için 1-2 dakikalık bağlantı kesilmeleri kabul edilemezdi.
- Hetzner CX32 VPS — €4.5/ay, 4 vCPU, 8 GB RAM, 80 GB disk. Docker Compose ile düz kurulum.
Seçtim: VPS. Nedeni basit — kontrol bende, timeout'lar bende, debug bende.
Ollama'yı VPS'te çalıştırıyorum, CPU inference ile. 14B Q8 model yerine 7B model (qwen2.5:7b) kullandım çünkü CPU ile 14B modelde yanıt süresi 3-5 dakikaya çıkıyordu; kullanılamaz hale geliyordu. 7B ile ~25-40 saniye. Kabul edilebilir.
Stack ve Dosya Yapısı
zenth/
├── docker-compose.yml # 8 servis
├── docker-compose.dev.yml # local override
├── .env.prod # .gitignore'da
├── nginx/
│ └── nginx.conf
└── backend/
└── cmd/server/main.go
docker-compose.yml'daki servisler:
services:
backend: # Go + Fiber, 8080
web: # nginx (Next.js static build), 80/443
postgres: # 17, pg_cron yüklü
redis: # 7.2
ollama: # LLM inference
watchtower: # otomatik image güncelleme (sadece saatlik)
backup: # restic → Backblaze B2 (cronjob)
worker-ops: # Phoenix Agent ops worker (mesh)
Gerçek Sorunlar: Kronolojik Sırayla
1. Fiber WriteTimeout (İlk Büyük Bug)
Uygulama açıldı, AI chat denedim, 504 Gateway Timeout aldım. Nginx loguna baktım: upstream timed out (110: Operation timed out) while reading response header from upstream. Zaman damgası: istek 31 saniye sonra kesildi.
İki şüpheli vardı: nginx proxy_read_timeout ve Fiber'in kendi write timeout'u. nginx'e baktım — AI chat için 600s ayarlamıştım. Fiber'e baktım:
app := fiber.New(fiber.Config{
WriteTimeout: 30 * time.Second, // ← sorun burada
ReadTimeout: 30 * time.Second,
})
Default 30s yazılmıştı. Ollama yanıtı 30-40 saniye sürdüğünde Fiber kendi bağlantısını kesiyordu, nginx 504 dönüyordu.
app := fiber.New(fiber.Config{
WriteTimeout: 600 * time.Second,
ReadTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
})
Ders: framework'ün varsayılan timeout'larını LLM inference için mutlaka override et.
2. Nginx Sync Endpoint Gözden Kaçtı
Streaming endpoint (/api/v1/ai/chat/stream) için nginx'te 600s timeout ayarlamıştım. Ama mobil için ayrı bir senkron endpoint vardı (/api/v1/ai/chat). Bu, generic /api/ bloğuna düşüyordu ve orada proxy_read_timeout 30s vardı.
Fiber'i düzelttikten sonra streaming çalıştı. Mobil hâlâ timeout. Sebebi bulmak 20 dakika aldı. Çözüm: sync endpoint için ayrı location = /api/v1/ai/chat bloğu eklemek.
location = /api/v1/ai/chat {
proxy_read_timeout 600s;
# ...
}
Ders: her AI endpoint'ini ayrı ayrı nginx'te kontrol et. Generic blok sessizce yutabilir.
3. Rate Limit IP Sorunu
Rate limiter devreye girince tüm kullanıcılar aynı bucket'a düştü. Sebebi: nginx arkasında çalışırken c.IP() her zaman nginx'in iç IP'sini döndürüyordu (172.x.x.x).
// Yanlış
ip := c.IP()
// Doğru
ip := c.Get("X-Forwarded-For")
if ip == "" {
ip = c.IP()
}
// ilk IP'yi al (virgülle ayrılmış olabilir)
ip = strings.SplitN(ip, ",", 2)[0]
nginx tarafında da eklemek gerekti:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
4. Ollama Model Memory'de Tutmama
Her AI sorgu arasında ~90 saniye geçince Ollama modeli bellekten atıyor ve yeniden yüklüyordu (cold start tekrar). OLLAMA_KEEP_ALIVE varsayılan değeri 5 dakika.
# docker-compose.yml
ollama:
environment:
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_NUM_PARALLEL=2
- OLLAMA_MAX_LOADED_MODELS=1
8 GB RAM'li VPS'te OLLAMA_MAX_LOADED_MODELS=1 önemli — birden fazla model yüklenirse bellek tükenir.
5. Postgres pg_cron ve Timezone
pg_cron ile günlük fiyat kaydı yapıyordum: her gün 23:00'de. Ama kayıtlar 20:00'de oluşuyordu.
Sebep: pg_cron UTC kullanıyor, VPS'in timezone'u da UTC, ama ben cron zamanını Türkiye saatine göre yazmıştım. Çözüm: cron ifadesini UTC'ye dönüştürmek ve bunu dokümante etmek.
-- 23:00 Türkiye saati = 20:00 UTC (DST'siz)
SELECT cron.schedule('daily-price-record', '0 20 * * *',
$$SELECT record_daily_prices()$$);
6. Docker Watchtower ve Database Migration
Watchtower backend image'ını güncelledi. Yeni versiyonda migration vardı ama migration scripti auto-migrate ile çalışıyordu ve tablo zaten varsa ALTER TABLE ADD COLUMN IF NOT EXISTS değil düz ALTER TABLE ADD COLUMN kullanıyordum.
Sonuç: servis başlamadı, pq: column "new_field" of relation already exists.
// Yanlış
_, err = db.Exec(`ALTER TABLE portfolios ADD COLUMN risk_score FLOAT`)
// Doğru
_, err = db.Exec(`ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS risk_score FLOAT DEFAULT 0`)
Watchtower artık sadece saatte bir çalışıyor ve kritik güncellemeler için manuel docker compose pull && docker compose up -d akışı kullanıyorum.
7. Redis Keyspace Notification (SSE Event'leri)
Kullanıcı portfolio güncellediğinde AI chat'in yeni veriyi görmesi için Redis keyspace notification kullandım. Ama notify-keyspace-events varsayılan olarak kapalı.
redis:
command: redis-server --notify-keyspace-events KEA
K = keyspace, E = keyevent, A = all events. Production'da sadece ihtiyaç duyulan event'leri açmak daha iyi ama başlangıç için hepsi.
8. SSL Sertifika Yenileme Akışı
Let's Encrypt certbot ile wildcard sertifika (*.zenth.finance) aldım. İlk 90 günde otomatik renewal çalıştı. Ama certbot hook'ları nginx'i reload etmeyi "unuttu" — sertifika yenilendi, nginx eski sertifikayı sunmaya devam etti. Tarayıcıda NET::ERR_CERT_DATE_INVALID.
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
docker compose -f /opt/zenth/docker-compose.yml exec nginx nginx -s reload
Renewal hook'unu deploy klasörüne koyunca problem çözüldü. deploy hook'u sertifika başarıyla yenilendiğinde çalışır — post hook'undan daha güvenilir.
9. Worker Mesh JWT Token
Sistemin son eklentisi: homelab'deki Claude Code agent'ının Zenth backend'ine doğrudan API çağrısı yapabilmesi için bir worker mesh servisi kurdum. Agent, Zenth'e JWT ile bağlanıyor.
Prod compose'a AGENT_JWT_SECRET eklemeyi unutmuştum. Agent'ın audit/recent endpoint'i her çağrısında 401 dönüyordu. Secret keychain'de vardı, .env dosyasında vardı ama docker-compose.yml'deki backend servisinin environment bölümüne geçirilmemişti.
backend:
environment:
- AGENT_JWT_SECRET=${AGENT_JWT_SECRET}
# ← bunu eklemedim, servise ulaşmıyordu
Ders: environment variable'ların .env'de bulunması ≠ servisin göreceği anlamına gelmiyor. Her yeni env var için servis tanımını ve .env'i birlikte güncelle.
3 Aylık Prod Sonrası Notlar
Çalışmaya devam eden şeyler:
- Restic backup → Backblaze B2: 3 ayda 0 başarısız çalışma
- PostgreSQL: 0 downtime, veri kaybı yok
- nginx: 2 kez reload (sertifika + config güncelleme)
Beklenmedik sorunlar:
- Ollama CPU inference ısı yönetimi: VPS'in CPU throttle ettiği anlarda yanıt süresi 2× artıyor
- Redis keyspace notification: production'da kapsamı daraltmam gerekti (tüm event'ler log'u şişirdi)
Değiştirdiğim kararlar:
- 14B → 7B model: doğru karar, CPU'da 14B'yi production'da kullanmak mümkün değil
- Watchtower saatlik: ilk başta anlıktı, migration sorununu o öğretti
auto-migrate→ migration script tablosuna geçiş: yakında
Yapmadığım ama yapmalıyım:
- Healthcheck endpoint'i harici monitoring aracıyla izlemek (şu an sadece UptimeRobot ile basit HTTP check)
- Postgres connection pool limitini config'den okumak (şu an hardcoded)
- AI chat yanıt süresini Prometheus ile metrik olarak tutmak
Genel Değerlendirme
Kişisel bir uygulama için VPS + Docker Compose yaklaşımı, K8s'ten çok daha az operasyonel yük demek. Helm chart yazmak yok, ingress controller sorunuyla boğuşmak yok, namespace izin matrisi yok. docker compose up -d çalışıyor, log için docker compose logs -f yeterli.
Ama "prod olduğu için" bazı K8s alışkanlıklarını taşıdım: network isolation (her servis sadece gerekli portları expose ediyor), explicit restart policy, resource limit (RAM bazlı), secret'ları env file'a, plain text değil.
Bu stack'in sınırını görece yakında hissedeceğim: tek node'da horizontal scaling yok, zero-downtime deploy için ek iş gerekiyor. O noktada ya homelab K8s'e taşıyacağım ya da Kamal/Coolify gibi bir orchestration katmanı ekleyeceğim. Ama şu an için, haftada 2-3 saat bakım gerektiren, gerçekten çalışan bir sistem bu.