Docker Compose'a Giriş: compose.yaml ile Bookstore'u Tek Komutla Ayağa Kaldırmak
Docker Compose, Bookstore'un API, web ve SQL Server container'larını, network'ünü ve volume'ünü tek bir compose.yaml'da tanımlar ve docker compose up -d ile hepsini birlikte kaldırır. Dosyanın anatomisini, build ile image farkını, proje adını, service adıyla DNS'i, logs/exec/config komutlarını, down ile down -v'nin veriye etkisini ve version alanının neden artık obsolete olduğunu 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 ve Angular 22 ile denenmiştir; Docker Desktop 4.99 Compose 5.7.0 ile geliyor ve buradaki komutlar onda da aynı çalışır.
Önce Tahmin Et
Bookstore'un API'si, Angular web'i ve SQL Server'ı tek bir compose.yaml'da tanımlı. Veritabanına kitapları yükledin, docker compose down dedin, ertesi sabah docker compose up -d ile her şeyi yeniden kaldırdın. Kitaplar hâlâ orada mı? Aynı soruyu bir de docker compose down -v için düşün. Son bir soru: service'in adı api, ama web container'ındaki nginx istekleri bookstore-api adına iletiyor. Tarayıcı kitap listesini görebilir mi?
README'deki 12 Adımdan Tek Dosyaya
Docker serisinde sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) parçalarını tek tek container'a aldık: Bookstore.Api için chiseled bir ASP.NET Core image'ı, bookstore-web için unprivileged nginx, BookstoreDb için SQL Server 2025 ve named volume. 12. yazının sonunda üçünü birlikte çalıştırmak şu demekti: bir network oluştur, bir volume oluştur, veritabanını doğru environment variable'larla başlat, script'leri çalıştır, API'yi connection string'le başlat, web'i API adresiyle ve port'uyla başlat. Her biri uzun bir docker run satırı. Ekibe yeni katılan biri bunları README'den sırayla kopyalıyor ve bir bayrağı unuttuğunda neyin bozulduğunu anlamak yarım gününü alıyor.
Docker Compose bu komutların hepsini bir YAML (girintiye dayalı, configuration dosyalarında yaygın bir veri biçimi) dosyasına, istenen durumu anlatan bir tanıma çeviriyor. Bu seride Bookstore'u bu dosyayla ayağa kaldıracağız: bu yazıda iskelet, sonra başlama sırası ve healthcheck, configuration ve secret'lar, geliştirme döngüsü, birden çok environment ve tek sunuculu production. Dockerfile, image, volume ve network'ün kendisi Docker serisinde anlatıldı; burada onların compose.yaml'daki karşılıklarına bakıyoruz.
compose.yaml'ın Anatomisi
Repo'nun köküne şu dosyayı ekledik:
# Tek komutla Bookstore: SQL Server, Bookstore.Api ve bookstore-web
name: composeseries
services:
db:
image: mcr.microsoft.com/mssql/server:2025-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD}
volumes:
- db-data:/var/opt/mssql
- ./db:/scripts:ro
ports:
- "127.0.0.1:1433:1433"
api:
build:
context: .
dockerfile: src/Bookstore.Api/Dockerfile
environment:
ConnectionStrings__Default: "Server=db;Database=BookstoreDb;User Id=bookstore_app;Password=${BOOKSTORE_DB_PASSWORD};TrustServerCertificate=True"
networks:
default:
aliases:
- bookstore-api
web:
build: ./web
environment:
API_BASE_URL: /api
APP_ENVIRONMENT: compose
ports:
- "8080:8080"
volumes:
db-data:
Üç top-level element var. services, çalışacak container'ların tanımı; her key bir service adı. volumes, Compose'un yöneteceği named volume'ler. networks burada yazılı değil, çünkü Compose her projeye kendiliğinden bir default network'ü açıyor ve bütün service'leri ona bağlıyor; ayrı network'ler gerektiğinde (ör. veritabanını web'den yalıtmak) bu element eklenir. API'nin altındaki networks.default.aliases ise bir service'e o network'te ek bir DNS adı veriyor; ona birazdan döneceğiz.
Service'lerin image'ı iki yoldan gelir. db'deki image: hazır bir image'ı registry'den çeker. api ve web'deki build: ise image'ı repo'daki Dockerfile'dan üretir: kısa form (build: ./web) yalnız context klasörünü verir ve o klasördeki Dockerfile'ı kullanır, uzun form context ile Dockerfile yolunu ayrı yazar. Bookstore.Api'nin Dockerfile'ı global.json'ı da kopyaladığı için context repo kökü olmalı. image: yazılmayan build edilmiş image'lara Compose <project>-<service> adını veriyor (composeseries-api); ikisi birlikte yazılırsa build sonucu o adla tag'lenir. Bunu 6. yazıda production için kullanacağız.
name: composeseries projenin adı. Compose bütün kaynaklarını bu adla öneklendiriyor ve aynı adı taşıyan kaynakları "kendi" sayıyor. Ad sırasıyla şu yerlerden gelir: -p bayrağı, COMPOSE_PROJECT_NAME environment variable'ı, dosyadaki name:, en son da dosyanın bulunduğu klasörün adı. name: yazmazsan repo'yu bookstore klasörüne klonlayan herkes bookstore, Bookstore-main klasörüne açan biri başka bir proje adı alır; aynı makinede iki kopya yan yana çalışacaksa tam istenen de bu olabilir. Biz adı sabitledik.
${MSSQL_SA_PASSWORD} ve ${BOOKSTORE_DB_PASSWORD} interpolation: Compose bu değerleri dosyayı okurken shell'den ya da proje klasöründeki .env dosyasından alıyor. Repo'ya yalnız boş bir .env.example koyduk, gerçek .env .gitignore'da. İki mekanizmanın (.env ve env_file) farkını ve şifrelerin environment variable'dan daha iyi bir yere taşınmasını 3. yazıda ayrıntılı göreceğiz.
İlk up: Ne Oluşturuldu?
# iki şifreyi doldur
cp .env.example .env
docker compose build
docker compose up -d
Network composeseries_default Creating
Volume composeseries_db-data Creating
Volume composeseries_db-data Created
Network composeseries_default Created
Container composeseries-web-1 Creating
Container composeseries-db-1 Creating
Container composeseries-api-1 Creating
Container composeseries-db-1 Created
Container composeseries-api-1 Created
Container composeseries-web-1 Created
Container composeseries-web-1 Starting
Container composeseries-api-1 Starting
Container composeseries-db-1 Starting
Container composeseries-web-1 Started
Container composeseries-api-1 Started
Container composeseries-db-1 Started
docker compose build iki image'ı build etti. Compose 5.0'dan beri kendi içindeki builder'ı yok; build'i Docker serisinin 6. yazısında tanıdığımız Bake'e devrediyor, bu yüzden çıktı docker buildx bake'inkiyle aynı görünüyor. up -d ise bir network, bir volume ve üç container oluşturdu. Adlandırma düzenli: network ve volume <project>_<name>, container <project>-<service>-<index>. Sondaki -1, service ölçeklendiğinde (--scale api=2) artan replica numarası. -d (detached) olmadan Compose bütün service'lerin log'unu terminale akıtır ve Ctrl+C ile hepsini durdurur.

docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
composeseries-api-1 composeseries-api "dotnet Bookstore.Ap…" api Less than a second ago Up Less than a second (health: starting) 8080/tcp
composeseries-db-1 mcr.microsoft.com/mssql/server:2025-latest "/opt/mssql/bin/laun…" db Less than a second ago Up Less than a second 127.0.0.1:1433->1433/tcp
composeseries-web-1 composeseries-web "/docker-entrypoint.…" web Less than a second ago Up Less than a second 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp
(health: starting) Dockerfile'daki HEALTHCHECK'ten geliyor; Compose onu olduğu gibi kullanıyor. Port'lar Docker serisinin 12. yazısındaki kurala uyuyor: dışarıya yalnız giriş noktası web açık; SQL Server'ı SSMS (SQL Server Management Studio; Microsoft'un SQL Server yönetim aracı) veya sqlcmd ile host'tan inceleyebilmek için yalnız 127.0.0.1'e yayımladık; API hiç yayımlanmadı.
Service'ler birbirini adıyla buluyor. Compose'un default network'ü user-defined bir bridge olduğu için Docker'ın gömülü DNS'i service adlarını (ve alias'ları) çözüyor. API'nin connection string'indeki Server=db bu yüzden çalışıyor. Web container'ından baktık:
docker compose exec web nslookup db | grep -A1 Name
docker compose exec web nslookup bookstore-api | grep -A1 Name
Name: db
Address: 172.19.0.4
Name: bookstore-api
Address: 172.19.0.3
bookstore-web'in nginx config'i Docker serisinde /api/ isteklerini bookstore-api:8080'e iletecek şekilde yazılmıştı. Service'e bookstore-api adını vermek yerine kısa adı korumak için alias ekledik: aynı container iki adla da çözülüyor. İlk sorunun son kısmının cevabı bu yüzden evet, ama alias olmasaydı nginx her /api/ isteğinde 502 dönerdi.
Günlük Komutlar: logs, exec ve config
Tarayıcıdan localhost:8080'i açtığında sayfa geliyor ama kitap listesi yerine "Catalog is not available" yazıyor. Sebebi log'da:
docker compose logs -f api
api-1 | info: Microsoft.Hosting.Lifetime[14]
api-1 | Now listening on: http://[::]:8080
api-1 | info: Bookstore.Api[0]
api-1 | Catalog source: SqlBookSource
api-1 | Microsoft.Data.SqlClient.SqlException (0x80131904): Login failed for user 'bookstore_app'.
logs bütün service'lerin log'unu service adıyla öneklendirerek birleştirir; arkasına service adı yazınca yalnız onu, -f ile canlı akışı, --tail 50 ile son satırları verir. Hata beklenen bir hata: veritabanı boş, bookstore_app login'i ve BookstoreDb henüz yok. Docker serisinden gelen iki idempotent script'i (şema ve 6 kitap; yalnız okuma yetkili login) db container'ının içinde çalıştırıyoruz:
# .env'deki değişkenleri shell'e al
set -a; . ./.env; set +a
docker compose exec -e SQLCMDPASSWORD="$MSSQL_SA_PASSWORD" -e BOOKSTORE_DB_PASSWORD db \
/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa \
-i /scripts/01-bookstore.sql -i /scripts/02-app-login.sql
curl -s localhost:8080/api/books | jq -r '.[0:3][] | "\(.title) | \(.priceTry)"'
Changed database context to 'BookstoreDb'.
(6 rows affected)
Changed database context to 'master'.
Changed database context to 'BookstoreDb'.
Çalıkuşu | 240.00
Dune | 420.00
Emma | 180.00
docker compose exec, docker exec'in service adıyla çalışan hâli: container adını (composeseries-db-1) bilmen gerekmiyor. Şifreyi -P ile komut satırına yazmak yerine SQLCMDPASSWORD environment variable'ıyla verdik; -e BOOKSTORE_DB_PASSWORD değer yazılmadığında shell'deki değeri taşıyor ve script onu $(BOOKSTORE_DB_PASSWORD) olarak okuyor. ./db:/scripts:ro bind mount'u script'leri container'a salt okunur veriyor. Bu elle yapılan adımı bir sonraki yazıda bir service'e çevireceğiz.
Üçüncü günlük komut docker compose config: dosyayı, interpolation'ları çözülmüş, göreli yolları mutlaklaştırılmış ve kısa formları uzun forma açılmış hâliyle basar. Birden çok dosya birleştirmeye başladığımızda (5. yazı) en çok güveneceğimiz araç bu olacak:
docker compose config
name: composeseries
services:
api:
environment:
ConnectionStrings__Default: Server=db;Database=BookstoreDb;User Id=bookstore_app;Password=<app-password>;TrustServerCertificate=True
networks:
default:
aliases:
- bookstore-api
build:
context: /home/user/bookstore
dockerfile: src/Bookstore.Api/Dockerfile
db:
...
volumes:
- type: volume
source: db-data
target: /var/opt/mssql
volume: {}
- type: bind
source: /home/user/bookstore/db
target: /scripts
read_only: true
bind: {}
...
networks:
default:
name: composeseries_default
volumes:
db-data:
name: composeseries_db-data
Çıktıda şifreler açık yazılıyor (burada yer tutucuyla değiştirdik). config çıktısını bir issue'ya ya da CI log'una yapıştırmadan önce buna dikkat et.
down, down -v ve Volume'ün Ömrü
compose.yaml'da bir şey değiştirip up -d'yi tekrar çalıştırdığında Compose yalnız tanımı değişen service'i yeniden oluşturur. APP_ENVIRONMENT'ı değiştirdik:
Container composeseries-db-1 Running
Container composeseries-api-1 Running
Container composeseries-web-1 Recreate
Container composeseries-web-1 Recreated
Container composeseries-web-1 Starting
Container composeseries-web-1 Started
Compose her container'a, tanımının hash'ini bir label olarak yazıyor ve up'ta istenen durumla mevcut durumu karşılaştırıyor; veritabanı ve API'ye dokunulmadı. Şimdi ilk sorunun cevabı:
docker compose down
docker volume ls --filter name=composeseries
Container composeseries-api-1 Removed
Container composeseries-web-1 Removed
Container composeseries-db-1 Removed
Network composeseries_default Removed
DRIVER VOLUME NAME
local composeseries_db-data
down container'ları ve network'ü siler ama named volume'e dokunmaz. Ertesi sabah up -d yeni container'ları aynı volume'e bağlar ve /api/books yine 6 kitap döner. down -v ise projenin volume'lerini de siler:
docker compose down -v
docker compose up -d
curl -s -o /dev/null -w "GET /api/books: %{http_code}\n" localhost:8080/api/books
Volume composeseries_db-data Removing
Network composeseries_default Removing
Volume composeseries_db-data Removed
Network composeseries_default Removed
GET /api/books: 500
SQL Server boş bir instance olarak başladı ve API yine Login failed for user 'bookstore_app' aldı. Lokal makinede "temiz başlangıç" için down -v tam istediğin şey; production sunucusunda ise bütün veriyi silen bir komut. stop ve start container'ları silmeden durdurup başlatır, restart yeniden başlatır ama compose.yaml'daki değişiklikleri uygulamaz; değişiklik için her zaman up -d.
docker compose, docker-compose ve Dosya Adı
Eski blog yazılarında ve README'lerde docker-compose up göreceksin. O, Python ile yazılmış ilk Compose'du (v1) ve Haziran 2023'te desteği bitti. Bugünkü Compose Go ile yazılmış bir Docker CLI plugin'i ve docker compose (boşlukla) olarak çağrılıyor. Docker'ın resmi image'larında ve Docker Desktop'ta docker-compose adı da aynı binary'ye bağlı bir uyumluluk adı olarak duruyor:
docker-compose version
Docker Compose version v5.7.1
Yani tireli komut eski script'leri bozmasın diye var, ama arkasında v1 değil v5 çalışıyor. Ubuntu'nun kendi paketinden kurulmuş bir sunucuda ise docker-compose hâlâ eski Python sürümü olabilir; Docker serisinin 2. yazısı bu tuzağı anlatıyor.
Dosya adı için öncelik sırası compose.yaml, compose.yml, ardından geriye uyumluluk için docker-compose.yaml ve docker-compose.yml. İkisi birden varsa Compose uyarıp yenisini seçiyor:
WARN[0000] Found multiple config files with supported names: /home/user/bookstore/compose.yaml, /home/user/bookstore/docker-compose.yml
WARN[0000] Using /home/user/bookstore/compose.yaml
Eski dosyaların başındaki version: "3.8" satırı da artık bir şey ifade etmiyor. Compose bugün tek bir şemayı, Compose Specification'ı uyguluyor ve alanı görünce yalnızca uyarıyor:
WARN[0000] /home/user/bookstore/old-compose.yaml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion
Eski dosyaları okumayı ve bugünkü biçime çevirmeyi 7. yazıda ayrıntılı göreceğiz.
Üretimde Dikkat
Bu dosya bir geliştirme ortamı tanımı; production'a aynen taşınacak bir dosya değil. Production'da build: yerine registry'den immutable tag'li image:, restart policy, log rotation ve resource limit'leri gerekiyor; bunları 6. yazıda ekleyeceğiz. Bugünden geçerli olanlar: dışarıya yalnız giriş noktasını aç, veritabanı port'unu açacaksan 127.0.0.1'e bağla. Port'un önüne IP yazmazsan Docker onu bütün arayüzlerde yayımlar ve host'taki firewall kurallarını da atlar (Docker serisinin 12. yazısı). name:'i sabitlemek, aynı sunucuda iki ayrı projenin yanlışlıkla aynı container'ları sahiplenmesini de önler: Compose bir kaynağın projeye ait olup olmadığına com.docker.compose.project label'ına bakarak karar veriyor.
Ölçüm için iki hazır komut var: docker compose top service'lerin process'lerini, docker compose stats (Compose 5.7'den beri --aggregate ile proje toplamını da) CPU ve bellek kullanımını gösteriyor.
Sık Düşülen Tuzaklar
docker compose restart'ıncompose.yaml'daki değişikliği uygulamasını beklemek: restart mevcut container'ı yeniden başlatır, yeni tanım içinup -dgerekir.- Service adı yerine container adını connection string'e yazmak (
Server=composeseries-db-1): proje adı değişince bozulur; DNS adı olarak service adını ya da alias'ı kullan. localhost'u başka bir service'e ulaşmak için kullanmak: container'ın içindekilocalhosto container'ın kendisi.-file başka klasördeki bir dosyayı çalıştırınca.env'in bulunamaması: Compose.env'i proje klasöründe arar;/tmp/old.yamlile denediğimizde iki uyarı aldık:The "MSSQL_SA_PASSWORD" variable is not set. Defaulting to a blank string.- Alışkanlıkla
down -vyazmak: lokalde bile birkaç saatlik test verisini götürür; sunucuda felaket. build:'li bir service'te kod değişikliğinden sonra yalnızup -ddemek: image zaten varsa yeniden build edilmez;up -d --buildya da öncedocker compose build.
Küçük Bir Deneme
★☆☆ docker compose up -d --scale web=2 çalıştır. Hata mesajını oku ve sebebini açıkla: iki replica aynı host port'unu (8080) isteyemez. ports'u "8080-8081:8080" aralığına çevirip tekrar dene.
★★☆ name: satırını sil ve repo'yu bookstore-copy adlı ikinci bir klasöre kopyalayıp iki kopyayı da başlatmayı dene. Hangi kaynaklar çakışıyor, hangileri çakışmıyor? Host port'unu kopyada değiştirince ikisi aynı anda çalışabiliyor mu?
★★★ docker compose config --format json çıktısını jq ile okuyarak bir service'in bütün environment variable adlarını (değerleri olmadan) listeleyen tek satırlık bir komut yaz. Bunu bir CI adımına koyarsan hangi bilgiyi log'a sızdırmamış olursun?
Bu yazıda veritabanı şemasını elle, exec ile yükledik ve API veritabanı hazır olmadan başladı. Bir sonraki yazıda başlama sırasını depends_on ve healthcheck koşullarıyla tanımlıyor, şema adımını tek seferlik bir migrate service'ine çeviriyor ve uygulama tarafında neden yine de retry gerektiğini görüyoruz.