HomeBlogKişisel Finans Uygulamasını VPS'e Taşımak: Zenth Prod Notları
DevOps & Infrastructure

Kişisel Finans Uygulamasını VPS'e Taşımak: Zenth Prod Notları

Emre Ferit AslantaşJune 12, 202614 dkArticle
docker-compose vps nginx go self-hosted production ollama ai
Sponsored

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

  1. 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.
  2. 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.

Sponsored

Weekly DevOps Newsletter

Get new tool reviews, comparisons and DevOps trends delivered to your inbox weekly.