İçeriğe atla
Yiğit Tenekeci
CV

Homelab

Bu siteyi nasıl kurdum: evdeki bir dizüstünden internete açılan yol

Bu sitenin arkasında pahalı bir sunucu yok. Odanın köşesinde, kapağı kapalı duran bir dizüstü bilgisayar var; üzerinde Ubuntu çalışıyor ve aylardır kapanmadı. Site kendisi orada durmuyor — statik dosyalar Cloudflare’de barınıyor — ama otomasyonları çalıştıran n8n orada, ve dışarıdan erişilebilmesi gerekiyordu.

Daha önce hiç site yapmamıştım. Elimde bir cümle vardı: “kendi sitem olsun, bir de evdeki n8n dışarıdan webhook alabilsin.” Bu yazı o cümleden buraya kadar olan yolun hikâyesi — sırasıyla, elediklerim ve neden elediğimle birlikte. Toplam yeni harcama yılda bir alan adı ücreti oldu.

Önce karar, sonra kod

İlk refleksim bir şablon indirip başlamaktı. Yapmadım, çünkü hiçbir şey bilmediğim bir alanda ilk yazdığın satır aslında bir karar: hangi araç, nerede duracak, nasıl yayınlanacak. Şablonu indirdiğin an bu kararların hepsini birisi senin yerine vermiş oluyor ve geri almak pahalı.

Onun yerine bütün işi bir karar haritasına böldüm: her açık soru ayrı bir bilet, biletler birbirini bekleyecek şekilde bağlı. On altı bilet çıktı; alan adı, teknoloji, tasarım, barındırma, tünel, güvenlik duvarı, izleme, donanım. Kural şuydu: bilet kapanmadan o konuda kod yazılmaz. Sonunda hepsi tek bir dokümanda birleşti ve inşa o dokümandan yürüdü.

Bunun somut faydası ilk gün değil, üçüncü hafta görüldü. “Neden Tailwind kullanmıyoruz” ya da “neden şu servisi de açmıyoruz” sorusu her tekrar geldiğinde cevabı yeniden üretmedim; gerekçesi yazılıydı. Bir kere düşünüp yazmak, beş kere hatırlamaya çalışmaktan ucuz.

Bütün kararların üstünde tek bir duruş vardı ve neredeyse her tartışmayı o bitirdi: en az hareketli parça. Yeni bir bağımlılık, panel veya soyutlama eklemeden önce tek bir soru sordum — “bu olmasa ne olur?” Bu yazıdaki “hayır”ların çoğu (form yok, Tailwind yok, ikon kütüphanesi yok, otomatik güncelleme yok, dış izleme yok) o sorunun cevabı.

Adres: adın kendisi marka

Alan adında iki karar vardı: isim ve kayıt yeri.

İsimde takma adım (javerto) elendi. Sebep basit: bu siteyi bir işverene ya da meslektaşa vereceğim ve takma ad her seferinde bir cümlelik açıklama istiyor. Ad-soyad hiçbir şey açıklamıyor, yigittenekeci.com oldu. Uzantıda .dev teknik olmayan kitlede duraksatıyor, .com.tr fazladan bürokrasi demek — .com sözlü olarak tarif gerektirmeyen tek varsayılan.

Kayıt yeri için Cloudflare Registrar’ı seçtim, çünkü barındırma, tünel ve kimlik kapısı zaten Cloudflare’de olacaktı; alan adı da orada olunca DNS kendiliğinden bağlı geliyor. İkinci sebep daha keskin: Cloudflare alan adını maliyetine satıyor. “İlk yıl 1 dolar, yenileme 40 dolar” oyunu ve satın alma ekranındaki üst satış çöplüğü yok, WHOIS gizliliği de dahil.

Bir de küçük ama pahalıya patlayabilecek bir ayrıntı çıktı. Servisleri n8n.ev.yigittenekeci.com gibi iki seviyeli adlandırmayı düşünmüştüm, düzenli görünüyordu. Cloudflare’in ücretsiz SSL sertifikası *.yigittenekeci.com’u kapsıyor ama iki seviyeyi kapsamıyor. Yani o düzen, her yeni servis için sertifika derdi demekti. Adresler tek seviye kaldı: n8n.yigittenekeci.com.

Siteyi neyle yaptım

Blog gerçek bir hedefti — yılda yirmi beş civarı yazı. Bu tempoda ham HTML’de başlığı ve menüyü her dosyaya kopyalamak sürdürülemez, dolayısıyla bir derleme adımı hak ediliyordu. Astro’yu seçtim.

Burada yeni başlayan biri için önemli bir güvence var, çünkü ilk başta beni en çok tedirgin eden şey buydu: Astro ve npm bağımlılıkları yalnızca kendi bilgisayarımda, derleme sırasında çalışıyor. Yayındaki sitede çalışan hiçbir program yok. Ziyaretçiye giden şey önceden üretilmiş düz HTML ve CSS dosyaları. “Statik site” bunu anlatıyor: sunucuda bozulacak, güncellenmesi gereken, açık bırakılabilecek bir program yok.

Stil tarafında Tailwind’i ve hazır temaları eledim. Tailwind’in çözdüğü sorun büyük projede sınıf isimlerinin kaosa dönmesi; üç sayfalık bir sitede o kaos yok, üstelik Astro’nun bileşen içi stil özelliği aynı faydayı bedavaya veriyor. Hazır temayı elemek daha bilinçliydi: tasarım kararını hazır tema önden ve senin yerine veriyor, sonra ondan çıkmak sıfırdan yazmaktan pahalıya geliyor. CSS’i elle yazdım.

Tasarımı prototiple buldum, tartışarak değil. Referans olarak mxb.dev’i aldım, ölçülerini tarayıcıdan ölçüp kendi ölçeğime uyarladım — orijinaldeki 94 piksellik başlığı fazla iri buldum, tipografiyi yaklaşık %83’e çektim. Sayfa yapısı üç adrese indi: ana sayfa doğrudan özgeçmişin kendisi, yazılar, projeler. Ayrı bir “Hakkımda” sayfası yok; ziyaretçi zaten onu öğrenmeye geliyor, ana sayfayı özgeçmiş yapmak bir tıklama ve bir sayfa siliyor.

Yazı sayfasında ise ölçüyü değiştirmek zorunda kaldım. Ana sayfadaki 1200 piksellik kap, 20 piksellik gövde yazısında satır başına yaklaşık 99 karakter demek; okunabilirlik aralığı 60–75. Kısa bloklarda sorun değil ama uzun düz metinde göz satır sonundan bir sonrakinin başına dönerken yerini kaybediyor. Üç ölçüyü yan yana koyup baktım ve yalnızca yazı sayfasındaki metin sütununu 800 piksele (~81 karakter) daralttım. İdeal aralığın hâlâ biraz üstünde, bilerek kabul ettim — asıl uçurum 974 pikseldeydi.

Push’tan yayına iki dakika

Barındırmada Cloudflare’in iki ürünü vardı ve aralarında bir gün kaybettim: Pages ve Workers. Pages’in benim için tek somut üstünlüğü her dal için okunabilir bir önizleme adresi vermesiydi — ama ben yazıları yerelde npm run dev ile görüp doğrudan ana dala göndereceğim, o önizlemeyi kullanmayacaktım. O gerekçe düşünce Pages lehine somut bir eksen kalmadı. Cloudflare de 2025’ten beri yeni projeler için Workers diyor. Repoda henüz kod yokken göç maliyeti sıfırdı; sonra taşımak zorunlu bir işe dönüşecekti.

Adı yanıltıcı: “Workers” kod çalıştırmayı çağrıştırsa da tamamen statik bir sitede Worker kodu yazılmıyor. Repodaki tüm maliyeti beş satırlık bir ayar dosyası.

Yayın hattını da olabildiğince aptal tuttum. Cloudflare panelinden GitHub repomu bağladım; artık her git push kendiliğinden derlemeyi ve yayını tetikliyor. GitHub Actions yolunu eledim, çünkü fazladan bir iş akışı dosyası ve GitHub’da saklanan bir Cloudflare API anahtarı getiriyordu — sızarsa hesabıma yazma yetkisi demek — ve karşılığında bu projede hiçbir şey kazandırmıyordu. Güvenlik ağı iki yolda da aynı: derleme hata verirse yayın güncellenmiyor, eski sürüm ayakta kalıyor.

Günlük akış şuna indi:

yaz → npm run dev ile bak → git commit → git push → 1-2 dk → yayında

Bir tuzak var, atlarsan sessizce yarım kalıyor: www. ile başlayan adresin köke yönlenmesi otomatik değil. İki parça gerekiyor — www için proxy’li bir DNS kaydı ve bir yönlendirme kuralı. İkincisini kurup birincisini unutursan istek Cloudflare’e hiç ulaşmadığı için kural da çalışmıyor.

Asıl zor kısım: evdeki makine

Site tarafı burada bitmişti. Geriye asıl istediğim şey kaldı: evdeki n8n’in internetten webhook alabilmesi.

İlk engel işin en belirleyici kısıtı çıktı. İnternet servis sağlayıcım bana gerçek bir genel IP adresi vermiyor. Buna CGNAT deniyor; kabaca aynı genel adresi düzinelerce abone paylaşıyor. Pratik sonucu şu: modemin yönetim ekranından port yönlendirme yapamıyorsun, çünkü yönlendirecek bir kapın yok. Uzun süre bunun bir ayar hatası olduğunu sandım, sonra traceroute çıktısına baktım ve ilk sıçramaların hepsinin özel adres aralığında olduğunu gördüm. Ayar hatası değildi, mimari kısıttı.

Bu tespit tek başına birkaç fikri birden eledi. Plex’i uzaktan yayınlama planı da bu yüzden düştü: Plex’in kendi uzaktan erişimi bu şartlarda imkânsız, VPN’le çözmek ise akıllı TV ve konsol tarafında çalışmıyor, üstelik uzaktan izleme artık ücretli. Emek/getiri tutmadı, Plex ev ağında kaldı.

Kapıyı dışarıdan açmak yerine içeriden uzatmak

CGNAT arkasındaysan çözüm, dışarıdan içeri bir kapı açmayı bırakıp içeriden dışarı kalıcı bir bağlantı kurmaktan geçiyor. Cloudflare Tunnel tam olarak bunu yapıyor: makinede küçük bir program çalışıyor, Cloudflare’in ağına giden bir bağlantı açıyor ve o bağlantı hep açık kalıyor. Dışarıdan gelen istek önce Cloudflare’e, oradan bu hazır hattın içinden makineye iniyor. Modemde port açılmıyor, ev IP’m hiçbir yerde görünmüyor.

Yapılandırmanın tamamı şu kadar:

tunnel: ev-sunucusu
credentials-file: /home/yigit/.cloudflared/ev-sunucusu.json

ingress:
  - hostname: n8n.yigittenekeci.com
    service: http://localhost:5678
  - service: http_status:404

Son satır göründüğünden önemli: listede eşleşmeyen her istek 404 alıyor. Yani yanlışlıkla başka bir servisi dünyaya açma ihtimalin kalmıyor. Makinede Grafana, Zabbix, Syncthing, Ollama, qBittorrent gibi bir sürü şey çalışıyor ve hiçbirinin internete çıkmasını istemiyorum — özellikle Ollama’nın, çünkü kimlik doğrulaması yok.

Dışarı açılan tek servis n8n oldu. Gerekçesi dar ve net: tek gerçek ihtiyaç dış servislerden gelen webhook, bu da tanımı gereği internete açık bir uç istiyor. Diğer servisler için böyle bir zorunluluk yok; onlara evin dışından ulaşmak istediğimde Tailscale kullanıyorum — cihazlarımı tek bir özel ağa alan bir VPN. Her açılan servis bakım yükü ve saldırı yüzeyi demek, açılmayan servis ise sıfır.

Bu yapılandırmayı Cloudflare panelinden değil, makinedeki bir dosyadan yönetiyorum ve kopyası repoda duruyor. Böylece yayınlanan şeyin kaydı git geçmişinde kalıyor, iki tıkla yeni bir servis dünyaya açılamıyor, sunucu çökerse yapılandırma repodan geri geliyor.

Kimlik kapısı ve muaf tutulan tek yol

Tünel ayağa kalkınca n8n çıplak biçimde internette olacaktı, ki içinde API kimlik bilgilerim duruyor. Önüne Cloudflare Access koydum: adrese giren herkes önce e-postasına gelen tek kullanımlık kodu giriyor. İzin listesi tek kişi.

Ama burada bir istisna şart. Webhook adresleri Access’ten muaf olmalı, yoksa GitHub’ın ya da Telegram’ın gönderdiği istek n8n’e değil giriş ekranına çarpar ve akış hiç tetiklenmez. Dolayısıyla iki politika var: genel giriş için e-posta kodu, /webhook/* yolları için baypas.

Bunun kabul ettiğim bir maliyeti var ve açıkça yazmak istiyorum: webhook adresi bir şifredir. Adresi bilen o akışı tetikleyebilir. Adresler rastgele kimliklerden oluştuğu için tahmin edilemez, ama sızabilir — gönderen servisin ayar ekranında, tarayıcı geçmişinde, bir ekran görüntüsünde. Bugünkü akışlarım için bunu yeterli buldum; hassas bir akış çıkarsa o akışa ayrıca başlık doğrulaması ekleyeceğim.

CGNAT bir güvenlik duvarı değil

Bu noktada sistem çalışıyordu ve kolayca durabilirdim. Durmamamın sebebi tek bir cümleydi: koruma tesadüfe değil, açık bir kurala dayanmalı. O ana kadar servislerimi dış dünyadan koruyan şey bir kural değildi, sadece CGNAT’ın yan etkisiydi.

İki varsayımım da yanlış çıktı.

Birincisi: CGNAT yalnızca IPv4’ü örtüyor. Makinenin genel bir IPv6 adresi var ve o paylaşımlı değil, doğrudan adreslenebilir. Servislerin çoğu IPv4 ile birlikte IPv6’yı da dinliyor. Yani “dışarıdan erişilebilen hiçbir servisim yok” cümlem IPv4 için kanıtlıydı, IPv6 için sadece bir temenniydi.

İkincisi daha sinsi: Docker, ufw’yi atlıyor. Port yayınlayan bir konteyner, Docker’ın kendi yönlendirme kurallarıyla güvenlik duvarının giriş zincirini baypas ediyor. Düz bir ufw enable o konteynerleri korumuyor — koruduğunu sanıyorsun, korumuyor.

Sonuç kural seti şöyle oldu: gelen trafik varsayılan olarak reddediliyor, yalnızca ev ağı ve Tailscale arayüzü izinli; konteynerler için de Docker’ın kendi zincirine tek bir kural yazıldı, böylece bugünkü ve gelecekteki bütün konteynerler tek yerden kapsanıyor. Alternatif olan “her compose dosyasında portu tek tek yerel adrese bağlamak” fikrini eledim, çünkü her yeni konteynerde hatırlamayı gerektiriyor ve unutulan bir tanesi sessiz bir açık demek.

Sıralama da önemliydi ve bunu bilerek yaptım: önce Tailscale kurup evin dışından bağlanabildiğimi doğruladım, güvenlik duvarını ancak ondan sonra açtım — üstelik makinenin başındayken. Kendini dışarıda bırakmanın en kolay yolu bu adımları ters çevirmek.

Yeşil ışıklı ölü sistem

Geriye tek soru kaldı: bu şey öldüğünde nasıl haberim olacak?

Hedefim kesintisiz çalışma değildi. Kesintiyi baştan kabul ettim — makine kapanırsa ya da ev interneti giderse tünel düşer ve o sırada gelen webhook’lar kaybolur. Kritik bir iş bu sunucuya konmayacak. İstediğim tek şey, bir şeyin günlerce sessizce ölü kalmasını engellemekti.

Burada neredeyse düştüğüm bir tuzak var. Doğal refleks adresi düzenli olarak yoklamak. Ama Access giriş ekranını Cloudflare kendi tarafında üretiyor — yani tünel tamamen ölüyken bile o adres sana kibarca bir giriş formu ve 200 OK döndürüyor. Kontrol yeşil yanıyor, sistem ölü. İzlemenin en sinsi hatası bu: yanlış şeyi ölçüp kendini güvende sanmak.

Çözüm, yoklamanın zincirin gerçekten sonuna kadar gitmesini sağlamaktı. n8n’de iki düğümlük bir akış kurdum: gelen isteğe ok dönüyor, başka hiçbir şey yapmıyor. Adresi webhook yolunda olduğu için Access’i baypas ediyor. Yoklama artık oraya gidiyor ve tek istekte DNS, Cloudflare, tünel, konteyner ve n8n’in akış motoru — hepsi birden sınanıyor. Cevabı Cloudflare değil, n8n’in kendisi üretiyor.

Yoklamayı makinede zaten kurulu olan Zabbix yapıyor, alarm Telegram’a düşüyor. Dış bir izleme servisi eklemedim ve bunun gerekçesi ilk bakışta ters görünüyor: dış sonda yalnızca “makine tamamen ölü” vakasını ekliyor, yani uzaktan düzeltemeyeceğim vakayı. Makine ayakta ama servis ölüyse Zabbix zaten görüyor ve Tailscale ile girip düzeltebiliyorum. Makine tamamen ölüyse bilgi işime yaramıyor, üstelik elektrik gelince her şey kendiliğinden ayağa kalkıyor.

Bunun kabul ettiğim bir kör noktası var: alarm gelmemesi “her şey yolunda” da olabilir, “makine ölü” de. Bunu bilerek kabul ettim.

Aynı bölümde çıkan bir başka gerçek beni şaşırttı. Dizüstünü sunucu yapmanın en çok övülen tarafı dahili pilinin kesintisiz güç kaynağı gibi çalışması — bu makinede pil ölü. Yani elektrik kesilirse anında kapanıyor. UPS almayı da eledim, çünkü makineyi kurtarsa bile modem giderse tünel yine düşer; koruma yarım kalıyor.

Geriye bakınca

Bugün yayında olan şey şu: statik bir site Cloudflare’de, her push’ta kendini yayınlıyor; ev sunucusunda tek bir servis dışarı açık, kimlik kapısının arkasında; geri kalan her şey kapalı ve açık bir kurala dayanıyor; bir şey ölürse telefonuma bildirim düşüyor. Toplam yeni harcamam yılda bir alan adı ücreti oldu.

Bu işten çıkardığım tek cümle şu: en çok işe yarayan karar, eklediklerim değil elediklerimdi. Form, panel, ikon kütüphanesi, otomatik güncelleme, dış izleme, ikinci alan adı — hepsinin makul birer gerekçesi vardı ve hiçbiri “bu olmasa ne olur?” sorusunu geçemedi. Geriye kalan sistem küçük olduğu için bir yıl sonra da anlayabileceğim, ve zaten asıl istediğim buydu.