Compose'da Configuration ve Secret'lar: environment, env_file, .env Interpolation, secrets ve Profile'lar
.env, Compose'un compose.yaml'daki ${VAR} ifadelerini doldurmak için okuduğu dosyadır ve container'a hiçbir şey vermez; container'a environment variable vermek environment ve env_file'ın, şifreyi dosya olarak vermek secrets:'ın işidir. Öncelik sırasını, ${VAR:?} ile zorunlu değişkenleri, Bookstore'un şifrelerini /run/secrets'a taşımayı, configs ile appsettings vermeyi ve profile'larla opsiyonel service'leri gerçek çıktılarla görüyoruz.
Sürüm notu: Bu yazıdaki örnekler Docker Compose 5.7.1, Docker Engine 29.9.3, SQL Server 2025 (RTM-CU11), .NET 10, Redis 8 ve Mailpit 1.33 ile denenmiştir.
Önce Tahmin Et
Proje klasöründeki .env dosyasında STORE_TIMEZONE=Europe/Istanbul satırı var. Bookstore.Api container'ının içinde STORE_TIMEZONE adlı bir environment variable olur mu? İkinci soru: API'nin env_file'ında Store__TimeZone=Europe/London, environment'ında Store__TimeZone=Europe/Istanbul yazıyor. /api/store/clock hangi saat dilimini döner? Üçüncüsü: docker compose down dedin ama --profile tools ile başlattığın Mailpit hâlâ çalışıyor olabilir mi?
İki Ayrı Mekanizma: .env ve env_file
İsimleri benzediği için en sık karıştırılan iki şey bunlar ve işleri tamamen farklı.
.env, Compose'un kendisi için. Compose compose.yaml'ı okurken ${VAR} ifadelerini önce shell'deki environment variable'lardan, bulamazsa proje klasöründeki .env'den doldurur. Buna interpolation deniyor ve sonuç dosyanın metnine yazılır; .env'deki bir değişken, compose.yaml'da kullanılmadıkça hiçbir container'a ulaşmaz. İlk sorunun cevabı bu yüzden hayır.
env_file ise bir service'in özelliği: içindeki her satır o container'ın environment variable'ı olur. Bookstore.Api için gizli olmayan ayarları config/api.env'e topladık ve repo'ya koyduk:
# Bookstore.Api'nin Compose ortamındaki gizli olmayan ayarları (commit'lenir)
Store__Culture=tr-TR
Logging__LogLevel__Microsoft.AspNetCore=Warning
api:
build:
context: .
dockerfile: src/Bookstore.Api/Dockerfile
env_file: config/api.env
environment:
Store__TimeZone: ${STORE_TIMEZONE:-Europe/Istanbul}
environment'taki satır ikisini birleştiriyor: değer interpolation ile shell'den ya da .env'den geliyor, :- ile ikisi de yoksa Europe/Istanbul kullanılıyor ve sonuç container'a Store__TimeZone adıyla veriliyor. Çift alt çizgi, ASP.NET Core'un environment variable'larda hiyerarşi ayıracı; Store__TimeZone uygulamada Store:TimeZone olarak okunur. Configuration provider'larının sırasını ASP.NET Core Web API serisinin "ASP.NET Core'da Configuration ve Options Pattern: Provider Sırası, User Secrets ve Validation" yazısı anlatıyor.
docker inspect composeseries-api-1 --format "{{range .Config.Env}}{{println .}}{{end}}" | grep -E "Store|STORE"
Store__Culture=tr-TR
Store__TimeZone=Europe/Istanbul
Container'da STORE_TIMEZONE yok, yalnız onun değeriyle doldurulmuş Store__TimeZone var.
Kim Kimi Ezer?
Compose'un dokümanındaki sıra, en güçlüden başlayarak: docker compose run -e, değeri interpolation'la gelen environment, düz environment, env_file, en son image'daki ENV. Interpolation'da da shell .env'i ezer. İkinci soruyu denedik: config/api.env'e Store__TimeZone=Europe/London ekledik, sonra shell'den başka bir değer verdik, sonra satırı geri aldık.
echo "Store__TimeZone=Europe/London" >> config/api.env
docker compose up -d api
curl -s localhost:8080/api/store/clock
STORE_TIMEZONE=Europe/Berlin docker compose up -d api
curl -s localhost:8080/api/store/clock
{"zone":"Europe/Istanbul","localTime":"2026-11-14 16:43 +03:00"}
Container composeseries-api-1 Recreate
Container composeseries-api-1 Recreated
Container composeseries-api-1 Started
{"zone":"Europe/Berlin","localTime":"2026-11-14 14:43 +01:00"}
env_file'daki London, environment'taki değere yenildi. Dikkat çekici olan ilk up'ın hiçbir şey yapmaması: Compose container'ın son environment'ını hesaplıyor, sonuç değişmediği için container'ı yeniden oluşturmuyor. Shell'den verilen STORE_TIMEZONE ise .env'dekini ezdi, sonuç değişti, container yeniden oluşturuldu. env_file'daki gerçek bir değişiklik de (Store__Culture'ı değiştirdiğimizde) aynı şekilde yeniden oluşturma tetikledi.
Bunun bir yan etkisi var: CI'da ya da bir sunucuda shell'de kalmış eski bir environment variable, .env'deki değeri sessizce ezer. Bir ayarın nereden geldiğinden emin değilsen docker compose config çözülmüş sonucu gösterir.
Boş Değişken ve ${VAR:?}
Bir önceki yazının dosyasında şifreler ${MSSQL_SA_PASSWORD} diye geliyordu. .env'i geçici olarak kaldırıp denedik:
docker compose config --quiet; echo "exit code: $?"
WARN[0000] The "MSSQL_SA_PASSWORD" variable is not set. Defaulting to a blank string.
WARN[0000] The "BOOKSTORE_DB_PASSWORD" variable is not set. Defaulting to a blank string.
WARN[0000] The "MSSQL_SA_PASSWORD" variable is not set. Defaulting to a blank string.
WARN[0000] The "BOOKSTORE_DB_PASSWORD" variable is not set. Defaulting to a blank string.
exit code: 0
Her kullanım yeri için bir uyarı ve exit code 0. CI log'unda kolayca kaybolan bu uyarıdan sonra SQL Server boş şifreyle başlamaya çalışır ve kendi log'unda bambaşka bir hata verir. Değişkeni zorunlu kılmak için :?:
MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?set MSSQL_SA_PASSWORD in .env}
error while interpolating services.db.environment.MSSQL_SA_PASSWORD: required variable MSSQL_SA_PASSWORD is missing a value: set MSSQL_SA_PASSWORD in .env
exit code: 1
Compose hiçbir şey başlatmadan duruyor ve mesaj neyin eksik olduğunu söylüyor. Kısaca: ${VAR:-default} boş ya da tanımsızsa varsayılanı kullanır, ${VAR:?mesaj} boş ya da tanımsızsa hata verir; iki noktasız ${VAR-default} ve ${VAR?mesaj} yalnız tanımsız olmaya bakar, boş değeri kabul eder.
Environment Variable'daki Şifre Nereden Sızar?
Zorunlu kılmak şifrenin yerini değiştirmiyor. Environment variable olarak verilen şifre container'ın tanımına yazılıyor:
docker inspect composeseries-api-1 --format "{{range .Config.Env}}{{println .}}{{end}}" | grep -i password
docker inspect composeseries-db-1 --format "{{range .Config.Env}}{{println .}}{{end}}" | grep -i password
ConnectionStrings__Default=Server=db;Database=BookstoreDb;User Id=bookstore_app;Password=<app-password>;TrustServerCertificate=True
MSSQL_SA_PASSWORD=<sa-password>
Docker'a erişimi olan herkes, docker compose config çıktısını gören her CI log'u ve container'ın içindeki her process (/proc/1/environ) bu değerleri görebilir; hata durumunda environment'ı log'a basan bir kütüphane de cabası. Docker serisinin 13. yazısında aynı sorunu docker run ile görmüş ve şifreyi dosya olarak vermiştik. Compose'daki karşılığı secrets:.
secrets: Şifreyi Dosya Olarak Vermek
secrets:
sa-password:
file: ./secrets/sa-password.txt
db-app-password:
file: ./secrets/db-app-password.txt
api-connection-string:
file: ./secrets/api-connection-string.txt
Top-level secrets secret'ın kaynağını tanımlar (bir dosya ya da environment: ile host'taki bir environment variable), service'teki secrets ise hangi service'in hangi secret'ı göreceğini. Service'e verilen secret container'da /run/secrets/<ad> altında bir dosya olarak durur. secrets/ klasörü Docker serisinden beri hem .gitignore'da hem .dockerignore'da; dosyaları üreten küçük bir script ekledik:
#!/usr/bin/env bash
# secrets/ altında lokal geliştirme secret'larını üretir (git ve docker dışında). Makine başına bir kez çalıştır.
set -euo pipefail
cd "$(dirname "$0")/../.."
mkdir -p secrets
new_password() { printf '%s' "Bk$(openssl rand -base64 18 | tr -d '/+=')!9"; }
[ -f secrets/sa-password.txt ] || new_password > secrets/sa-password.txt
[ -f secrets/db-app-password.txt ] || new_password > secrets/db-app-password.txt
printf 'Server=db;Database=BookstoreDb;User Id=bookstore_app;Password=%s;TrustServerCertificate=True' \
"$(cat secrets/db-app-password.txt)" > secrets/api-connection-string.txt
# Container'lar başka kullanıcılarla çalışıyor (SQL Server 10001, Bookstore.Api 1654): dosyaları okuyabilmeleri gerekir
chmod 644 secrets/*.txt
ls -l secrets
Üç service üç farklı yoldan okuyor. Bookstore.Api, Docker serisinden beri /run/secrets'ı key-per-file configuration provider'ıyla okuyor: dosya adı key, içeriği value. Secret'ı bu ada uyacak şekilde bağladık:
secrets:
# Key-per-file: /run/secrets/ConnectionStrings__Default, ConnectionStrings:Default olur
- source: api-connection-string
target: ConnectionStrings__Default
SQL Server için ilginç bir şey bulduk. Microsoft Learn'deki environment variable listesinde yalnız MSSQL_SA_PASSWORD var, ama sqlservr binary'sinde MSSQL_SA_PASSWORD_FILE adı da geçiyor. Denedik: yalnız MSSQL_SA_PASSWORD_FILE=/run/secrets/sa-password verilen 2025 image'ı açıldı ve dosyadaki şifreyle sa girişi çalıştı. Belgelenmemiş bir davranış olduğunu bilerek kullanıyoruz; ileride kaybolursa entrypoint'te dosyayı okuyup MSSQL_SA_PASSWORD'u export eden bir satır aynı işi görür. Healthcheck ve migrate ise şifreyi kullanacakları anda dosyadan okuyor:
db:
image: mcr.microsoft.com/mssql/server:2025-latest
environment:
ACCEPT_EULA: "Y"
# Belgelenmiş listede yok, ama SQL Server 2025 sa şifresini bu dosyadan okuyor
MSSQL_SA_PASSWORD_FILE: /run/secrets/sa-password
secrets:
- sa-password
healthcheck:
test: SQLCMDPASSWORD="$$(cat /run/secrets/sa-password)" /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -b -Q "SELECT 1" -o /dev/null
...
migrate:
image: mcr.microsoft.com/mssql/server:2025-latest
entrypoint: ["/bin/bash", "-c"]
command:
- >-
SQLCMDPASSWORD="$$(cat /run/secrets/sa-password)"
BOOKSTORE_DB_PASSWORD="$$(cat /run/secrets/db-app-password)"
exec /opt/mssql-tools18/bin/sqlcmd -C -S db -U sa -b
-i /scripts/01-bookstore.sql -i /scripts/02-app-login.sql
secrets:
- sa-password
- db-app-password
docker inspect composeseries-db-1 --format "{{range .Config.Env}}{{println .}}{{end}}" | grep MSSQL_SA
docker inspect composeseries-api-1 --format "{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}"
MSSQL_SA_PASSWORD_FILE=/run/secrets/sa-password
bind /home/user/bookstore/config/appsettings.Production.json -> /app/appsettings.Production.json rw=false
bind /home/user/bookstore/secrets/api-connection-string.txt -> /run/secrets/ConnectionStrings__Default rw=false
Container tanımında artık yalnız dosyanın yolu var. İkinci satır secret'ın Swarm dışında nasıl çalıştığını da gösteriyor: Compose, host'taki dosyayı salt okunur bir bind mount olarak bağlıyor. Şifreleme ya da ayrı bir depolama yok; kazanç şifrenin environment'tan, inspect'ten ve config çıktısından çıkması. Bunun iki sonucu var. Birincisi, dosyanın host'taki izinleri aynen geçerli. Linux'ta (WSL 2 dahil) dosyayı chmod 600 ile yalnız kendine açık bırakırsan uid 1654 ile çalışan API onu okuyamıyor:
Unhandled exception. System.UnauthorizedAccessException: Access to the path '/run/secrets/ConnectionStrings__Default' is denied.
---> System.IO.IOException: Permission denied
Script'teki chmod 644 bu yüzden var. İkincisi, long syntax'taki uid, gid ve mode ayarları Swarm dışında uygulanmıyor ve Compose 5.7.1 bunu artık açıkça söylüyor:
WARN[0000] service "api": secrets.api-connection-string.uid: uid is not supported outside Swarm mode and will be ignored
WARN[0000] service "api": secrets.api-connection-string.mode: mode is not supported outside Swarm mode and will be ignored
Bir not daha: 2. yazıdaki pre_start hook'una secrets: verdiğimizde Compose 5.7.1 dosyaları bağlamadı (cat: /run/secrets/db-app-password: Is a directory); labs/compose/02-pre-start.yaml'da secret dosyalarını hook'a bind mount ile verdik.
configs: Image'ı Değiştirmeden appsettings Vermek
configs, secrets'ın gizli olmayan karşılığı: bir dosyayı container'da istediğin yola bağlar. Image'ı yeniden build etmeden API'ye bir appsettings.Production.json verdik; image Production environment'ında çalıştığı için ASP.NET Core bu dosyayı kendiliğinden okuyor:
{
"Logging": {
"Console": {
"FormatterName": "simple",
"FormatterOptions": {
"SingleLine": true,
"TimestampFormat": "HH:mm:ss ",
"UseUtcTimestamp": true
}
}
}
}
configs:
- source: api-settings
target: /app/appsettings.Production.json
...
configs:
api-settings:
file: ./config/appsettings.Production.json
api-1 | 13:43:17 info: Microsoft.Hosting.Lifetime[0] Hosting environment: Production
api-1 | 13:43:17 info: Bookstore.Api[0] Catalog source: SqlBookSource
Log satırları artık tek satır ve UTC (Coordinated Universal Time; saat diliminden bağımsız evrensel saat) zaman damgalı; docker compose logs'u okumak kolaylaştı. Dosyayı uzun bir içerikle tutmak yerine küçük ayarlar için config'in content: attribute'uyla içeriği doğrudan compose.yaml'a yazmak da mümkün. Hangisi environment variable, hangisi dosya olmalı? Kısa, tekil değerler (Store__TimeZone) için environment; yapılı ya da uzun ayarlar için config dosyası; gizli olan her şey için secret.
Profile'lar ile Opsiyonel Service'ler
Herkesin her gün ihtiyaç duymadığı service'ler de var: cache denemeleri için Redis, e-posta akışını test etmek için Mailpit (gönderilen e-postaları yakalayıp web arayüzünde gösteren bir test SMTP (Simple Mail Transfer Protocol; e-posta gönderme protokolü) sunucusu), veritabanına elle sorgu atmak için bir sqlcmd. Bunları profiles ile işaretledik:
# Opsiyonel service'ler: docker compose --profile cache / --profile tools
redis:
image: redis:8-alpine
profiles: [cache]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
restart: unless-stopped
mailpit:
image: axllent/mailpit:v1.33
profiles: [tools]
ports:
- "127.0.0.1:8025:8025"
# Elle sorgu, up ile hiç başlamaz: docker compose run --rm sqlcmd -Q "SELECT COUNT(*) FROM dbo.Books"
sqlcmd:
image: mcr.microsoft.com/mssql/server:2025-latest
profiles: [cli]
entrypoint:
- /bin/bash
- -c
- SQLCMDPASSWORD="$$(cat /run/secrets/sa-password)" exec /opt/mssql-tools18/bin/sqlcmd -C -S db -U sa -d BookstoreDb "$$@"
- sqlcmd
secrets:
- sa-password
depends_on:
db:
condition: service_healthy
Profile'ı olmayan service'ler her zaman aktif; profile'ı olanlar yalnız o profile açıldığında. Profile --profile bayrağıyla ya da COMPOSE_PROFILES environment variable'ıyla açılır; bir service doğrudan hedeflendiğinde (docker compose run sqlcmd) onun profile'ı kendiliğinden açılır:
docker compose config --services
COMPOSE_PROFILES=tools,cache docker compose up -d
docker compose run --rm sqlcmd -W -Q "SELECT TOP 2 Title FROM dbo.Books ORDER BY PriceTry DESC"
db
migrate
api
web
...
SERVICE STATUS
api Up 5 seconds (healthy)
db Up 57 seconds (healthy)
mailpit Up 56 seconds (healthy)
redis Up 56 seconds (healthy)
web Up Less than a second
Title
-----
Dune
İnce Memed
(2 rows affected)
sqlcmd'yi ilk denemede tools profile'ına koymuştuk ve --profile tools up -d onu da başlattı: argümansız sqlcmd stdin'den okuyacak bir şey bulamadı ve hemen Exited (0) oldu. Uzun yaşayan service'lerle tek seferlik araçları aynı profile'a koymamak gerekiyor; araçlar için ayrı bir profile (cli) run ile kullanılırken sorun çıkarmıyor. entrypoint'teki "$$@" ve sondaki sqlcmd, run'a verilen argümanları bash -c'nin içindeki sqlcmd'ye taşıyor.
Üçüncü sorunun cevabı da bu deneyden: profile'la başlatılan service'ler, profile'ı açık olmayan komutların modelinde yok.
docker compose down
Network composeseries_default Removing
Network composeseries_default Resource is still in use
down varsayılan service'leri kaldırdı ama Mailpit çalışmaya devam etti ve network'ü silmek mümkün olmadı. Temiz bir kapanış için aynı profile'larla: docker compose --profile tools down ya da COMPOSE_PROFILES'ı .env'e yazmak. Seq, .NET'te Logging serisinin "Seq ile Structured Log'ları Okumak: Docker Kurulumu, Sorgular, Signal'lar ve Alert'ler" yazısındaki container tanımıyla aynı şekilde bir profile'a eklenebilir.
Üretimde Dikkat
Compose'un dosya tabanlı secret'ı bir güvenlik sınırı değil, bir sızıntı önlemi: dosya host'ta düz metin duruyor. Sunucuda dosyaları deploy kullanıcısına ait ve yalnız gereken container kullanıcılarının okuyabileceği izinlerle tut; secret'ları bir vault'tan ya da CI'ın secret store'undan deploy anında yazdırmak ve rotasyonu düzenlemek ilerideki Configuration ve Secret Management serisinin konusu. docker compose config secret dosyalarının içeriğini basmıyor, ama interpolation ile environment'a giren her değeri basıyor; CI'da config çıktısını log'lamadan önce environment'ta şifre kalmadığından emin ol. .env'i .dockerignore'a da ekledik: API'nin build context'i repo kökü; bugünkü Dockerfile .env'i kopyalamıyor ama bir gün biri COPY . . yazarsa şifreler image'ın bir layer'ına girer.
Sık Düşülen Tuzaklar
.env'deki değişkenin container'a geçtiğini sanmak: yalnızcompose.yaml'da kullanıldığı yerlere girer; container'a vermek içinenvironmentya daenv_filegerekir..env'i commit'lemek: repo'ya yalnız boş bir.env.examplegirer; gerçek değerler her makinede ayrı durur.${VAR}'ın boş geldiğini fark etmemek: Compose yalnız uyarır; zorunlu değerler için${VAR:?mesaj}.saşifresinicompose.yaml'a düz yazmak: dosya repo'ya girer ve şifre herkesin olur; environment variable'a koymak da onuinspect'e taşır.- Secret dosyasını
600yapıp container'ın başka bir kullanıcıyla çalıştığını unutmak: Swarm dışındauid/modeuygulanmaz, host'taki izin geçerlidir. - Profile'la başlatılan service'leri profile'sız
downile kapatmaya çalışmak: service'ler ve network ayakta kalır. - Varsayılan bir service'i profile'daki bir service'e bağımlı yapmak: profile kapalıyken Compose hiç başlamaz,
service "api" depends on undefined service "redis": invalid compose projecthatası verir.
Küçük Bir Deneme
★☆☆ STORE_TIMEZONE'ı .env'den sil ve shell'de de tanımlı olmadığından emin ol. docker compose config'te Store__TimeZone ne oldu? :- yerine - yazıp .env'e STORE_TIMEZONE= (boş) eklediğinde sonuç nasıl değişiyor?
★★☆ api-connection-string secret'ını dosya yerine environment: BOOKSTORE_CONNECTION_STRING kaynağından üret ve değişkeni yalnız shell'de tanımla. Container'da dosya yine /run/secrets altında mı? docker inspect'te ve docker compose config'te değeri görebiliyor musun?
★★★ Mailpit'i Bookstore'a bağla: API'ye MailKit ile basit bir "sipariş alındı" e-postası gönderen bir endpoint ekle, SMTP adresini config/api.env'den (Smtp__Host=mailpit) ver ve e-postayı localhost:8025'te gör. tools profile'ı kapalıyken endpoint ne yapmalı?
Configuration artık katman katman geliyor ama hepsi tek bir compose.yaml'da ve geliştirme döngüsü yavaş: API'de bir satır değişince image'ı yeniden build edip container'ı yeniden oluşturuyoruz. Bir sonraki yazıda hangi parçanın container'da çalışacağına karar veriyor ve Compose Watch ile kod değişikliğini container'a otomatik taşıyoruz.