PDF işlerimi bugüne kadar ya ücretli bir masaüstü programıyla ya da “pdf birleştir” diye aratınca çıkan rastgele bir siteyle yapıyordum. İkincisi beni rahatsız ediyordu. Muhasebe tarafında çalıştığım dosyaların içinde cari bilgiler, tutarlar, vergi numaraları var; bunları kimin sunucusuna yüklediğimi bilmediğim bir forma göndermek içime sinmiyordu.
Bu yüzden kendi PDF araç setimi kurmaya karar verdim. Beklediğimden kısa sürdü, ama beklediğimden farklı bir yere gitti.
Önce seçim: Stirling-PDF mi, BentoPDF mi
İki seçenek öne çıktı. Stirling-PDF daha olgun, araç sayısı daha fazla, kendi sunucunda çalışıyor ve dosyayı iş bitince siliyor. Ama işin mimarisine bakınca hoşuma gitmeyen taraf şuydu: dosya gerçekten sunucuya gidiyor, orada işleniyor, sonra siliniyor. Bunun için de bir Java uygulaması ayakta duruyor; imajı büyük, boşta bile yüzlerce megabayt bellek yiyor.
BentoPDF ise tamamen tarayıcıda çalışıyor. İçindeki araçlar WebAssembly’ye derlenmiş motorlarla (PyMuPDF, Ghostscript, CoherentPDF) istemci tarafında iş görüyor; Docker imajının yaptığı tek şey statik dosya servis etmek. Yani “dosya sunucuya gidiyor” değil, “dosya sunucudan inen koda gidiyor”. Bu ayrım benim için sadece teknik değil, ahlaki bir fark. AGPL-3.0 lisanslı, GitHub’da 16 bin civarı yıldızı var, aktif geliştiriliyor. Karar verdim.
Kurulum docker-compose.yml dosyasından ibaret. İki detay: portu 8080’e koyamadım çünkü orada qBittorrent’in arayüzü oturuyordu, 8090’a aldım. İmaj bentopdf-simple etiketiyle geliyor — ticari sürüm değil, kendi barındırma sürümü; pazarlama arayüzü olmayan tam araç seti. Container root olmayan nginx kullanıcısıyla (UID 101) çalışıyor. Upstream imajda healthcheck yok, ben ekledim: imajda curl yok ama busybox wget var, o yüzden kontrol wget -q --spider ile yapılıyor.
Bu noktadan sonrası rutin bir homelab kurulumu olacaktı. Sonra fark ettim ki aslında hiç servis kurmamışım.
Statik dosyayı servis gibi düşünmek
Bir uygulamanın sunucu tarafı yoksa, onu “sunucuda çalıştırmak” diye bir şey de yoktur. BentoPDF’te veritabanı, oturum, kimlik doğrulama, işlenen dosyanın tutulduğu bir dizin yok. Elimde sadece 4.500 dosyalık bir klasör var. Bu klasörü ev sunucusunda tutmanın tek faydası, aynı ağdaki cihazların erişebilmesi. Ama aynı klasörü bir CDN’e koyarsanız, ev sunucusundan hiçbir şey açmadan aynı işi daha hızlı yaparsınız.
Bu benim için önemliydi çünkü sitede “dışarı açılan tek servis n8n” diye bir kural koymuştum. Sebebi bakım yükü ve saldırı yüzeyi. Statik bir dosya paketi bu gerekçenin dışında kalıyor: açtığınız bir kapı yok, sadece dosya var.
Cloudflare Pages’e deploy ettim. pdf.yigittenekeci.com altında yayında. Ev kopyası da 8090’da duruyor, ikisi birbirinden bağımsız.
Ama bedava olmadı.
25 MiB duvarı
Cloudflare Pages tek dosyada 25 MiB üstünü kabul etmiyor. Paketteki 4.512 dosyadan tam ikisi bu sınırı aşıyordu: soffice.wasm.gz (46,5 MB) ve soffice.data.gz (27,3 MB). Bunlar tarayıcıya gömülü LibreOffice motoru — Word, Excel, PowerPoint, RTF, ODT gibi 13 ofis formatını PDF’e çeviren araçların tamamı bu iki dosyaya bağlı.
Yani “sınırı biraz zorlasam” diye bir şey yok, dosya ya yükleniyor ya yüklenmiyor. Seçenekler şunlardı: başka bir hosting (Workers static assets de benzer sınırlarla geliyor), parçalayıp istemcide birleştirmek (kırılgan ve aptalca), ya da o araçları public paketten çıkarmak.
Üçüncüsünü seçtim. libreoffice-wasm klasörü paketten çıkıyor, 13 araç arayüzde kapatılıyor, kalan ~117 araç olduğu gibi çalışıyor. Gerçek bir kayıp da yok: LibreOffice ev sunucusunda zaten kurulu, aynı dönüşümü soffice komutuyla yapıyorum. Ofis dönüşümü evde, geri kalan her şey bulutta.
Araçları kapatmak için upstream’in kendi mekanizmasını kullandım: kök dizine bir config.json koyup disabledTools listesini veriyorsunuz, uygulama açılışta bu dosyayı çekip listeyi uyguluyor. Build gerekmiyor, runtime ayarı.
Burada upstream’in bir kusuruna denk geldim: ilk deploy’da ana sayfa o 13 aracı gizledi ama araçlar dizini (tools.html) hâlâ listeliyordu. Sebebi, o sayfanın listelemeyi gömülü bir script’le çizmesi ve disabledTools filtresini uygulamaması. Yerelde yamaladım. Bu, “config dosyası var, o zaman her yerde geçerlidir” varsayımının ne kadar tehlikeli olduğunun iyi bir örneğiydi.
Kimsenin bahsetmediği tuzak: başlıklar
İlk deploy temiz geçti, sayfa açıldı, sevinç. Sonra araçlardan birini denediğimde sessizce çalışmadığını fark ettim.
Sebep şu: BentoPDF’in bir kısmı WebAssembly’yi SharedArrayBuffer ile çalıştırıyor, bu da tarayıcıdan Cross-Origin-Opener-Policy: same-origin ve Cross-Origin-Embedder-Policy başlıklarını istiyor. Docker imajında bu başlıklar nginx.conf içinde tanımlı, yani imajı çalıştırınca kendiliğinden geliyor. Cloudflare Pages’te nginx yok — başlıkları deploy kökündeki _headers dosyasından vermeniz gerekiyor.
Bunu bilmesem, “araçlar bozuk” diye upstream’de issue açacaktım. Doğrusu şu: statik hosting’e taşıdığınız her uygulamada, eski ortamın reverse proxy’sinin sessizce yaptığı işlerin listesini çıkarmanız gerekiyor. Benim için bu, ilk deploy ile ikinci deploy arasındaki tek gerçek farktı.
Gizlemek silmek değil
Bugün meraktan kontrol ettim: pdf.yigittenekeci.com/word-to-pdf.html hâlâ 200 dönüyor. Sayfa pakette duruyor, sadece arayüzden gizli. Motoru ise yok — libreoffice-wasm/soffice.wasm.gz 404.
Bunu yazarken düşündüm: arayüzde gizlemek bir ürün kararı, erişimi kesmek ise bir güvenlik kararı. İkisini karıştırırsanız, kullanıcıya “bu araç yok” diyorsunuz ama URL’yi bilen biri girip boş ekranla karşılaşıyor. Benim durumumda zararsız — sonuçta gizli bir özellik değil, çalışmayan bir sayfa. Ama aynı mantık bir yetkilendirme kurgusunda kullanılsa felaket olur.
Neyi farklı yapardım
İki şey.
Birincisi, sürüm takibini ciddiye almadım. Kurulumu yaptığımda sürüm 2.8.8’di, bugün upstream 2.8.10’da. Deploy script’im sürüm numarasını parametre olarak alıyor ama paketi hazırlayan kısım elle yapılan bir dizi komut. Yani her güncellemede zip’i indirip, sha256’yı karşılaştırıp, libreoffice-wasm klasörünü silip config.json’ı yeniden yazmam gerekiyor. Otomatik değil. Sıradaki işim bunu tek komuta indirmek — sürüm numarasını verse, kontrolü de kendisi yapsın.
İkincisi, deploy’u “dosyalar yüklendi” diye doğrulamak yetmiyor. Bugün ekleyeceğim asıl kontrol şu: deploy sonrası başlıkları ve temsilî bir aracı gerçekten çalıştıran bir duman testi. Yeşil yanan ama ölü bir sistemi bir kez daha yaşamak istemiyorum.
Bu arada DNS tarafında da bir ders çıktı: hesabımın API token’ı Zone → DNS → Edit yetkisini taşımıyordu, panelde gördüğüm “DNS Settings” izni bunun yerine geçmiyor. Çözüm, zaten sunucuda duran Cloudflare Tunnel’ın kendi token’ını kullanmak oldu — cloudflared tunnel route dns komutu da onu kullanıyor. Yetki, aradığınız yerde değil, sistemin kendi içinde olabiliyor.
Elimde iki kopya var: evde 8090’da tam sürüm, dünyaya açık adreste ofis dönüşümü olmayan 117 araçlık sürüm. PDF işlerim hâlâ aynı hızda ilerliyor. Değişen tek şey, dosyalarımın kime gittiğini bilmem.