Ayda 50 dolarlık bir Hetzner kutusunda beş prodüksiyon uygulaması
Vercel faturası 312 dolardı. Hetzner faturası 11,66 euro. Altı ay sonra kutunun içinde gerçekten ne var, yazıyorum.
Son Vercel faturam 312 dolardı. Bir önceki 287. Üç Next.js sitem, ücretsiz tier'ı çoktan aşmış bir Postgres'im ve bağlantı başına ücretlendirilen bir Redis'im vardı. Bir hafta sonu içinde hepsini tek bir Hetzner CAX21 ARM sunucusuna taşıdım. Bir sonraki ay fatura 11,66 euro geldi, üstüne küçük bir staging kutusu için 4,49 euro. Altı ay geçti; o iki kutuda beş şey çalışıyor ve ayda 16,15 euro ödüyorum. Aşağıda kutuların içinde gerçekten ne olduğunu anlatıyorum.
Kutular
- Kutu A — CAX21. 4 vCPU ARM, 8 GB RAM, 80 GB NVMe, Falkenstein. €11,66/ay.
- Kutu B — CAX11. 2 vCPU ARM, 4 GB RAM, 40 GB NVMe, Helsinki. €4,49/ay.
Hepsi bu. Load balancer yok, managed Postgres yok, Redis-as-a-service yok. Hetzner private network üzerinden birbirine bağlı iki Ampere ARM kutusu. İkisi de düz Debian 12 koşturuyor.
ARM'a geçmemin sebebi sıradan: küçük bir ajans işleten bir arkadaşım eşdeğer x86 kutularını kullanıyordu ve aynı RAM için ciddi şekilde daha fazla ödüyordu. ARM tarafı yaklaşık yüzde 40 daha ucuz. Çalıştırdığım hiçbir şey mimariyle derdi olmadı; sadece bir Sharp build'i için Dockerfile'a --platform=linux/arm64 eklemem gerekti, o kadar.
Kutu A'da ne çalışıyor
Tek bir docker-compose içinde, tek bir proje dizininde, tek bir non-root kullanıcı altında:
- Postgres 17. Tek instance, dört veritabanı, role bazlı izolasyon.
stork_prod,nova_prod,portfolio,analytics. Her role kendi veritabanının sahibi, başka yerde hiçbir hakkı yok. Multi-instance yaklaşımını iki gün denedim, saçma çıktı. Kavga etmeyi bırakırsanız Postgres multi-tenancy'yi temiz şekilde halledebiliyor. - Redis 7. Üç farklı index üzerinde üç mantıksal veritabanı. Cache 0'da, job queue 1'de, session'lar 2'de. Bir de şimdi tam 64 karakter olduğunu kesin olarak bildiğim bir
requirepass— sebebini birazdan anlatacağım. - Caddy 2. Beş domain, otomatik HTTPS, tek Caddyfile. Eşdeğer Nginx config'inin kabaca üçte biri uzunluğunda.
- Stork warehouse-test. Go API ile stork-admin-panel build'i, ikisi de Caddy arkasında subdomain'lerde.
- nova-api. Aynı compose dosyasında iki Go mikroservisi; Docker iç ağında birbirleriyle konuşuyorlar.
- Prometheus + Grafana. Evet, aynı kutuda. Prometheus binary boşta 60 MB, Grafana 35 MB civarı. Komple gözlemleme stack'i mevcut belleğin yüzde birini kullanıyor. Gerektiği gün taşırım.
Kutu A'nın load average'ı normal bir öğleden sonra 0,6 civarında oturuyor; gece Postgres dump'ı koşunca 1,8'e çıkıyor. CPU steal sıfır; ARM dedicated core'lar paylaşılmıyor. Eskiden bir VPS sağlayıcısında shared bir kutum vardı, vmstat 1 çıktısının st sütunu sürekli kararsızca titrerdi. Burada saatlerce baktım, kıpırdamadı. Bu önemli bir fark, çünkü Postgres autovacuum'unun ne kadar süreceğini tahmin etmek, ancak kimse CPU'mu çalmıyorsa anlamlı oluyor.
Kutu B'de ne çalışıyor
- Postgres 17 — test instance. Prod verisinin örneği. Her pazar 03:00'te Kutu A'dan private network üzerinden
pg_dumpçeken bir script tazeliyor; yeni bir şifre ve anonimleştirilmiş bir users tablosuyla geri yüklüyor. - Membrane AI prototipi. Bir yan proje için Python FastAPI servisi. Burada duruyor, çünkü yarım kalmış bir prototipi prod veritabanının yanına koymayı içime sindiremedim.
Kutu B'ye sadece Kutu A'dan, 10.0.0.0/16 private network üzerinden ulaşılıyor. Public IP'si var, ama firewall ev IP'mden gelen SSH ve ICMP haricinde her şeyi reddediyor.
Neden Kubernetes değil
Operatör zamanını hesapladım. K8s bana ayda kabaca 4 saatlik bakım demek olurdu: yükseltmeler, cert rotasyonu, gece bir pod restart ettiğinde kaçınılmaz olan kubectl describe pod arkeolojisi. Beş uygulama docker-compose up -d içine rahatça sığıyor. Compose'a göre ayda belki 30 dakika deployment ergonomisi kazanırım. Takas kötü.
Aynı şekilli bir proje için önce K8s'i deneyen meslektaşım bu işe üç hafta sonu yaktı, sonra compose'a geri döndü. Kararı kolaylaştıran o hikaye oldu.
Neden Caddy, neden Nginx değil
İki sebep. Caddyfile, bir config dosyası nasıl okunmalıysa öyle okunuyor. Bir de ACME içinde geliyor. Hayatımda hiç certbot cron'u yazmadım, başlamaya da niyetim yok.
# /etc/caddy/Caddyfile — beş domain, tek dosya
{
email isikozgur35@gmail.com
}
ozgurdamar.dev {
reverse_proxy portfolio:3000
encode zstd gzip
}
api.stork-test.dev {
# iç Docker DNS — Caddy ve stork-api aynı compose network'ünde
reverse_proxy stork-api:8080
}
admin.stork-test.dev {
reverse_proxy stork-admin:3000
}
nova-test.dev, *.nova-test.dev {
reverse_proxy nova-api:8081
}
grafana.ozgurdamar.dev {
# sadece ev IP'me dashboard açılır
@home client_ip 92.45.xx.0/24
handle @home { reverse_proxy grafana:3000 }
handle { respond 403 }
}Caddy sertifikaları ben olmadan yeniliyor. Geçen hafta her şeyi sessizce yenilediğini ancak fark ettim, çünkü altı ay önce kurduğum bir Slack hatırlatıcısı çaldı ve gidip kontrol ettim.
Compose dosyası
Tüm stack /srv/box-a/docker-compose.yml altında, deploy kullanıcısının sahipliğinde. İlginç kısımlar:
# docker-compose.yml — sadece yük taşıyan servisler
services:
postgres:
image: postgres:17-bookworm
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
# açıkça collation — taşınmadan sonra Ubuntu default'u bozdu
LANG: C.UTF-8
LC_COLLATE: C.UTF-8
volumes:
- pgdata:/var/lib/postgresql/data
secrets: [pg_password]
redis:
image: redis:7-bookworm
restart: unless-stopped
# şifre dosyadan — `docker inspect` çıktısına asla düşmesin
command: ["redis-server", "/etc/redis/redis.conf"]
volumes:
- ./redis.conf:/etc/redis/redis.conf:ro
caddy:
image: caddy:2
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on: [stork-api, stork-admin, nova-api, portfolio]restart: unless-stopped ve aşağıdaki systemd unit'i ile birlikte, kutu yeniden başlasa bile her şey ben hiç bakmadan geri geliyor.
Her şeyi ayakta tutan systemd unit'i
Bu kurulumu kopyalayacak birine "şunu atlama" diyebileceğim tek şey bu. Docker daemon boot'ta geliyor; ama compose projen, sen söylemedikçe gelmiyor.
# /etc/systemd/system/box-a.service
[Unit]
Description=Box A docker-compose stack
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/box-a
# `up -d` idempotent — retry güvenli; stop'ta `down` volume'leri temiz bırakıyor
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=300
[Install]
WantedBy=multi-user.targetBir kez systemctl enable box-a, sonra unut. Kutu altı ayda iki kez yeniden başladı: biri kernel update yüzünden, biri de now yerine shutdown -r yazdığım için. İkisinde de her şey 90 saniye içinde geri geldi. Telefonum sustu, hiçbir alert düşmedi, Caddy sertifikaları unutmadı. Kutu yeniden başladığında bir servis geri gelmiyorsa, neden genelde sürpriz bir bağımlılık zinciri oluyor. Bu yüzden depends_on listesi sıkıştırılmış değil; her servisin gerçek başlangıç sırasını yansıtıyor.
Yedekler, ki asıl pahalı şey o
Yedekler restic ile şifrelenip Hetzner Storage Box'a gidiyor. Ayda 3,20 euro, 1 TB sınır, ben 47 GB civarı kullanıyorum. Script her gece 03:30'da cron altında koşuyor:
#!/usr/bin/env bash
# /srv/box-a/scripts/nightly-backup.sh — pg_dump + restic, herhangi bir hatada non-zero exit
set -euo pipefail
export RESTIC_PASSWORD_FILE=/etc/restic.password
export RESTIC_REPOSITORY=sftp:u123456@u123456.your-storagebox.de:/backups
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DUMP_DIR="/var/backups/pg/${STAMP}"
mkdir -p "$DUMP_DIR"
for DB in stork_prod nova_prod portfolio analytics; do
# custom format — sonra paralel restore, hat üzerinde sıkıştırılmış
docker exec postgres pg_dump -Fc -d "$DB" > "${DUMP_DIR}/${DB}.dump"
done
restic backup "$DUMP_DIR" --tag nightly --tag pg
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
rm -rf "$DUMP_DIR"Her çeyrekte bir restore tatbikatı yapıyorum: dün geceki snapshot'ı Kutu B'ye çek, farklı bir veritabanı adı altında geri yükle, bilinen stabil bir tabloya count(*) çek. Her şey yolundaysa üç dakika sürüyor. Bu tatbikatı iki kez yaptım; bir kez eksik bir role grant'ı yakaladı.
Taşınmada üç şey bozuldu
Bir. Postgres collation farkı. Ubuntu'da default en_US.UTF-8 idi; taze Debian image'i C.UTF-8 ile geldi. Stork warehouse migration'ı temiz koştu, ama Türkçe ürün adlarındaki bir sıralama sorgusu yanlış sırayla döndü. Bug küçüktü — Ş, S'den sonra değil Z'den sonra sıralanıyordu — ve bulmam üç saatimi aldı; çünkü semptom bir stack trace değil, bir UX şikayetiydi. Çözüm, compose dosyasındaki açık LC_COLLATE=C.UTF-8 ve etkilenen indekslere tek seferlik bir ALTER COLLATION oldu.
İki. Next.js standalone build bellek tavanı. Önce runner'ı park ettiğim CAX11'de build etmeyi denedim. Build iki kez OOM-killed oldu. CAX11'in 4 GB'ı, analytics sayfa bundle'ı dahil next build için yeterli alan değildi. Build'i Kutu A'ya taşıdım — 8 GB fazlasıyla yetiyor — ve standalone çıktıyı private network üzerinden deploy dizinine rsync'liyorum. A'da build, B'de çalış, deploy-symlink. On dakikalık iş, sorun bitti.
Üç. Prod'a neredeyse sızan bug. Ürettiğim Redis şifresi 88 karakterdi: openssl rand 64'ten base64. Tırnaksız bir şekilde .env'e koydum. Docker compose, ilk + karakterinde sessizce kesti — çünkü bazı bağlamlarda shell'den geçince + bir YAML control oluyor. Container 6379'da yükseldi, kesilmiş önekle AUTH kabul etti, ama tam olarak çözemediğim bir etkileşim yüzünden hiç şifre olmadan da kabul etti. Yakalama anı şuydu: Kutu B'den redis-cli -h 10.0.0.2 PING çalıştırdım, kimlik bilgisi vermedim, PONG döndü. Kutu B'nin bunu yapabilmemesi gerekiyordu. Şifreyi + karakteri olmadan döndürdüm; artık Redis şifrelerini openssl rand -hex 32 ile üretiyorum — tam 64 hex karakter, shell-özel byte yok.
İnternete açık şifresiz bir Redis çalıştırmama tek bir docker compose up mesafedeydim. Taşınma penceresi yılın en riskli 48 saatiydi ve neredeyse bana mal olacak olan bug bu oldu.
Maliyet tablosu, nihayet
| Şey | Önce (Vercel/managed) | Şimdi (Hetzner) |
|---|---|---|
| Compute (3 Next.js sitesi) | $79 | dahil |
| Postgres (managed, 8 GB) | $135 | dahil |
| Redis (managed) | $58 | dahil |
| Bandwidth | $32 | dahil (ayda 20 TB) |
| Storage Box (yedekler) | — | €3,20 |
| Aylık toplam | $312 | €19,35 (~$21) |
Kabaca 14 kat maliyet düşüşü. Girişte kendime fısıldadığım 18 kat değil — zarfın arkasında hesap yaparken Storage Box'ı unutmuşum. Taşınma haftasonu harcadığım sekiz saati katarsam matematik kötüleşiyor, ama o saatler altı ay sonra neredeyse bedavaya amortise oldu.
Sezgiye ters parça
Serverless işe başladığımda ucuzdu. Başardığımda pahalı oldu. Managed PaaS'in maliyet eğrisi lineer değil; tam da uygulamanın en kırılgan olduğu anlarda merdiven gibi sıçrayan bir şey. Portfolyonun bir Hacker News linkinden beklenmedik trafik aldığı gün, Vercel'in bana fatura uyarısı yolladığı gündü. Warehouse-test API'nin Postgres'e 50 bağlantıyı geçtiği gün, bağlantı başına fiyatlandırılan Redis tier'ı faturamın en pahalı satırı oldu.
Sıkıcı ARM kutusu, uygulamamın iyi bir hafta geçirip geçirmediğini bilmiyor. Sadece çalışıyor.
Şunu yapma eğer
OS katmanında debug etmekten zevk almıyorsan yapma. Postgres collation bug'ı üç saatimi aldı. Redis şifre bug'ı kötü bitebilirdi. Caddy yenileme cron'u yok çünkü Caddy hallediyor — ama Caddy'ye güvenmen gerek; güvenmek için docs'u sıyırmak yerine bir kez oturup okumak gerek. journalctl -u box-a ve docker compose logs --tail 200 kas hafızanda değilse, ayda kazanılan 290 dolar, ilk sabah 2'de gelen page'in götürdüğü uykuya değmiyor.
Ben zevk alıyorum. Dinlendirici buluyorum. Tesadüfen bedava olan bir hobi. Bir managed servisin abstraction'ı kırıldığında, çoğu zaman support ticket'ı kazıyorum demektir. Kendi kutumda bir şey kırıldığında, journalctl açıyorum ve cevabı kendim buluyorum. İkisi de iş, ama biri kontrol hissi veriyor, diğeri bekleme hissi.
Easter egg
Faturadaki en pahalı şey hâlâ yedeklerin Storage Box'ı. Yedekler, ihtiyacın olana kadar ucuz görünüyor. Ayda 3,20 euro — asla optimize etmeyeceğim tek satır.
// madem buradasın
- 7 dk okuma
Agentic AI guardrail'leri: while(true) döngüsünü token bütçeni yakmasından durdurmak
Vibes-döngüsünü production'da gerçekten çalıştırabileceğin bir şeye çeviren dört guardrail: bütçe zarfı, retry eğrileri, break koşulları ve gerçek bir fallback zinciri.
agentic-aiproductionengineering-lessons - 10 dk okuma
Satış ekibim olmadan 300 bin dolarlık bir engagement'ı nasıl fiyatladım
Dört konuşma, 165 dakika, sunum yok, tek sayfadan uzun teklif yok. 300.000 dolarlık bir sözleşmenin arkasındaki matematik.
kariyerdanışmanlıkfiyatlamaiş