Credential'ları repoda saklamak: SOPS + age

Projelerimden birinde deployment aşamasında takıldığım bir soru vardı: secret değerlerini nerede tutmam gerekiyor? Başlarda bunları GitHub üzerinde secret olarak saklıyordum. Çalışıyordu çalışmasına ama zamanla tekrar okuması beni zorladı.

İlk yöntemim tüm .env dosyasını tek bir GitHub secret'inin içine koymaktı. Kolaydı, ama bu sefer hangi secret'in set edilmiş olduğunu göremiyordum: her şey tek bir blok. Bunun yerine her değeri ayrı secret yapmaya kalksam bu sefer de secret sayısı arttıkça onlarca değeri tek tek set etmem gerekiyordu, yönetmesi ayrı dert.

Sunucuda doğrudan bir secret dosyası da tutabilirdim. Ama o zaman da her değişiklik için sunucuya bağlanmam gerekecekti. İstediğim şeyi netleştirdim: CI sürecini hiç bozmadan, secret'i otomatik değiştirip deploy edebilmek.

Biraz araştırınca Mozilla ekibinin çıkardığı bir yöntem karşıma çıktı: SOPS. age ile birleştirince secret'ları şifreli halde reponun içinde tutabiliyordum. Kod nerede, secret de orada olsun, ama düz metin olarak değil. Bu yazıda bunu nasıl yaptığımı, nerede işe yaradığını ve nerede dikkat etmek gerektiğini anlatıyorum.

Bütün örnekler çalışan bir demo repoda: https://github.com/erdyasan/sops-age-example

Fikir

İki parça var:

  • age: basit, modern bir şifreleme aracı. Bir public/private key çifti üretiyoruz. Public key ile şifrelenen şeyi yalnızca private key açıyor.
  • SOPS (Secrets OPerationS): bir YAML/JSON/env dosyasının sadece değerlerini şifreliyor, key'leri (alan adlarını) düz bırakıyor. Yani dosya hala okunabilir yapıda duruyor, ama değerler şifreli. Diff aldığımızda "hangi alan değişmiş" görünüyor, değerin kendisi görünmüyor.

age asimetrik anahtarla çalışıyor: bir public/private key çifti üretiyoruz. Veriyi public key ile şifreliyoruz ve o veri yalnızca eşleşen private key ile açılıyor, başka hiçbir anahtarla değil. Asimetri de burada: public key'i herkese verebiliriz, herkes onunla bize şifreli veri hazırlayabilir, ama açabilen tek taraf private key'in sahibi. Yani şifreleyebilmek herkese açık bir yetenek, çözebilmek tek elde. (GPG de temelde aynı asimetrik mantıkla çalışıyor, public ile şifrele, private ile aç.)

age'in GPG'den farkı ağırlıkta. GPG (GNU Privacy Guard) güçlü ama hantal: web-of-trust denen bir güven modeli, keyring yönetimi, keyserver'lar, anahtar son kullanma tarihleri, subkey'ler, arka planda çalışan agent süreçleri, düzinelerce alt komut. Daha bir dosyayı şifreleyene kadar epey kavram öğrenmek gerekiyor. age tek işe odaklı: config dosyası yok, anahtarlar kısa, komutlar tek satır. Gündelik secret şifrelemede GPG'nin bütün o düzeneğine çoğu zaman ihtiyaç olmuyor; age aynı işi çok daha sade yapıyor.

Bunları birleştirince şunu elde ediyoruz: secret dosyası git'e commit'lenebilir. Çünkü içindeki değerler age ile şifreli. Private key'i olmayan biri clone'lasa bile işe yaramaz bir şifreli metin görür. Kim ne zaman değiştirmiş git history'de duruyor. Yeni makinede tek git clone (bir de age anahtarı) ile her şey geliyor.

Şunu da baştan söyleyeyim: bu yaklaşım küçük ve orta ölçekli projeler için biçilmiş kaftan. Ama bir sınırı var. Public key'ler .sops.yaml'da açıkta olduğu için, repoya yazma erişimi olan herkes secret'ların değerini okuyamasa bile üzerine yeni değer yazabiliyor (aşağıda "Şifreleyebilmek okuyabilmek değil" bölümünde açıyorum). Küçük ve güvenilir bir ekipte bu kabul edilebilir bir risk. Ekip ve etki alanı büyüdükçe git tarafında ekstra korumalar (branch protection, zorunlu review, secret path'ine özel kurallar) ya da bir noktadan sonra merkezi bir secret manager gerekiyor.

Kurulum

macOS'ta ikisi de brew'da:

brew install sops age

1. age anahtarı üret

age-keygen -o key.txt

Çıktısı şöyle bir dosya:

# created: 2026-08-12T12:02:42+03:00
# public key: age1cpyuq5ss7w85phtrkhsxntek7k4ypx3gr8qa8c4vd5y0k772wqssrd4w4q
AGE-SECRET-KEY-1DC8QTSCYAXRADPLAQA853TMCYDYZXSJ9JW8MFWRYLZFM6J4E67CS3K6ZW5

Üstteki age1... satırı public key (recipient). Alttaki AGE-SECRET-KEY-... private key. Public key ile şifreliyoruz, private key ile açıyoruz. Private key gizli kalıyor, public key'i rahatça paylaşabiliyoruz. Gerçekte private key her makinede ~/.config/sops/age/keys.txt altında durur ve asla commit'lenmez.

2. Dosya düzeni: secrets/ ve .env.enc

Kendi kurulumumda secret'ları bir secrets/ dizininde, ortam başına ayrı tutuyorum. Her ortam için üç dosya var:

secrets/
  app.local.env.enc         # şifreli, commit'li
  app.local.env.example     # sadece key'ler, commit'li (hangi secret'lar var belli olsun)
  app.local.env             # düz metin working file, GITIGNORE (lokal, çözülmüş kopya)

Mantık şu: .enc şifreli ve commit'lenen dosya; .example sadece anahtar isimlerini taşıyan, yeni gelenin "hangi değerleri doldurmam lazım" diye bakacağı template; düz .env ise düzenlerken oluşan çözülmüş kopya, .gitignore'da durur ve hiçbir zaman commit'lenmez.

.enc uzantısının bir faydası da CI tarafında: pipeline secrets/*.env.enc gibi bir kalıpla tüm şifreli dosyaları tek seferde bulup topluca çözebiliyor. Buradaki * bir joker karakter; "bu desene uyan bütün dosya isimlerini getir" demek. Dosya isimlerini böyle desenle eşleştirmeye glob deniyor (secrets/ dizininde .env.enc ile biten ne varsa hepsi). Uzantı ayrıca "bu dosya şifreli, deploy'da çözülecek" sinyali oluyor.

Bir nokta: .enc uzantısı SOPS'a formatı söylemiyor. Dosyalar dotenv (KEY=value) formatında, ama SOPS bunu uzantıdan çıkaramadığı için her komutta formatı elle vermek gerekiyor: --input-type dotenv --output-type dotenv. Aşağıdaki küçük yönetici bunu hep otomatik ekliyor.

3. Kime şifreleniyor: .sops.yaml

SOPS'a hangi dosyayı kimin için şifreleyeceğini .sops.yaml söylüyor. Ortam başına farklı recipient'lar tanımlıyoruz. Önce anahtarları anchor olarak bir kere yazıp aşağıda referans veriyoruz:

# .sops.yaml
keys:
  - &local          age1cpyuq5ss...   # benim makinem
  - &server_staging age12clzmdhl...   # staging sunucusu
  - &server_prod    age1g5uwzup8...   # prod sunucusu

creation_rules:
  # local: sadece benim makinem açabilir
  - path_regex: secrets[/\\]app\.local\.env(\.enc)?$
    key_groups:
      - age:
          - *local

  # staging: staging sunucusu + ben
  - path_regex: secrets[/\\]app\.staging\.env(\.enc)?$
    key_groups:
      - age:
          - *server_staging
          - *local

  # prod: prod sunucusu + ben
  - path_regex: secrets[/\\]app\.prod\.env(\.enc)?$
    key_groups:
      - age:
          - *server_prod
          - *local

Buradaki recipient modeli önemli: her ortam o ortamın sunucu public key'i + benim local public key'im ile şifreleniyor. Sonuç:

  • Sunucu deploy anında kendi private key'i ile açıyor.
  • Ben local'de kendi private key'imle açıp düzenliyorum.
  • Prod dosyasını sadece prod sunucusu (bir de ben) açabiliyor; staging sunucusu ya da başka bir geliştirici prod secret'ını çözemiyor, çünkü onların key'i o kuralda yok.

.sops.yaml'a yazdığımız age1... satırlarının hepsi public key. Hiçbir private key buraya girmiyor.

sops+age kurulumunu ilk zamanlar biraz ezbere yapıyordum; bu yapının nasıl işlediğini tam bilmiyordum. O zaman dokümanın tamamını okumamıştım, açıkçası deneyimim de yoktu. Sonradan fark ettim ki buradaki key_groups aslında dikkat isteyen bir ifade: prod kuralına "local için de encrypt et" dediğimiz an, prod secret'ı local'de de çözülebilir hale geliyor. SOPS bir değeri şifrelerken önce rastgele bir data key üretiyor, asıl veriyi onunla şifreliyor, sonra o data key'i gruptaki her recipient'ın public key'ine ayrı ayrı sarıyor. Yani prod dosyasındaki data key hem prod sunucusunun private key'iyle hem benim local private key'imle açılabiliyor; ikisinden biri yetiyor. Prod secret'larını local'de sops -d ile çözebildiğimi görünce açıkçası çok şaşırmıştım; ilk anda "acaba bir zafiyet mi var" diye düşündüm. Değil: bu tam da her ortamın key group'una &local'i eklememin sonucu, tasarımın kendisi. Aynısı staging/qa için de geçerli: kuralda &local recipient olduğu sürece o ortamı local'de açabiliyorum.

Ama getirdiği trade-off'u görmek gerekiyor: local private key'i eline geçiren biri, o key'in recipient olduğu her ortamın (prod dahil) secret'larını çözebilir. Yani keys.txt dosyasını korumak ve kimin hangi ortama recipient olduğunu bilinçli seçmek önemli. Küçük ve güvenilir bir ekipte bu kabul edilebilir bir kolaylık. Erişimi daraltmak istersek local'i prod grubundan çıkarıp prod'u sadece sunucu key'iyle şifreleriz; o zaman prod secret'ını kimse local'de açamaz.

(path_regex'teki secrets[/\\] hem / hem \ ayıracını kapsıyor: bir Windows portability detayı, aşağıdaki encoding bölümünde değiniyorum.)

Bir de terim notu: buradaki key_groups, bir grup içinde recipient'ları saran bir konteyner sadece. Ortam ayrımı için tek grup ve o gruba yazdığımız recipient listesi yeterli.

Tam dosya: https://github.com/erdyasan/sops-age-example/blob/811a70873d040b2be83f68f5cfaaf972cef7deeb/.sops.yaml

4. Secret'i yaz ve şifrele

Önce düz working file'ı hazırlıyoruz (dotenv):

# secrets/app.local.env  (gitignore'lı, düz metin)
HELLO=world
FAKE_API_KEY=demo_local_sk_0000
DB_PASSWORD=local-demo-password

Sonra şifreliyoruz (dotenv formatını elle vererek):

sops -e --input-type dotenv --output-type dotenv secrets/app.local.env > secrets/app.local.env.enc

.enc dosyası artık şöyle görünüyor:

HELLO=ENC[AES256_GCM,data:wPU91TF7...,iv:bbS2qHGd...,tag:ALssHW9H...,type:str]
FAKE_API_KEY=ENC[AES256_GCM,data:...,type:str]
DB_PASSWORD=ENC[AES256_GCM,data:...,type:str]
sops_age__list_0__map_recipient=age1cpyuq5ss...
sops_mac=ENC[AES256_GCM,...]
sops_version=3.13.2

Alan adları (HELLO, FAKE_API_KEY, DB_PASSWORD) düz, değerleri şifreli. İşte bu yüzden diff'ler anlamlı kalıyor. Alttaki sops_... satırları SOPS'un metadata'sı: hangi recipient için şifrelendiği ve bütünlük kontrolü (mac). YAML kullansaydık bu bir sops: bloğu olurdu; dotenv düz key=value tuttuğu için metadata da sops_... şeklinde düz anahtarlara yayılıyor.

Bu .enc dosyasını rahatça commit'leyebiliyoruz. Düz app.local.env'i ise git görmüyor.

5. Runtime'da açmak

Şifreli dosya güzel de, uygulama bunu nasıl okuyacak? En temiz yol düz metni hiç diske yazmamak: dosyayı bellekte çözüp değerleri ortam değişkeni olarak process'e enjekte etmek.

Bunun için repoya küçük bir yönetici koydum (secrets.mjs), gerçek projemdeki daha kapsamlı halinin sadeleştirilmişi. Kendi kripto'yu yapmıyor, sadece sops CLI'ını çağırıyor. run komutu ortamı çözüp verilen komutu o değişkenlerle çalıştırıyor:

node secrets.mjs run local -- node index.js

Uygulama tarafı sadece process.env'i okuyor, SOPS'tan haberi bile yok:

// index.js
const KEYS = ["HELLO", "FAKE_API_KEY", "DB_PASSWORD"];

console.log("Secrets decrypted by SOPS, read from process.env:\n");
for (const key of KEYS) {
  console.log(`  ${key} = ${process.env[key] ?? "(missing)"}`);
}

Çıktı:

$ npm start

Secrets decrypted by SOPS, read from process.env:

  HELLO = world (local)
  FAKE_API_KEY = demo_local_sk_0000_not_a_real_key
  DB_PASSWORD = local-demo-password

Neden sops exec-env değil de kendi küçük script'im? Çünkü exec-env, --input-type bayrağını kabul etmiyor; formatı uzantıdan çıkaramadığı için uzantısız/.enc dotenv dosyalarında işe yaramıyor. Node tarafında sops -d --input-type dotenv ... ile çözüp çıktıyı parse etmek hem bu sorunu çözüyor hem de boşluk içeren değerleri (world (local) gibi) shell'in bozmasına izin vermiyor. Çözülmüş içerik hiçbir zaman diske yazılmıyor, sadece process yaşadığı sürece bellekte duruyor.

Yöneticinin tamamı: https://github.com/erdyasan/sops-age-example/blob/811a70873d040b2be83f68f5cfaaf972cef7deeb/secrets.mjs

Ekip: anahtar paylaşmak değil, recipient eklemek

Başta tek bir anahtar çifti ürettik, çünkü tek kişiydik. Peki ekip olunca ne oluyor, herkese aynı private key'i mi dağıtıyoruz? Hayır. İşin püf noktası tam burada.

Ekipteki her kişi kendi makinesinde age-keygen çalıştırıp kendine ait bir çift üretiyor: kendi private key'i (gizli, makinesinden çıkmaz) ve kendi public key'i (paylaşılabilir). Yani ortada tek bir public key yok; her üyenin farklı bir çifti var. .sops.yaml'daki age1... satırlarının hepsi public key.

SOPS bir dosyayı şifrelerken şöyle çalışıyor: rastgele bir veri anahtarı (data key) üretir, asıl değerleri onunla şifreler, sonra bu veri anahtarını o kuraldaki her recipient'ın public key'i ile ayrı ayrı sarmalar (wrap). Böylece listedeki herhangi bir kişi (ya da sunucu) kendi private key'i ile veri anahtarını açıp dosyayı çözebiliyor. Herkesin ayrı private key'i olduğu için kimse başkasının anahtarını bilmek zorunda değil.

Neden böyle? Çünkü tek bir private key'i herkese dağıtsaydık: biri sızdırınca hepsi birden yanardı, kimin açtığı belli olmazdı ve anahtarı değiştirmek için herkese yeniden dağıtmamız gerekirdi. Ayrı ayrı olunca sadece public key'ler paylaşılıyor, private key'ler makineden hiç çıkmıyor.

Yeni biri katılınca, onun public key'ini ilgili kurala ekliyoruz, sonra dosyayı onun için de sarmalamak üzere:

sops updatekeys secrets/app.local.env.enc

Biri ayrılınca: public key'ini .sops.yaml'dan çıkarıyoruz, sops updatekeys çalıştırıyoruz, bir de gerçek secret değerlerini rotate ediyoruz. (Ayrılan kişi eski commit'teki şifreli dosyayı hala açabilir, çünkü history'de o versiyon duruyor. O yüzden değeri değiştirmek şart.)

CI (GitHub Actions)

CI ortamında o ortamın private key'ini repo secret'ı olarak veriyoruz:

- run: node secrets.mjs run prod -- node index.js
  env:
    SOPS_AGE_KEY: ${{ secrets.PROD_AGE_KEY }}

SOPS_AGE_KEY anahtarın içeriğini doğrudan alır; SOPS_AGE_KEY_FILE ise bir dosya yolu ister. İstediğim şey de tam buydu: CI'a hiç dokunmadan, şifreli dosyayı commit'leyerek secret güncelliyorum ve prod anahtarı sadece prod CI'ının secret'ında durduğu için ortam izolasyonu bozulmuyor.

Şifreleyebilmek okuyabilmek değil: repo erişimi

Bir noktada şunu fark ettim: public key'ler .sops.yaml'da açıkta duruyor. Peki repoya yazma erişimi olan biri secret'ları değiştirebilir mi? Evet, değiştirebilir. Şifrelemek için sadece public key yetiyor, private key gerekmiyor. Yani o kişi mevcut değerleri okuyamaz (okumak private key ister), ama üzerlerine yeni değer yazıp yeniden şifreleyip commit'leyebilir. Deploy'da sunucu commit'lenmiş değeri çözüp kullandığı için, yazma erişimi olan biri DB_PASSWORD'ü farklı bir değere çekebilir ya da bir API adresini yanlış bir yere yönlendirebilir.

Yani SOPS bize gizlilik (confidentiality) veriyor, bütünlük (integrity) değil. Değerleri okumaya karşı korur, değiştirilmeye karşı korumaz. Küçük projede bu genelde yeterli; ölçek büyüdükçe bu boşluğu git tarafında kapatmak gerekiyor. sops'u bir ekipte kullanmaya karar verilirse önerim şu birkaç önlem:

  • Branch protection + zorunlu review. main'e doğrudan push'u kapatmak, her değişikliği PR + onay ile almak. Böylece kimse tek başına secret'ın üzerine yazıp deploy'a gönderemez.

  • Secret path'ine özel reviewer (CODEOWNERS). secrets/ ve .sops.yaml'a dokunan her PR'a belirli kişilerin onayını zorunlu kılmak.

  • PR'da otomatik uyarı. secret dosyalarına dokunan bir PR açıldığında oraya otomatik bir uyarı yorumu düşürmek. Böylece o değişiklik reviewer'ın gözünden kolayca kaçmıyor. İstersek bunu required status check'e bağlayıp onaysız merge'ü de engelleyebilir, daha ileri gidip commit imzalama ekleyebiliriz.

Not: SOPS'taki mac alanı dosyanın elle (sops kullanmadan) kurcalanmasını yakalar, ama sops ile düzgünce yeniden şifreleyen biri geçerli bir mac üretir. Yani mac kör hex editini engeller, yetkili birinin yeniden şifrelemesini değil.

Dikkat edilecek nokta: metadata sızar

SOPS sadece değerleri şifreliyor, alan adları düz kalıyor. Diff'ler okunabilir olsun diye böyle, ama bunun bedeli var: dosyaya bakan biri hangi secret'lardan bahsedildiğini görür. DB_PASSWORD, STRIPE_SECRET_KEY, ADMIN_TOKEN... değerleri gizli ama isimleri altyapının şeklini ele veriyor. Alan adının kendisi hassassa, ya bütün dosyayı şifrelemek ya da ismini nötrleştirmek gerekiyor.

Satır sonu (CRLF/LF) ve encoding tuzağı

Bununla deploy tarafında baya uğraştım, en sinir bozucu kısım buydu. .enc dosyasını bir makinede (mac, LF satır sonu) oluşturdum, sonra aynı repoyu Windows'ta açtım. Değerler doğruydu ama decrypt bir türlü olmuyordu. Sebep: SOPS her dosya için içeriğin üzerinden bir mac (bütünlük kontrolü) hesaplıyor. Byte'lar bir tık değişince mac tutmuyor ve dosya bozuk sayılıyor. Windows'ta bu byte'ları değiştiren birkaç ayrı şey var.

1. Git autocrlf. Windows'ta git checkout sırasında LF'leri CRLF'e çevirebiliyor. .enc'in byte'ları değişiyor, mac patlıyor. Çözüm .gitattributes ile bu dosyalarda dönüşümü kapatmak:

# .gitattributes
secrets/*.enc -text

-text, git'e "bu dosyaları metin sayma, satır sonlarına dokunma" diyor.

2. PowerShell > yönlendirmesi UTF-16 yazıyor. Bunu bulmak zaman aldı. Windows'ta sops -e ... > dosya.enc yapınca, PowerShell'in > operatörü çıktıyı UTF-8 değil UTF-16 LE olarak yazıyor, üstüne bir de BOM ekliyor. Dosya daha baştan bozuk doğuyor. Editörlerin araya kattığı BOM da aynı sonucu veriyor.

Bu yüzden Windows için küçük bir PowerShell script'i yazdım (encrypt.ps1). Kabaca: önce plaintext'i LF-only + UTF-8 no BOM'a normalize ediyor, sonra sops çıktısını > ile değil bir değişkene alıp .NET File API ile (yine LF + UTF-8 no BOM, sonda tek newline) yazıyor, en sonunda dosyanın ilk byte'larına bakıp UTF-16/UTF-8 BOM ya da CRLF varsa hata fırlatıyor. Yani bozuk bir .enc üretmeyi baştan imkansız kılıyor. Tamamı repoda: https://github.com/erdyasan/sops-age-example/blob/811a70873d040b2be83f68f5cfaaf972cef7deeb/encrypt.ps1

3. Path ayıracı. Küçük ama can sıkan bir başka Windows farkı .sops.yaml'daki path_regex. sops, dosya yolunu OS'un verdiği haliyle regex'e geçiriyor; Windows'ta ayıraç \ olduğu için sadece / ile yazılmış bir pattern eşleşmiyor ve dosya "hangi kurala ait" bulunamıyor. Yukarıda .sops.yaml örneğinde path_regex'i secrets[/\\]... diye yazmamın sebebi bu: köşeli parantez iki ayıracı da kapsıyor.

Özet: .enc dosyalarının byte'ları aynen korunmalı. Satır sonu, encoding, BOM, araya giren her şey mac'i bozuyor. .enc'i elle editörde açıp kaydetmemek gerekiyor; hep araçlar (sops, secrets.mjs, encrypt.ps1) üzerinden düzenliyoruz. Dosyaların encoding'i önemli.

SOPS ne zaman, alternatifler ne zaman

  • git-crypt: bütün dosyayı şifreler, şeffaf çalışır (checkout'ta açılır). Ama değer değil dosya bazlı olduğu için diff'ler okunmaz. Alan adı sızmasını istemiyorsak avantaj.
  • Vault / cloud secret manager (Azure Key Vault, AWS Secrets Manager): merkezi, audit log'lu, dinamik secret üreten ağır çözümler. Büyük ekip ve sıkı uyumluluk gereksinimi varsa doğru yer. Ama ayağa kaldırmak ve işletmek maliyetli.
  • SOPS + age: küçük/orta projeler için tatlı nokta. Ekstra bir servis yok, secret kodla aynı yerde versiyonlu, kurulum bir avuç komut. GitOps akışlarına da iyi oturuyor.

"Zaten private repomuz var, üstüne bir de şifrelemek gereksiz mi?" diye sorulabilir. Bence değil: defense in depth. Repo yanlışlıkla public olursa, bir fork sızarsa ya da birinin GitHub token'ı çalınırsa düz .env anında ele geçer; şifreli dosya hala private key olmadan işe yaramaz.

Sonuç

Secret'ları repoda şifreli tutmak, "ayrı bir yerde secret saklama" derdini büyük ölçüde ortadan kaldırıyor. SOPS + age ile kurulum birkaç komut, günlük kullanım neredeyse görünmez. Küçük projelerde Vault'a gerek kalmadan makul bir güvenlik ve rahat bir iş akışı sağlıyor.

Tam çalışan demo, tüm dosyalarıyla: https://github.com/erdyasan/sops-age-example

Paylaş

Yorumlar