Mert Özen Her Satırda Daha İleri
DevOps SSL/TLS Certificate'ları ve HTTPS — #05

Yönetilen Platformlarda Certificate: Caddy, Azure App Service, Key Vault, Front Door ve Kubernetes'te cert-manager

Mert Özen 14 Kas 2026 17 dk 4 görüntülenme
Yönetilen Platformlarda Certificate: Caddy, Azure App Service, Key Vault, Front Door ve Kubernetes'te cert-manager

Managed platformlarda certificate'ı platform alır ve yeniler, ama her birinin seni yakalayacak bir sınırı vardır: Caddy /data volume'ü olmadan rate limit'e takılır, App Service managed certificate wildcard vermez, Front Door doğrudan CNAME ister, cert-manager her yenilemede key'i değiştirir. Caddy, cert-manager ve Envoy Gateway'i Pebble ile gerçek koşularla çalıştırıp pinning'in yenilemede nasıl kırıldığını görüyoruz.

Sürüm notu: Bu yazıdaki örnekler Caddy 2.11.9, cert-manager 1.21.3, cmctl 2.6.2, Envoy Gateway 1.10, kind 0.33 ile Kubernetes 1.37, Helm 4.3 ve Let's Encrypt'in test sunucusu Pebble (Kasım 2026 image'ı) ile Docker 29 üzerinde denenmiştir; Azure bölümleri çalıştırılmamış, Microsoft Learn'ün Kasım 2026 tarihli sayfalarından aktarılmıştır.

Önce Tahmin Et

Bookstore'un mobil uygulaması, API'ye giderken sunucunun public key'ini pin'liyor: TLS handshake'inde gelen certificate'ın public key hash'i uygulamanın içine gömülü değerle aynı değilse bağlantıyı kesiyor. Bu kontrol, API Nginx ve Certbot ile çalışırken iki yıl boyunca sorun çıkarmadı; çünkü ekip Certbot'u --reuse-key ile çalıştırıyordu ve yenilemelerde key değişmiyordu. Şimdi platform ekibi API'yi Kubernetes'e taşıyor ve certificate'ı cert-manager alacak.

Tahmin et: cert-manager certificate'ı ilk kez yenilediğinde mobil uygulamaya ne olur? Bir de ikinci soru: aynı ekip bir test ortamında Caddy'yi container olarak çalıştırıyor ve her deploy'da container'ı silip yeniden oluşturuyor. Haftada beş deploy yaparlarsa Let's Encrypt ne der? İkisini de gerçek koşularla göreceğiz.

Caddy: Sıfır Config ile Otomatik HTTPS

Serinin önceki iki yazısında certificate'ı bir ACME (sertifika alma ve yenilemeyi otomatikleştiren protokol) client'ı alıyor, sunucu ise dosyadan okuyordu; aradaki bağı deploy hook'ları kuruyordu. Caddy bu ikisini tek process'te birleştiriyor. Config'te bir hostname görmesi yeterli: certificate'ı alır, saklar, yeniler ve HTTP'yi HTTPS'e (HTTP Secure) yönlendirir. Labda Let's Encrypt yerine Pebble'ı kullandığım için issuer'ı açıkça yazdım; gerçek kurulumda tls bloğunun tamamı gereksiz, çünkü Caddy varsayılan olarak Let's Encrypt'i, olmazsa ZeroSSL'i dener:

{
	email ops@bookstore.example
}

api.bookstore.example {
	tls {
		issuer acme {
			dir https://pebble:14000/dir
			trusted_roots /etc/caddy/pebble.minica.pem
			profile shortlived
		}
	}
	respond "ok from {host}" 200
}

profile shortlived, üçüncü yazıda gördüğümüz ACME profillerinden 6 günlük olanı istiyor; Caddy dokümanı bu ayarı, profil spesifikasyonu hâlâ taslak olduğu için deneysel diye işaretliyor. Container'ı başlatıp log'u süzdüm:

$ docker run -d --name tlsseries-caddy --network tlsseries-net --network-alias api.bookstore.example \
    -v ./caddy:/etc/caddy:ro -v tlsseries-caddy-data:/data caddy:2
$ docker logs tlsseries-caddy 2>&1 | jq -r 'select((.logger // "")|test("tls.obtain|acme_client|auto_https"))
    | [.logger, .msg, (.challenge_type // .issuer // "")] | @tsv'
http.auto_https	server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS
http.auto_https	enabling automatic HTTP->HTTPS redirects
tls.obtain	acquiring lock
tls.obtain	lock acquired
tls.obtain	obtaining certificate
http.acme_client	trying to solve challenge	http-01
http.acme_client	authorization finalized
http.acme_client	validations succeeded; finalizing order
http.acme_client	got renewal info
http.acme_client	successfully downloaded available certificate chains
tls.obtain	certificate obtained successfully	pebble:14000-dir
tls.obtain	releasing lock

Container ayağa kalktıktan beş saniye sonra certificate hazırdı. Caddy hem HTTP-01'i hem TLS-ALPN-01'i (Application-Layer Protocol Negotiation) açık tutuyor; iki koşumdan birinde HTTP-01'i, diğerinde 443 üzerinden TLS-ALPN-01'i seçti. Yani 80 portu bir sebeple kapalıysa doğrulama yine de geçebiliyor. Sonucu container'ın içinden kontrol ettim:

$ curl -sI http://api.bookstore.example/ | head -3
HTTP/1.1 308 Permanent Redirect
Connection: close
Location: https://api.bookstore.example/
$ ls -l /data/caddy/certificates/pebble-14000-dir/api.bookstore.example/
-rw-------    1 root     root          1271 Nov 13 22:29 api.bookstore.example.crt
-rw-------    1 root     root           509 Nov 13 22:29 api.bookstore.example.json
-rw-------    1 root     root           227 Nov 13 22:29 api.bookstore.example.key
$ openssl x509 -in /data/caddy/certificates/pebble-14000-dir/api.bookstore.example/api.bookstore.example.crt \
    -noout -issuer -serial -dates
issuer=CN=Pebble Intermediate CA 160d5b
serial=6D7F1A0942A2A3E3
notBefore=Nov 13 22:29:27 2026 GMT
notAfter=Nov 19 22:29:26 2026 GMT
$ curl -sS --cacert /tmp/root.pem https://api.bookstore.example/
ok from api.bookstore.example

Redirect 308 ile geliyor; 301'den farkı, method'u ve body'yi korumasını istemesi. Certificate, key ve metadata /data altında yalnızca root'un okuyabileceği dosyalar olarak duruyor. Certbot'taki live/ ve archive/ karmaşası, symlink'ler ve deploy hook yok; sunucu ile ACME client'ı aynı process olduğu için yenilenen certificate bellekte doğrudan değişiyor.

Caddy'de Yenileme, ARI ve /data Volume'ü

Log'daki got renewal info satırı ARI (CA'nın sertifikanın ne zaman yenilenmesi gerektiğini bildirmesi). RFC 9773 ile tanımlanan bu uzantı sayesinde Caddy, certificate'ı alır almaz CA'ya (sertifika veren güvenilir kuruluş) ne zaman yenilemesi gerektiğini sormuş:

$ docker logs tlsseries-caddy 2>&1 | jq -c 'select(.msg=="got renewal info")
    | {window_start: (.window_start|todate), window_end: (.window_end|todate),
       selected_time: (.selected_time|todate), recheck_after: (.recheck_after|floor|todate)}'
{"window_start":"2026-11-16T22:29:26Z","window_end":"2026-11-18T22:29:26Z","selected_time":"2026-11-18T15:29:50Z","recheck_after":"2026-11-14T04:29:32Z"}

6 günlük certificate için CA, 3. ve 5. günler arasında bir pencere önermiş. Caddy bu pencere içinden bir an seçmiş ve pencereyi altı saat sonra yeniden soracak. CA bir toplu iptal kararı alırsa pencereyi geri çeker ve Caddy bir sonraki sorguda erkenden yeniler; üçüncü yazıda Certbot ile gördüğümüz davranışın aynısı. ARI olmayan bir CA'da Caddy, kalan ömür üçte bire indiğinde yeniler (renewal_window_ratio, varsayılan 0.3333). Hata durumunda da sessiz kalmıyor: Pebble'a ulaşamadığım ilk denemede log, 60 saniye sonra tekrar deneyeceğini ve toplam deneme süresinin üst sınırının 2592000 saniye, yani 30 gün olduğunu yazdı. Dokümana göre denemeler arası bekleme en fazla bir güne kadar uzuyor.

Şimdi Önce Tahmin Et'in ikinci sorusu. Caddy'nin bütün durumu /data klasöründe: ACME account key'i, certificate'lar, ARI bilgisi. Container'ı dört kez sildim ve yeniden oluşturdum; ilk ikisinde /data bir named volume'deydi, son ikisinde hiç volume yoktu:

#!/bin/sh
# Caddy container'ını yeniden oluşturur ($1 = ek docker argümanları) ve sunulan serial'ı yazar
docker rm -f tlsseries-caddy >/dev/null
docker run -d --name tlsseries-caddy --network tlsseries-net --network-alias api.bookstore.example \
    -v ./caddy:/etc/caddy:ro $1 caddy:2 >/dev/null
sleep 8
printf "obtained=%s " "$(docker logs tlsseries-caddy 2>&1 | grep -c 'certificate obtained successfully')"
docker exec tlsseries-caddy sh -c 'apk add -q openssl >/dev/null 2>&1; echo | openssl s_client -connect localhost:443 -servername api.bookstore.example 2>/dev/null | openssl x509 -noout -serial'
$ ./recreate.sh "-v tlsseries-caddy-data:/data"
obtained=0 serial=6D7F1A0942A2A3E3
$ ./recreate.sh "-v tlsseries-caddy-data:/data"
obtained=0 serial=6D7F1A0942A2A3E3
$ ./recreate.sh ""
obtained=1 serial=4A2D36F8F0CBDE6A
$ ./recreate.sh ""
obtained=1 serial=0743CC64EACD3BCA

Volume varken Caddy diskteki certificate'ı yükleyip CA'ya hiç gitmiyor. Volume yoksa her başlangıç yeni bir account ve yeni bir certificate demek. Pebble buna ses etmiyor, Let's Encrypt ise eder: aynı isim kümesi için yedi günde en fazla beş yeni certificate veriyor ve bu limit için override kabul etmiyor. Haftada beş deploy yapan ekibin altıncı deploy'u certificate'sız açılır ve kapasite 34 saatte bir certificate hızıyla geri doluyor. ARI ile koordine edilen yenilemeler bütün rate limit'lerden muaf; ama sıfırdan alınan certificate yenileme sayılmaz. Caddy'yi container'da çalıştırıyorsan /data kalıcı bir volume'de olmalı; birden fazla instance varsa da aynı storage'ı paylaşmalı. Caddy paylaşılan storage üzerinden lock alarak aynı certificate'ı yalnızca bir instance'ın istemesini sağlıyor; log'daki acquiring lock satırı bu.

Caddy mi, Nginx + Certbot mu?

Web Server'lar ve Hosting serisinin "Hosting Seçimi ve Production Checklist'i" yazısında Caddy'yi bir seçenek olarak anmıştık. Certificate açısından karar şu sorulara indirgeniyor. Yeni bir sunucu mu kuruyorsun, yoksa yıllardır bakımı yapılan bir Nginx config'i mi var? Yeni kurulumda, özellikle tek makinede birkaç site varsa, Caddy hareketli parça sayısını ciddi azaltıyor: timer, hook, reload, dosya izni yok. Mevcut Nginx'te ise üçüncü yazıdaki Certbot kurulumu zaten çalışıyor; sırf certificate için web server değiştirmek, rate limiting, cache ve header ayarlarını yeniden yazmak demek.

İkinci soru, çok sayıda ve önceden bilinmeyen domain'in olup olmadığı. Bookstore'un ileride yayıncılara kendi domain'lerini bağlama imkânı verdiğini düşün. Caddy'nin on-demand TLS özelliği, certificate'ı o isim için gelen ilk handshake sırasında alıyor. Bunu açarken bir ask endpoint'i ile hangi domain'lerin kabul edileceğini mutlaka sınırlamalısın; yoksa birisi sunucuna rastgele isimler yönlendirip senin adına rate limit tüketir. Nginx + Certbot ile bu senaryo ciddi bir otomasyon işi.

Üçüncü soru gözlenebilirlik. Certbot'ta yenileme ayrı bir process ve çıkış kodu, log dosyası, systemd durumu üzerinden izlenir. Caddy'de yenileme JSON (JavaScript Object Notation) log'unun içinde. Hangisini seçersen seç, yenilemenin başarısını dışarıdan ölçmek gerekiyor; serinin son yazısının konusu bu.

Azure: App Service, Key Vault ve Front Door

Bu bölüm bir Azure hesabı gerektirdiği için çalıştırılmadı; kurallar Microsoft Learn'den. Managed certificate'larda işin büyük kısmı platformda, ama her birinin seni yakalayacak bir sınırı var.

App Service Managed Certificate

Ücretsiz managed certificate'ı DigiCert veriyor ve App Service yeniliyor; Basic veya üstü bir plan gerekiyor. Sınırları baştan bil: wildcard desteklenmiyor, certificate dışa aktarılamıyor (yani aynı certificate'ı başka bir yerde kullanamazsın), private DNS ve App Service Environment desteklenmiyor, domain en fazla 64 karakter olabiliyor. Apex domain (bookstore.example) için uygulamanın IP'sini gösteren bir A kaydı, alt domain için de doğrudan <app>.azurewebsites.net'e giden bir CNAME (bir alan adını başka bir alan adına yönlendiren DNS kaydı) gerekiyor. Arada başka bir CNAME varsa certificate ne alınabiliyor ne yenilenebiliyor. Domain'inde CAA (hangi CA'ların sertifika verebileceğini belirten DNS kaydı) kaydı varsa 0 issue digicert.com eklemen gerekiyor; ikinci yazıda gördüğümüz gibi CAA, listede olmayan CA'yı reddettirir.

2025'te bu servis iki kez değişti. 28 Temmuz 2025'te DigiCert, MPIC (Multi-Perspective Issuance Corroboration, doğrulamanın birden fazla ağ noktasından yapılması) kuralına uymak için doğrulamayı /.well-known/pki-validation/fileauth.txt adresine HTTP isteği atmaya çevirdi. Sonuç: IP kısıtlaması, private endpoint veya kapalı public erişim olan uygulamalarda certificate ne alınabildi ne yenilenebildi. Kasım 2025'te Microsoft bu isteği App Service'in front-end katmanında cevaplamaya başladı; istek artık uygulamanın worker'larına ulaşmıyor ve public erişim şartı kalktı. Hâlâ geçerli olan şart, domain'in public DNS'te çözülmesi; Traffic Manager'ın "nested" ve "external" endpoint'leri de desteklenmiyor. Bu iki değişiklik managed certificate'ın asıl özelliğini gösteriyor: doğrulama yöntemi senin kontrolünde değil ve bir CA politikası değiştiğinde yenileme sessizce kırılabiliyor.

Key Vault'tan Certificate

Wildcard, dışa aktarılabilir key veya belirli bir CA gerekiyorsa certificate'ı Key Vault'ta tutup App Service'e import edersin. App Service, PKCS (public key kriptografisi için standartlar ailesi) #12, yani PFX (Personal Information Exchange) formatındaki certificate'ı vault'tan okur ve vault'taki yeni sürümü 24 saat içinde kendisi senkronize edip binding'i günceller; beklemek istemezsen portaldaki Sync ile elle tetiklersin. Yetki verirken sık yapılan hata, uygulamanın managed identity'sine rol vermek. Okuyan uygulama değil, App Service resource provider'ı: Microsoft.Azure.WebSites service principal'ına Key Vault Certificate User rolü (veya access policy'de secret ve certificate için Get) verilmeli. Key Vault certificate'ı entegre CA'larından (DigiCert, GlobalSign) kendisi yenileyebiliyor. Let's Encrypt ile ACME'yi doğrudan konuşmuyor; bu durumda ACME client'ı ayrı bir job olarak çalıştırıp sonucu vault'a yazman gerekiyor.

Front Door'da Managed Certificate

Front Door Standard/Premium da DigiCert certificate'ı kullanıyor ve domain sahipliğini _dnsauth.<alt-domain> adlı bir TXT (text) kaydıyla doğruluyor. Otomatik yenileme bir şarta bağlı: custom domain'in CNAME kaydı doğrudan Front Door endpoint'ini göstermeli. CNAME bir zincir üzerinden geliyorsa, A kaydı kullanılıyorsa veya apex domain CNAME flattening ile bağlanmışsa, bitişe 45 gün kala domain "Pending Revalidation" durumuna düşüyor ve yeni bir TXT kaydı girmen gerekiyor. Wildcard için managed certificate alınabiliyor ama otomatik yenilenmiyor. Kendi certificate'ını Key Vault'tan veriyorsan secret sürümünü "Latest" seç; Front Door yeni sürümü kendisi alıyor, ama dokümana göre bu 72 saate kadar sürebiliyor. Bir de sürpriz: Front Door kendi getirdiğin certificate'ta EC (eliptik eğri) key kabul etmiyor, RSA (Rivest–Shamir–Adleman) gerekiyor.

Kubernetes'te cert-manager: ClusterIssuer ve Gateway

Kubernetes'te certificate otomasyonunun fiilî standardı cert-manager. Kubernetes serisinin "Kubernetes'te Dış Trafik: Ingress, ingress-nginx'in Emekliliği ve Gateway API ile HTTPRoute" yazısında Envoy Gateway kurmuş ve cert-manager'ı bir paragrafta anmıştık. Burada uçtan uca çalıştırıyoruz. Gateway API desteği Helm chart'ında varsayılan olarak kapalı; açtığın config, controller'ın configuration dosyasına yazılıyor:

$ helm install cert-manager oci://quay.io/jetstack/charts/cert-manager --version v1.21.3 \
    -n cert-manager --create-namespace --set crds.enabled=true \
    --set config.apiVersion=controller.config.cert-manager.io/v1alpha1 \
    --set config.kind=ControllerConfiguration --set config.gatewayAPI.enabled=true --wait
$ kubectl -n cert-manager logs deploy/cert-manager | grep -i "gateway api"
I1113 22:18:45.826508       1 options.go:296] "enabling the sig-network Gateway API certificate-shim and HTTP-01 solver" logger="cert-manager"

Gateway API CRD'leri (Kubernetes'e özel resource tipi eklemeyi sağlayan tanım) cert-manager başlamadan önce cluster'da olmalı; sonradan kurarsan cert-manager deployment'ını restart etmen gerekir. Burada CRD'leri önceden Envoy Gateway'in chart'ı getirmişti. Sonra CA'yı tanımlayan ClusterIssuer:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: acme-pebble
spec:
  acme:
    # production'da: https://acme-v02.api.letsencrypt.org/directory
    server: https://pebble.pebble.svc:14000/dir
    # yalnızca lab: Pebble'ın HTTPS certificate'ını imzalayan CA (base64 PEM)
    caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...
    email: ops@bookstore.example
    profile: shortlived
    privateKeySecretRef:
      name: acme-pebble-account
    solvers:
      - http01:
          gatewayHTTPRoute:
            parentRefs:
              - name: bookstore
                namespace: bookstore
                kind: Gateway
                sectionName: http

profile alanı cert-manager 1.18'den beri var. gatewayHTTPRoute solver'ı, challenge sırasında Gateway'in http listener'ına geçici bir HTTPRoute bağlıyor. Gateway'e ise tek bir annotation ekleniyor:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: bookstore
  namespace: bookstore
  annotations:
    cert-manager.io/cluster-issuer: acme-pebble
spec:
  gatewayClassName: eg
  listeners:
    - name: http
      protocol: HTTP
      port: 80
    - name: https
      protocol: HTTPS
      port: 443
      hostname: api.bookstore.example
      tls:
        mode: Terminate
        certificateRefs:
          - name: api-bookstore-example-tls

cert-manager'ın gateway-shim'i hostname'i dolu, mode: Terminate olan ve bir Secret'a referans veren her listener için bir Certificate resource'u üretiyor; Secret adı Certificate'ın adı oluyor. Labda api.bookstore.example gerçek bir domain olmadığı için CoreDNS'e bu ismi Envoy'un Service IP'sine çeviren bir hosts girdisi ekledim; Pebble'ı da cluster'ın içinde çalıştırdım. Uygulamadan yarım dakika sonra:

$ kubectl -n bookstore get certificate,certificaterequest,order
NAME                                                    READY   SECRET                      AGE
certificate.cert-manager.io/api-bookstore-example-tls   True    api-bookstore-example-tls   34s

NAME                                                             APPROVED   DENIED   READY   ISSUER        REQUESTER                                         AGE
certificaterequest.cert-manager.io/api-bookstore-example-tls-1   True                True    acme-pebble   system:serviceaccount:cert-manager:cert-manager   34s

NAME                                                                STATE   AGE
order.acme.cert-manager.io/api-bookstore-example-tls-1-2352046867   valid   34s
$ kubectl -n bookstore get events --field-selector involvedObject.kind=Challenge \
    -o custom-columns=TIME:.lastTimestamp,REASON:.reason,MSG:.message
TIME                   REASON           MSG
2026-11-13T22:23:56Z   Started          Challenge scheduled for processing
2026-11-13T22:23:56Z   Presented        Presented challenge using HTTP-01 challenge mechanism
2026-11-13T22:24:27Z   DomainVerified   Domain "api.bookstore.example" verified with "HTTP-01" validation

Zincir, ACME akışının Kubernetes resource'larına dökülmüş hali: Certificate bir CertificateRequest üretiyor, o bir Order, Order da her isim için bir Challenge. Hata ararken bu sırayla aşağı inersin. Challenge sürerken solver'ın oluşturduğu route'u yakalamak için ikinci bir isimle bir Certificate daha istedim:

$ kubectl -n bookstore get httproute cm-acme-http-solver-bd8dd -o yaml | sed -n '/^spec:/,/^status:/p'
spec:
  hostnames:
  - shop.bookstore.example
  parentRefs:
  - group: gateway.networking.k8s.io
    kind: Gateway
    name: bookstore
    namespace: bookstore
    sectionName: http
  rules:
  - backendRefs:
    - group: ""
      kind: Service
      name: cm-acme-http-solver-75f57
      namespace: bookstore
      port: 8089
      weight: 1
    matches:
    - path:
        type: Exact
        value: /.well-known/acme-challenge/FzJpcQV2qLepJyM8BTD8OGgQvqqLPTfj6lqf3wHfW5I
status:

Route, token'ın tam path'ini geçici bir solver pod'una yönlendiriyor ve challenge bitince route, Service ve pod siliniyor. Bu yüzden http listener'ının allowedRoutes ayarı solver'ın namespace'ini kabul etmeli. Bir not: Gateway'in durumu labda Programmed=False ve sebebi AddressNotAssigned; kind'da LoadBalancer Service'ine dış IP veren bir şey yok. Testleri cluster içinden, Envoy Service'inin ClusterIP'si üzerinden yaptım:

$ kubectl -n bookstore get certificate api-bookstore-example-tls -o jsonpath='{.status}' | python3 -m json.tool
{
    "conditions": [
        {
            "lastTransitionTime": "2026-11-13T22:24:27Z",
            "message": "Certificate is up to date and has not expired",
            "observedGeneration": 1,
            "reason": "Ready",
            "status": "True",
            "type": "Ready"
        }
    ],
    "notAfter": "2026-11-19T22:24:26Z",
    "notBefore": "2026-11-13T22:24:27Z",
    "renewalTime": "2026-11-17T22:24:26Z",
    "revision": 1
}
$ curl -sS --cacert /tmp/acme-root.pem https://api.bookstore.example/api/health
ok from api.bookstore.example

renewalTime, 6 günlük ömrün üçte ikisi dolduğunda, yani 4. gün. cert-manager bu zamanı ACME'de CA'nın gerçekten verdiği ömre göre hesaplıyor.

cert-manager'da Yenileme: Key Her Seferinde Değişiyor

Şimdi Önce Tahmin Et'in birinci sorusu. Sunulan certificate'ın seri numarasını ve SPKI (sertifikadaki public key ve algoritma bilgisi) hash'ini, yani public key pinning'de kullanılan değeri yazan küçük bir script hazırladım ve yenilemeyi cmctl renew ile elle tetikledim:

#!/bin/sh
# sunulan certificate'ın serial'ı ve SPKI pin'i (public key'in sha256'sı)
echo | openssl s_client -connect api.bookstore.example:443 -servername api.bookstore.example 2>/dev/null > /tmp/s.txt
printf "%s serial=%s spki=%s\n" "$(date -u +%H:%M:%S)" \
  "$(openssl x509 -in /tmp/s.txt -noout -serial | cut -d= -f2)" \
  "$(openssl x509 -in /tmp/s.txt -noout -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64)"
$ cmctl renew -n bookstore api-bookstore-example-tls
Manually triggered issuance of Certificate bookstore/api-bookstore-example-tls
22:25:10 serial=0EDA07096CB27DF2 spki=A/ssnb8nlizg+uwGXPx7trWqlWSTJANl6L04YlsNtcE=
22:25:11 serial=440C9D30EB1CFCC4 spki=yB+4ybCMS7lY5kgRMYWIPaAd3XnGLLtAnBWWsWRjLjI=
22:25:13 serial=440C9D30EB1CFCC4 spki=yB+4ybCMS7lY5kgRMYWIPaAd3XnGLLtAnBWWsWRjLjI=
...
$ curl -sS --cacert /tmp/acme-root.pem --pinnedpubkey "sha256//A/ssnb8nlizg+uwGXPx7trWqlWSTJANl6L04YlsNtcE=" \
    https://api.bookstore.example/api/health
curl: (90) SSL: public key does not match pinned public key

İki saniye içinde Envoy yeni certificate'ı sunmaya başladı; restart veya reload yok, çünkü Envoy Gateway Secret'taki değişikliği Envoy'a kendisi iletiyor. Ama SPKI değişti. cert-manager 1.18'de rotationPolicy'nin varsayılanı Never'dan Always'e döndü; 1.19'da bu varsayılanı geri çeviren feature gate kaldırıldı ve eski davranış artık yalnızca Certificate başına açıkça istenerek alınabiliyor. Yani her yenilemede yeni bir private key üretiliyor. Bu güvenlik açısından doğru tercih: sızmış bir key en fazla bir certificate ömrü kadar işe yarar. Önce Tahmin Et'in cevabı: mobil uygulama ilk yenilemede, curl'ün 90 koduyla düştüğü gibi, bağlantıyı reddeder. Bu kesinti mağaza güncellemesi yayınlanana kadar sürer.

Key'i sabitlemek mümkün: Gateway'e cert-manager.io/private-key-rotation-policy: Never annotation'ı eklersin. Ama bu sorunu çözmüyor, erteliyor. Let's Encrypt intermediate'ını değiştirdiğinde, sen CA değiştirdiğinde ya da key sızdığında pin yine kırılır. Managed platformlarda durum daha da net: App Service ve Front Door dokümanları, certificate'ın root dahil her parçasının haber vermeden değişebileceğini söylüyor ve pinning yapmamanı açıkça istiyor. Pinning gerçekten gerekiyorsa en azından yedek bir key'in pin'ini de uygulamaya gömmen gerekir; ama 2026'da çoğu mobil ve servis senaryosunda doğru cevap, pinning'i bırakıp chain doğrulamasına ve Certificate Transparency izlemesine güvenmek.

cert-manager 1.21, ARI desteğini ACMEUseARI feature gate'inin arkasında deneysel olarak getirdi; 1.21.3 ise ARI yenilemesindeki bir döngü hatasını düzeltti. Production'da varsayılan, ömrün üçte ikisinde yenileme; ARI'yi açmadan önce sürüm notlarını takip et.

Edge Certificate'ı ve Origin Certificate'ı: Yenilemeyi Kim Yapıyor?

Önünde Front Door, Cloudflare gibi bir CDN (statik dosyaları kullanıcıya yakın sunuculardan dağıtan ağ) varsa iki ayrı TLS bağlantısı vardır ve her birinin ayrı certificate'ı olur. Tarayıcının gördüğü edge certificate'ını CDN yönetir. CDN'in origin'e bağlanırken gördüğü origin certificate'ı ise senin sorumluluğundadır. Front Door origin'den, origin hostname'iyle eşleşen ve Microsoft'un güvendiği bir CA'dan çıkmış tam zincirli bir certificate bekliyor. Internal CA veya self-signed certificate kabul etmiyor. İsim kontrolü kapatılabiliyor, ama Microsoft güvenlik açısından bunu önermiyor. Cloudflare'in kendi modları ve yalnızca Cloudflare'in güvendiği origin certificate'ları ise Domain, DNS ve CDN serisinin konusu. Pratikte origin'de de bu yazıdaki araçlardan biri çalışır: App Service managed certificate, cert-manager ya da Caddy. Böylece iki certificate'ın da yenilenmesi otomatik olur. Sorumluluk dağılımının özeti:

KurulumCertificate'ı kim yeniliyorSenin işinSeni yakalayacak sınır
Nginx + CertbotCertbot (systemd timer)Deploy hook ile nginx -s reload, 80 portuHook'u unutmak: yeni certificate diskte, eskisi bellekte
Kestrel + CertbotCertbotDosya izinleri, Kestrel'in reload'uKestrel yalnızca Path'i izler
CaddyCaddy'nin kendisi/data'yı kalıcı tutmakVolume'süz container rate limit'e takılır
App Service managedApp Service (DigiCert)DNS kayıtları, CAAWildcard yok, dışa aktarılamaz, CNAME doğrudan olmalı
App Service + Key VaultKey Vault (entegre CA) veya senin ACME job'ınResource provider'a vault yetkisiSenkronizasyon 24 saate kadar sürer
Front Door managedFront Door (DigiCert)CNAME'i doğrudan endpoint'e vermekApex ve wildcard'da elle yeniden doğrulama
cert-managercert-managerIssuer, solver ve DNS'in cluster içinden çözülmesiHer yenilemede yeni key

Üretimde Dikkat

Managed, izlemeyi kaldırmıyor. 2025'teki MPIC değişikliğinde App Service certificate'ları hiçbir alarm çalmadan yenilenemedi; bunu fark edenler, sunulan certificate'ın bitiş tarihini dışarıdan ölçenlerdi. cert-manager kendi Prometheus metric'lerini veriyor: certmanager_certificate_expiration_timestamp_seconds ve certmanager_certificate_ready_status. Bunlara ek olarak, sunulan certificate'ı dışarıdan ölçen bir kontrol kur; serinin son yazısında ikisini birlikte kullanacağız.

cert-manager'da Let's Encrypt staging ve production için iki ayrı ClusterIssuer tanımla. Yeni bir Gateway'i önce staging issuer ile dene. Production'daki hatalı denemeler rate limit tüketiyor: aynı isim ve account için saatte beş başarısız authorization hakkın var.

cert-manager'ı güncel tut. 1.21.3 sürümü, ARI döngüsü düzeltmesinin yanında, güvenliği ihlal edildiği (compromised) bildirilen eMudhra CA'sına duyulan güveni de kaldırdı ve bütün kullanıcılara yükseltme önerdi. Certificate otomasyonu da bir bağımlılık; yamaları bekletme.

Caddy'de birden fazla instance çalıştırıyorsan storage'ı paylaştır ve on-demand TLS'i yalnızca bir ask endpoint'i ile aç. Azure'da managed certificate kullanıyorsan DNS değişikliklerini (CNAME zincirine bir ara kayıt eklemek gibi) certificate yenilemesini kırabilecek değişiklikler olarak gözden geçir.

Sık Düşülen Tuzaklar

Pinning: Yukarıda gördük. cert-manager key'i her yenilemede değiştiriyor, managed platformlar ise certificate'ın her parçasını değiştirebileceğini söylüyor. Certificate'a, key'e veya intermediate'a pinning yapan istemci, yenilemenin kendisini bir kesintiye çevirir.

cert-manager self-check'i: cert-manager, CA'ya "hazırım" demeden önce challenge URL'ini cluster'ın içinden kendisi çağırıyor. İkinci certificate'ı istediğimde CoreDNS yeni girdiyi henüz yüklememişti ve challenge şu mesajla bekledi: Waiting for HTTP-01 challenge propagation: failed to perform self check GET request ... no such host. Production'da bunun karşılığı split-horizon DNS: domain dışarıdan doğru çözülüyor, cluster içinden çözülmüyor veya iç IP'ye çözülüyor. Challenge'ın REASON sütunu sorunu çoğu zaman açıkça yazıyor.

Managed certificate'ın apex ve wildcard sınırları: App Service managed certificate wildcard vermiyor ve apex'te A kaydı istiyor. Front Door apex'i CNAME flattening ile bağlarsan yenilemeyi elle yapman gerekiyor, wildcard managed certificate'ı ise hiç otomatik yenilemiyor. "Managed" kelimesine güvenip apex'i veya wildcard'ı aynı rahatlıkla bağlayan ekip, ilk yenilemede Pending Revalidation ile karşılaşır.

CAA kaydını unutmak: Domain'de yalnızca letsencrypt.org'a izin veren bir CAA kaydı varsa, App Service ve Front Door'un managed certificate'ı DigiCert'ten alınamaz. Hata, certificate'ı ilk kez eklerken değil, CAA sonradan eklendiyse ilk yenilemede çıkar.

Caddy'yi volume'süz çalıştırmak: Yukarıdaki dört koşu bunu gösterdi. Development'ta zararsız görünür, aynı imajı Let's Encrypt production directory'si ile çalıştırınca rate limit'e takılırsın.

Küçük Bir Deneme

★☆☆ Kendi makinende Caddy'yi localhost için çalıştır (caddy reverse-proxy --from localhost --to :5000 gibi). Certificate'ı hangi CA'nın verdiğine bak, pki/authorities/local klasörünü bul ve Caddy'nin root'u trust store'a eklemeye çalışıp çalışmadığını gözle.

★★☆ Bu yazıdaki Gateway'e cert-manager.io/private-key-rotation-policy: Never annotation'ını ekle, cmctl renew ile iki kez yenile ve SPKI hash'inin değişip değişmediğini script ile ölç. Sonra Certificate resource'unun spec.privateKey alanına bak: annotation oraya nasıl yansıdı?

★★★ Caddy ve Pebble'ı yeniden kur. Certificate'ı, üçüncü yazıdaki gibi certbot revoke ile ve Caddy'nin /data'da sakladığı key'i vererek iptal et; Caddy'nin ARI penceresinin değişmesini, ardından certificate'ı kendiliğinden yenilemesini log'dan izle. Kaç saat beklemen gerekti, neden? (İpucu: recheck_after.)

Bu yazıda certificate'ı almayı ve yenilemeyi platformlara devrettik. Bir sonraki yazıda certificate'ın varlığını bir garantiye çeviriyoruz: HTTP'den HTTPS'e redirect'in nerede yapılacağı, HSTS'i (tarayıcıyı siteye yalnız HTTPS ile bağlanmaya zorlayan header) kademeli açmak, preload kararı ve mixed content.