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