Üretim ortamına kod göndermek her zaman biraz stresli bir deneyimdir. “Bende çalışıyordu” cümlesini kaç kez duydunuz? Docker Compose ile CI/CD pipeline entegrasyonu tam da bu sorunu çözmek için var. Geliştirici laptop’ında çalışan ortamın, test sunucusunda ve production’da da aynı şekilde çalışmasını sağlamak artık hayal değil. Bu yazıda gerçek dünya senaryolarıyla Compose ve CI/CD entegrasyonunu tüm detaylarıyla ele alacağız.
Neden Compose ve CI/CD Birlikte?
Docker Compose tek başına güçlü bir araçtır. Ama bir CI/CD pipeline’ı olmadan Compose kullanmak, her deployment’ta elle komut çalıştırmak demektir. Bu da insan hatasına açık kapı bırakmak demektir. Birinin docker-compose up -d komutu çalıştırmayı unutması, yanlış branch’ten build alması ya da environment variable’ları eksik geçmesi gibi durumlar production’ı patlatabilir.
CI/CD ile Compose’u birleştirdiğinizde şunları elde edersiniz:
- Tekrarlanabilir build’ler: Her push’ta aynı adımlar çalışır
- Otomatik test entegrasyonu: Servisler ayağa kalkar, testler çalışır, her şey kapanır
- Audit trail: Kim, ne zaman, hangi kodu deploy etti?
- Rollback kolaylığı: Önceki image tag’lerine dönmek saniyeler alır
- Paralel ortam yönetimi: Dev, staging, production aynı Compose dosyasından türer
Proje Yapısının Hazırlanması
Önce sağlam bir proje yapısıyla başlayalım. Bir web uygulaması senaryosu düşünelim: Frontend, backend API, PostgreSQL ve Redis.
myapp/
├── docker-compose.yml
├── docker-compose.override.yml
├── docker-compose.prod.yml
├── docker-compose.test.yml
├── .env.example
├── .gitignore
├── backend/
│ ├── Dockerfile
│ └── ...
├── frontend/
│ ├── Dockerfile
│ └── ...
└── .github/
└── workflows/
├── ci.yml
└── deploy.yml
Bu yapıda birden fazla Compose dosyası kullanmak önemli. docker-compose.yml temel yapıyı tanımlar, docker-compose.override.yml development ortamına özel ayarları barındırır (Compose bunu otomatik yükler), docker-compose.prod.yml ise production’a özel konfigürasyonları içerir.
Temel docker-compose.yml dosyamız şu şekilde görünebilir:
version: '3.9'
services:
backend:
image: ${REGISTRY:-ghcr.io}/myorg/myapp-backend:${IMAGE_TAG:-latest}
build:
context: ./backend
dockerfile: Dockerfile
environment:
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
- REDIS_URL=redis://redis:6379
- APP_ENV=${APP_ENV:-development}
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- appnet
frontend:
image: ${REGISTRY:-ghcr.io}/myorg/myapp-frontend:${IMAGE_TAG:-latest}
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "${FRONTEND_PORT:-3000}:3000"
depends_on:
- backend
networks:
- appnet
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=${DB_USER:-appuser}
- POSTGRES_PASSWORD=${DB_PASSWORD:-changeme}
- POSTGRES_DB=${DB_NAME:-appdb}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-appuser}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- appnet
redis:
image: redis:7-alpine
networks:
- appnet
volumes:
pgdata:
networks:
appnet:
driver: bridge
Test Ortamı için Compose Konfigürasyonu
CI ortamında testler çalışırken gerçek bir veritabanı ve cache servisi lazım. docker-compose.test.yml dosyası bu işi yapar:
version: '3.9'
services:
backend:
build:
context: ./backend
target: test
environment:
- DATABASE_URL=postgresql://testuser:testpass@db:5432/testdb
- REDIS_URL=redis://redis:6379
- APP_ENV=test
command: ["sh", "-c", "python manage.py migrate && python manage.py test --verbosity=2"]
depends_on:
db:
condition: service_healthy
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=testuser
- POSTGRES_PASSWORD=testpass
- POSTGRES_DB=testdb
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
Test ortamında tmpfs kullanmak önemli bir detay. Veritabanı verilerini RAM’de tutarak hem hızı artırıyoruz hem de her test çalışmasında temiz bir slate elde ediyoruz.
GitHub Actions ile Entegrasyon
GitHub Actions, Docker Compose ile çalışmak için oldukça uygun. Önce CI workflow’unu yazalım:
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop, 'feature/**']
pull_request:
branches: [main, develop]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Run tests with Compose
run: |
docker compose -f docker-compose.test.yml up
--build
--abort-on-container-exit
--exit-code-from backend
- name: Cleanup test containers
if: always()
run: |
docker compose -f docker-compose.test.yml down -v --remove-orphans
build:
name: Build and Push Images
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image tags
id: meta
run: |
SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7)
BRANCH=$(echo ${{ github.ref_name }} | sed 's///-/g')
echo "image_tag=${BRANCH}-${SHORT_SHA}" >> $GITHUB_OUTPUT
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Build and push backend
run: |
IMAGE_TAG=${{ steps.meta.outputs.image_tag }}
docker buildx build
--platform linux/amd64,linux/arm64
--cache-from type=gha
--cache-to type=gha,mode=max
--push
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend:${IMAGE_TAG}
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend:latest
./backend
- name: Build and push frontend
run: |
IMAGE_TAG=${{ steps.meta.outputs.image_tag }}
docker buildx build
--platform linux/amd64,linux/arm64
--cache-from type=gha
--cache-to type=gha,mode=max
--push
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:${IMAGE_TAG}
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:latest
./frontend
--abort-on-container-exit flag’i kritik. Test container’ı bittiğinde diğer tüm servisler de duruyor. --exit-code-from backend ise backend servisinin exit code’unu pipeline’a yansıtıyor. Test başarısız olursa pipeline da başarısız sayılıyor.
GitLab CI ile Entegrasyon
GitLab kullananlar için farklı bir yaklaşım gerekiyor. GitLab CI’ın Docker-in-Docker (DinD) servisiyle Compose kullanımı biraz farklı:
# .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_TLS_VERIFY: 1
DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
COMPOSE_PROJECT_NAME: "${CI_PROJECT_NAME}-${CI_PIPELINE_ID}"
.docker_template: &docker_template
image: docker:24
services:
- docker:24-dind
before_script:
- docker info
- apk add --no-cache docker-compose-plugin
test:
<<: *docker_template
stage: test
script:
- |
docker compose -f docker-compose.test.yml up
--build
--abort-on-container-exit
--exit-code-from backend
after_script:
- docker compose -f docker-compose.test.yml down -v --remove-orphans
coverage: '/TOTAL.+?(d+%)$/'
build_images:
<<: *docker_template
stage: build
only:
- main
- develop
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
- IMAGE_TAG="${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
- |
REGISTRY=$CI_REGISTRY_IMAGE
IMAGE_TAG=$IMAGE_TAG
docker compose build --push
artifacts:
reports:
dotenv: build.env
deploy_staging:
stage: deploy
only:
- develop
environment:
name: staging
url: https://staging.myapp.com
script:
- echo "Deploy to staging..."
GitLab CI’da COMPOSE_PROJECT_NAME değişkenini pipeline ID’siyle birleştirmek önemli. Paralel çalışan pipeline’ların container’ları birbirine karışmasını engeller.
Production Deploy Script’i
CI/CD’nin build aşaması tamamlandıktan sonra production’a deploy etmek için bir script lazım. Bu script SSH üzerinden çalışabilir ya da doğrudan server üzerinde tetiklenebilir:
#!/bin/bash
# deploy.sh
set -euo pipefail
# Konfigurasyon
DEPLOY_DIR="/opt/myapp"
COMPOSE_FILE="docker-compose.prod.yml"
BACKUP_IMAGES=3
LOG_FILE="/var/log/myapp/deploy.log"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
check_health() {
local service=$1
local max_attempts=30
local attempt=0
while [ $attempt -lt $max_attempts ]; do
if docker compose -f "$COMPOSE_FILE" ps "$service" | grep -q "healthy|running"; then
log "Service $service is healthy"
return 0
fi
attempt=$((attempt + 1))
log "Waiting for $service... ($attempt/$max_attempts)"
sleep 5
done
log "ERROR: Service $service did not become healthy"
return 1
}
rollback() {
log "ROLLBACK initiated..."
export IMAGE_TAG="$PREVIOUS_TAG"
docker compose -f "$COMPOSE_FILE" up -d --no-build
log "Rollback to $PREVIOUS_TAG completed"
}
# Ortam degiskenlerini yukle
source "$DEPLOY_DIR/.env"
# Yeni image tag'ini al
NEW_TAG=${1:-latest}
PREVIOUS_TAG=$(grep IMAGE_TAG "$DEPLOY_DIR/.env" | cut -d= -f2)
log "Starting deployment: $PREVIOUS_TAG -> $NEW_TAG"
# .env dosyasini guncelle
sed -i "s/IMAGE_TAG=.*/IMAGE_TAG=${NEW_TAG}/" "$DEPLOY_DIR/.env"
# Yeni image'lari cek
export IMAGE_TAG="$NEW_TAG"
docker compose -f "$COMPOSE_FILE" pull
# Zero-downtime deployment
log "Updating services..."
if ! docker compose -f "$COMPOSE_FILE" up -d --no-build --remove-orphans; then
log "Deployment failed, initiating rollback..."
rollback
exit 1
fi
# Health check
if ! check_health backend; then
log "Health check failed, initiating rollback..."
rollback
exit 1
fi
# Eski image'lari temizle
docker image prune -f --filter "until=24h"
log "Deployment successful: $NEW_TAG"
Bu script’in set -euo pipefail ile başlaması önemli. Herhangi bir komut hata verirse script durur, tanımsız değişken kullanılırsa hata verir ve pipe chain’de hata olursa yakalar.
Sırların Yönetimi: Secrets Handling
CI/CD pipeline’larında en kritik konu güvenlik. Environment variable’ları doğrudan kod içine yazmak ya da Git’e commitleyen biri mutlaka çıkar. Bunun yerine:
GitHub Actions için şu yaklaşım çalışır:
- name: Create production .env file
run: |
cat > .env.prod << EOF
DB_USER=${{ secrets.PROD_DB_USER }}
DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}
DB_NAME=${{ secrets.PROD_DB_NAME }}
SECRET_KEY=${{ secrets.APP_SECRET_KEY }}
IMAGE_TAG=${{ steps.meta.outputs.image_tag }}
REGISTRY=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
EOF
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /opt/myapp
cat > .env < /dev/stdin
script_stop: true
.env dosyalarını asla Git’e eklemeyin. .gitignore dosyanızda mutlaka şunlar olsun:
.env
.env.prod
.env.staging
.env.local
*.env
!.env.example
Çoklu Ortam Yönetimi
Gerçek dünyada dev, staging, production en az üç ortam oluyor. Compose override sistemi bunu zarif bir şekilde çözüyor:
# Development
docker compose up
# Staging
docker compose -f docker-compose.yml -f docker-compose.staging.yml up
# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up
docker-compose.prod.yml örneği:
version: '3.9'
services:
backend:
restart: always
deploy:
resources:
limits:
cpus: '1'
memory: 512M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
frontend:
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/prod.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
db:
restart: always
volumes:
- /data/postgres:/var/lib/postgresql/data
deploy:
resources:
limits:
memory: 1G
Pipeline’da Image Tagging Stratejisi
Hangi image’ın nerede çalıştığını bilmek istersiniz. Bunun için tutarlı bir tagging stratejisi şart:
main-a1b2c3d: main branch’ten gelen commit hash’i ile tagdevelop-x9y8z7w: develop branch’ten gelen taglatest: Her zaman main’deki son stable versiyonu gösterirv1.2.3: Release tag’leri için semantic versioning
# CI script'inde tag oluşturma
SHORT_SHA=$(git rev-parse --short HEAD)
BRANCH=$(git rev-parse --abbrev-ref HEAD | sed 's///-/g')
TIMESTAMP=$(date +%Y%m%d%H%M%S)
# Ana tag
IMAGE_TAG="${BRANCH}-${SHORT_SHA}"
# Release ise
if git describe --exact-match --tags HEAD 2>/dev/null; then
RELEASE_TAG=$(git describe --exact-match --tags HEAD)
echo "release_tag=${RELEASE_TAG}" >> $GITHUB_OUTPUT
fi
echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT
Monitoring ve Alerting Entegrasyonu
Deploy pipeline’ı başarıyla tamamlandı, ama iş bitmedi. Deployment sonrası monitoring de pipeline’ın parçası olmalı:
notify_deployment:
name: Notify Deployment Status
runs-on: ubuntu-latest
needs: [deploy]
if: always()
steps:
- name: Send Slack notification
uses: 8398a7/action-slack@v3
with:
status: ${{ needs.deploy.result }}
fields: repo,message,commit,author,action,eventName,ref,workflow
text: |
Deployment ${{ needs.deploy.result == 'success' && 'basarili' || 'BASARISIZ' }}
Image: ${{ env.IMAGE_TAG }}
Ortam: Production
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Yaygın Sorunlar ve Çözümleri
CI ortamında karşılaşılan bazı klasik sorunlar var:
Container başlamadan test koşmaya çalışmak: depends_on ile healthcheck birleşimi bu sorunu çözer. Ama bazı CI sistemlerinde yine de sorun çıkar. wait-for-it.sh gibi araçlar kullanabilirsiniz.
Port çakışmaları: Paralel pipeline’lar aynı host port’u kullanmaya çalışabilir. Çözüm: Production dışı ortamlarda ports tanımlamayın ya da dinamik port ataması kullanın.
Disk dolması: CI runner’larında build cache ve eski image’lar birikir. Her job sonunda temizlik şart:
# Cleanup job
cleanup:
runs-on: ubuntu-latest
if: always()
steps:
- name: Docker cleanup
run: |
docker system prune -f
docker volume prune -f
docker image prune -a -f --filter "until=24h"
Build cache’in doğru kullanılmaması: --cache-from ve --cache-to flag’lerini doğru kullanmak build sürelerini dramatik şekilde düşürür. GitHub Actions’ta type=gha cache en iyi seçenek.
Sonuç
CI/CD pipeline ile Docker Compose entegrasyonu ilk bakışta karmaşık görünebilir. Ama katmanlı bir yaklaşımla ilerlerseniz, her adım bir öncekinin üzerine oturur. Temel Compose konfigürasyonunu hazırladıktan sonra test ortamını ekleyin, ardından pipeline’ı yazın, en son production deploy mekanizmasını oturtun.
Burada anlattığımız yaklaşımların tamamını tek seferde kurmaya çalışmayın. Kendi projenize uygun olanları seçin. Küçük bir ekip için basit bir CI testi bile production krizlerini önlemek açısından devasa bir fark yaratıyor. Environment variable yönetimini düzgün yaparsanız, aynı Compose yapısıyla dev’den production’a sorunsuz geçiş yapabilirsiniz.
En önemli nokta: Pipeline’ınız yetersizse daha fazla manuel test yapmanız gerekir. Pipeline ne kadar güçlüyse, geceleri o kadar rahat uyursunuz.