Mert Özen Her Satırda Daha İleri
DevOps Docker Compose — #03

Compose'da Configuration ve Secret'lar: environment, env_file, .env Interpolation, secrets ve Profile'lar

Mert Özen 14 Kas 2026 11 dk 14 görüntülenme
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ız compose.yaml'da kullanıldığı yerlere girer; container'a vermek için environment ya da env_file gerekir.
  • .env'i commit'lemek: repo'ya yalnız boş bir .env.example girer; 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 şifresini compose.yaml'a düz yazmak: dosya repo'ya girer ve şifre herkesin olur; environment variable'a koymak da onu inspect'e taşır.
  • Secret dosyasını 600 yapıp container'ın başka bir kullanıcıyla çalıştığını unutmak: Swarm dışında uid/mode uygulanmaz, host'taki izin geçerlidir.
  • Profile'la başlatılan service'leri profile'sız down ile 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 project hatası 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.