Mert Özen Her Satırda Daha İleri
DevOps GitHub Actions — #11

GitHub Actions ile Deploy 2: Kendi Linux Sunucuna SSH, Docker Image ve GHCR

Mert Özen 8 Kas 2026 12 dk 4 görüntülenme
GitHub Actions ile Deploy 2: Kendi Linux Sunucuna SSH, Docker Image ve GHCR

Image'ı GitHub Actions'ta bir kez build edip GHCR'a push'la, sunucuya SSH ile yalnızca digest'i gönder; sunucu Docker Compose ile çeker ve yeniden başlatır. metadata-action, build-push-action ve gha cache backend'i (28 sn'den 4 sn'ye), actions/attest, known_hosts pin'leme ve host key değişince ne olduğu, .env.previous ile rollback; act ile gerçek koşu.

Sürüm notu: Bu yazıdaki örnekler act 0.2.97, Docker 29.2 (Buildx 0.36.1), docker/build-push-action@v7, docker/login-action@v4, docker/setup-buildx-action@v4, registry 3, OpenSSH 9.6, Docker Compose 2.40 ve .NET 10 ile denenmiştir. GitHub Container Registry'ye gerçek push ve attestation GitHub'da koşulmamıştır; aradaki farklar yazıda tek tek belirtilmiştir.

Önce Tahmin Et

Deploy job'ı sunucuya SSH (Secure Shell) ile bağlanıyor ve known_hosts dosyasını bir environment secret'ından yazıyor. Bir gece hosting firması sunucuyu aynı IP (Internet Protocol) adresiyle yeniden kuruyor. Ertesi sabah deploy ne yapar: sessizce yeni sunucuya bağlanıp devam mı eder, yoksa düşer mi? Peki workflow'da -o StrictHostKeyChecking=no yazsaydı?

İkinci soru: aynı commit için image'ı iki kez build edip push'luyorsun; ikinci build tamamen cache'ten geliyor. Registry'deki iki image'ın digest'i aynı mı olur?

Hedef: Image Bir Kez Build Edilir, Sunucu Sadece Çeker

Önceki yazıda deploy hedefi App Service'ti ve paket bir zip'ti. Bu yazıda hedef kendi Linux sunucumuz, paket ise bir container image'ı. Akış şu: CI (Continuous Integration) job'ı Bookstore.Api'nin image'ını build edip GHCR'a (GitHub Container Registry) push'lar ve digest'ini output olarak verir. Deploy job'ı SSH ile sunucuya bağlanır, o digest'i sunucudaki Docker Compose dosyasına yazar, image'ı çeker ve container'ı yeniden başlatır. Sunucuda kaynak kod yok, build yok, SDK (Software Development Kit) yok. Dockerfile'ın kendisini ASP.NET Core Web API (Application Programming Interface) serisinin "Docker'a Almak ve GitHub Actions ile CI/CD Kurmak" yazısında anlattık, ayrıntısı ilerideki Docker serisinin konusu; burada api/Dockerfile hazır, multi-stage ve --locked-mode restore yapan bir dosya olarak duruyor.

Tam workflow:

name: deploy-vm

on:
  push:
    branches: [main]
    tags: ['v*']

permissions:
  contents: read

env:
  IMAGE: ghcr.io/octo-org/bookstore-api

jobs:
  image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    outputs:
      image-ref: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v7
      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=sha
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v4
      - id: push
        uses: docker/build-push-action@v7
        with:
          context: .
          file: api/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=bookstore-api
          cache-to: type=gha,mode=max,scope=bookstore-api
      - if: startsWith(github.ref, 'refs/tags/v')
        uses: actions/attest@v4
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true

  deploy-staging:
    needs: image
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: ${{ vars.APP_URL }}
    concurrency:
      group: deploy-staging
      cancel-in-progress: false
    steps:
      - name: Configure SSH
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
      - name: Pull and restart on the server
        env:
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
          DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
          IMAGE_REF: ${{ needs.image.outputs.image-ref }}
        run: |
          ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" \
            -o StrictHostKeyChecking=yes -o BatchMode=yes \
            "deploy@$DEPLOY_HOST" bash -s -- "$IMAGE_REF" <<'EOF'
          set -euo pipefail
          cd /srv/bookstore
          cp .env .env.previous 2>/dev/null || true
          echo "BOOKSTORE_IMAGE=$1" > .env
          docker compose pull --quiet
          docker compose up -d --wait
          docker compose ps --format '{{.Name}} {{.Image}} {{.Status}}'
          EOF
      - name: Health check
        env:
          APP_URL: ${{ vars.APP_URL }}
        run: curl --fail --silent --show-error --retry 5 --retry-delay 3 --retry-all-errors "$APP_URL/health"

Image'ı Etiketlemek: metadata-action

docker/metadata-action, Git context'inden tag ve OCI (Open Container Initiative) label'ları üretir. Bizim tanımımızla main'e her push, commit SHA'sının (Secure Hash Algorithm) ilk yedi karakterinden oluşan sha-d4be9d0 gibi bir tag alır. v1.4.0 tag'i push'landığında buna 1.4.0 ve 1.4 eklenir; semver tipi kullanıldığı için action'ın varsayılan latest=auto davranışı latest'i de ekler. Label'lar arasında org.opencontainers.image.source, revision ve created var. source label'ı, GHCR'daki package'ı repository'ye bağlayan şey: package repository'nin erişim izinlerini miras alır ama görünürlüğünü değil. GHCR'a ilk kez push'lanan package varsayılan olarak private'tır.

Tag'ler okunabilirlik için; deploy için digest kullanıyoruz. image job'ının output'u ghcr.io/octo-org/bookstore-api@sha256:... biçiminde. Tag'ler taşınabilir (latest her release'te, 1.4 her patch'te yer değiştirir), digest değişmez. Sunucuya "şu tag'i çek" değil "tam olarak şu byte'ları çek" diyoruz; staging'de test edilen image ile production'a giden image'ın aynı olduğunu kanıtlamanın en basit yolu bu.

Yerel koşuda burada bir engelle karşılaştım. act ile çalıştırınca metadata-action şöyle düştü:

[deploy-vm/image]   ❗  ::error::Bad credentials - https://docs.github.com/rest
[deploy-vm/image]   ❌  Failure - Main docker/metadata-action@v6 [2.665411125s]

Action, label'ları doldurmak için GitHub API'sinden repository bilgisini okuyor ve act'te geçerli bir token yok. Gerçek bir token vermek istemediğim için yerel kopyada bu step'i, aynı formatta tags ve labels output'u yazan küçük bir run: step'iyle değiştirdim. Yukarıdaki tag ve label davranışı action'ın README'sinden, aşağıdaki build, push ve deploy çıktıları ise gerçek koşudan.

GHCR'a Push ve GitHub Actions Cache Backend'i

GHCR'a giriş için ayrı bir secret gerekmiyor: GITHUB_TOKEN, job'a packages: write verildiğinde workflow'un repository'sine bağlı package'lara push edebilir. Tek istisna, aynı isimde ama repository'ye bağlı olmayan bir package zaten varsa; o durumda push reddedilir ve package ayarlarından repository'ye erişim vermen gerekir.

Yerel koşuda GHCR yerine localhost:18310'da çalışan resmi registry:3 container'ını kullandım; workflow'un yerel kopyasında image adı ve login registry'si bu adrese çevrildi, setup-buildx'e de BuildKit container'ının bu adrese ulaşabilmesi için driver-opts: network=host eklendi. İlk koşuda build 28 saniye sürdü ve sonunda cache GitHub Actions cache backend'ine yazıldı:

#7 importing cache manifest from gha:6229865950423264461
#18 exporting manifest list sha256:65e0822b607b1e09eb3daee0f548edd267994cbb68127b2057cb3d567db64dbf done
#18 pushing manifest for localhost:18310/octo-org/bookstore-api:sha-d4be9d0@sha256:65e0822b607b1e09eb3daee0f548edd267994cbb68127b2057cb3d567db64dbf
#19 exporting to GitHub Actions Cache
#19 preparing build cache for export 0.5s done
✅  Success - Main docker/build-push-action@v7 [28.264897167s]

Ardından yalnızca README'yi değiştiren bir commit attım ve aynı workflow'u yeniden koştum. Dockerfile yalnızca api/src'yi kopyaladığı için bütün layer'lar cache'ten geldi:

#7 importing cache manifest from gha:16090413008912912096
#9 CACHED
#10 CACHED
...
#17 CACHED
✅  Success - Main docker/build-push-action@v7 [3.937505125s]

28 saniyeden 4 saniyeye. type=gha backend'i, act'in kendi cache server'ıyla da çalıştı. GitHub'da bu cache, repository'nin Actions cache kotasından (varsayılan 10 GB, yedi gün erişilmeyen entry'ler silinir) yer kaplar. mode=max ara stage'lerin layer'larını da yazar; multi-stage Dockerfile'da SDK stage'ini cache'lemenin yolu bu, ama kotayı hızlı doldurur. Her image için ayrı scope ver; vermezsen API ve web image'ları aynı cache'i birbirinin üzerine yazar. Buildx'in gha backend'i yalnızca cache service API v2'yi destekliyor; v1 15 Nisan 2025'te kapatıldı, bu yüzden Buildx 0.21'den eski bir sürümle bu satırlar çalışmaz.

İkinci sorunun cevabı da bu koşuda: layer'ların hepsi cache'ten gelse de ikinci image'ın digest'i sha256:0909bf90... oldu, ilki sha256:65e0822b... idi. Image config'indeki revision label'ı yeni commit'i gösteriyor ve build-push-action'ın varsayılan olarak eklediği provenance attestation manifest'i (log'daki exporting attestation manifest satırı) build'e özgü bilgi taşıyor. "Aynı kod, aynı digest" beklentisi bu yüzden tutmuyor; digest'i her build'in kimliği olarak düşün.

Bir de act'e özgü bir uyarı gördüm: build-push-action post step'inde build kaydını (build record) artifact olarak yüklemeye çalışıyor ve act'in artifact server'ı 09. yazıda gördüğümüz mime_type sorunu yüzünden bunu reddediyor. Step uyarıyla geçiyor: ::warning::Failed to CreateArtifact: Failed to make request after 5 attempts: Unexpected end of JSON input. GitHub'da bu kayıt run özetinde indirilebilir olarak görünür.

Artifact Attestation

Son step, tag push'larında image için imzalı bir build provenance'ı üretip registry'ye yazıyor. actions/attest@v4, eski attest-build-provenance ve attest-sbom action'larını kendi içine aldı (attest-build-provenance artık onun üzerinde ince bir wrapper). İmza Sigstore üzerinden, job'ın OIDC (OpenID Connect) token'ıyla atılıyor; id-token: write ve attestations: write bu yüzden gerekli. Tüketen taraf image'ı çalıştırmadan önce şöyle doğrular:

gh attestation verify oci://ghcr.io/octo-org/bookstore-api:1.4.0 -R octo-org/bookstore

Attestation public repository'lerde tüm planlarda, private ve internal repository'lerde GitHub Enterprise Cloud'da var. OIDC gerektirdiği için act'te koşamaz; bu step'i yalnızca tag push'larında çalıştırarak yerel main koşularının dışında bıraktım ve actionlint ile doğruladım. SBOM (Software Bill of Materials) ve imza doğrulamasını policy'ye bağlamak ilerideki DevSecOps serisinin konusu.

Sunucu Tarafı: deploy Kullanıcısı ve Compose Dosyası

Sunucuyu bir kez, elle veya configuration management aracıyla hazırlıyorsun. Yerel testte bunun yerine ubuntu:24.04 üzerine OpenSSH, Docker CLI (Command Line Interface) ve Compose plugin'i kurulmuş bir container kullandım; container, host'un Docker socket'ini kullanıyor. Gereken üç şey var. Birincisi, root olmayan bir deploy kullanıcısı, yalnızca key ile giriş (PasswordAuthentication no, PermitRootLogin no). İkincisi, bu kullanıcının authorized_keys'inde yalnızca CI için üretilmiş, başka hiçbir yerde kullanılmayan bir ed25519 key'in public kısmı. Üçüncüsü, /srv/bookstore/compose.yaml:

name: ghawork2
services:
  api:
    image: ${BOOKSTORE_IMAGE:?BOOKSTORE_IMAGE is not set}
    restart: unless-stopped
    ports:
      - "18320:8080"

Proje adı ve port, bu makinedeki diğer container'larla çakışmasın diye böyle; gerçek sunucuda bookstore ve reverse proxy arkasında bir port olur. Image satırı bilerek bir variable: Compose onu aynı klasördeki .env dosyasından okuyor, :? sözdizimi de variable boşsa Compose'un hiç başlamamasını sağlıyor. Deploy script'i her seferinde mevcut .env'i .env.previous'a kopyalıyor. İki deploy'dan sonra sunucudaki durum:

$ cat .env .env.previous
BOOKSTORE_IMAGE=localhost:18310/octo-org/bookstore-api@sha256:0909bf90a142ce3f4d3f7b00ba74feeebef0018a5f17d5ffee610ae5cd820394
BOOKSTORE_IMAGE=localhost:18310/octo-org/bookstore-api@sha256:65e0822b607b1e09eb3daee0f548edd267994cbb68127b2057cb3d567db64dbf

Rollback, .env.previous'ı geri kopyalayıp docker compose up -d demekten ibaret; image zaten sunucuda. Image GHCR'da private ise sunucunun da çekebilmesi gerekir: ya sunucuda read:packages yetkili bir token ile bir kez docker login ghcr.io yaparsın ya da deploy sırasında job'ın GITHUB_TOKEN'ını SSH üzerinden docker login --password-stdin'e verip iş bitince docker logout dersin. İkincisi kalıcı credential bırakmaz ama token'ı sunucuya taşır; yerel registry'de auth olmadığı için bu adımı koşmadım.

SSH ile Deploy ve known_hosts'u Pin'lemek

Deploy job'ı iki secret kullanıyor, ikisi de staging environment'ına ait: DEPLOY_SSH_KEY (private key) ve DEPLOY_KNOWN_HOSTS (sunucunun host key'i). İkincisi en çok atlanan parça. known_hosts'u sunucunun host key'ini güvenli bir kanaldan bir kez alıp secret'a koyarak üretirsin:

$ ssh-keyscan -p 18322 -t ed25519 localhost
[localhost]:18322 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHtLXtTMvNYsUEfXrDwbC/wjziUo3vtxzI3Th3dq36vM
$ ssh-keygen -lf known_hosts
256 SHA256:dpL85UcfPA0APZ1fHWhjBhfySkXUHE8J41lw8VqZQDk [localhost]:18322 (ED25519)

ssh-keyscan'i workflow içinde çalıştırmak pin'leme değildir; o anda karşında kim varsa onun key'ine güvenirsin. Doğru yol, fingerprint'i sunucunun konsolundan (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub) veya hosting panelinden karşılaştırıp secret'a bir kez yazmak.

act ile koşunca deploy job'ının çıktısı:

[deploy-vm/deploy-staging]   ✅  Success - Main Configure SSH [64.639708ms]
[deploy-vm/deploy-staging]   |  api Pulling
[deploy-vm/deploy-staging]   |  api Pulled
[deploy-vm/deploy-staging]   |  Container ghawork2-api-1  Creating
...
[deploy-vm/deploy-staging]   |  Container ghawork2-api-1  Started
[deploy-vm/deploy-staging]   |  Container ghawork2-api-1  Waiting
[deploy-vm/deploy-staging]   |  Container ghawork2-api-1  Healthy
[deploy-vm/deploy-staging]   | ghawork2-api-1 localhost:18310/octo-org/bookstore-api@sha256:65e0822b607b1e09eb3daee0f548edd267994cbb68127b2057cb3d567db64dbf Up Less than a second
[deploy-vm/deploy-staging]   ✅  Success - Main Pull and restart on the server [3.61241025s]
[deploy-vm/deploy-staging]   ✅  Success - Main Health check [130.912416ms]

Script'i bash -s -- "$IMAGE_REF" ile stdin'den gönderiyoruz; böylece sunucuda bir deploy script'i bakımı yapmıyoruz ve image referansı tırnak kurallarıyla boğuşmadan $1 olarak geliyor. Heredoc'un 'EOF' şeklinde tırnaklı olması önemli: tırnaksız olsaydı $1 runner'da genişletilirdi. BatchMode=yes, key reddedilirse SSH'nin şifre sorup job'ı dakikalarca asılı bırakmasını engelliyor. docker compose up --wait de container ayağa kalkana kadar bekliyor.

Şimdi tahminin ilk sorusu. Sunucunun host key'lerini yeniden ürettim (bir yeniden kurulumun yaptığı şey) ve aynı known_hosts ile bağlanmayı denedim:

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
The fingerprint for the ED25519 key sent by the remote host is
SHA256:bjojcd8GqkHoYXvmfPcTMZRyV1TDHd40Hwe0RdfFO4M.
Host key for [localhost]:18322 has changed and you have requested strict checking.
Host key verification failed.

Deploy düşer, ve düşmesi gerekir. Aynı denemeyi -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ile yaptığımda SSH tek satırlık bir uyarı (Permanently added '[localhost]:18322' (ED25519) to the list of known hosts.) basıp bağlandı. Yani o seçenekle workflow, DNS (Domain Name System) kaydını veya trafiği ele geçiren herhangi bir makineye production image referansını, ve agent forwarding açıksa daha fazlasını, gözü kapalı teslim eder. Host key gerçekten değiştiyse yapılacak iş, yeni fingerprint'i doğrulayıp secret'ı güncellemek.

Alternatif: Release Klasörü ve systemd

Container kullanmayan sunucularda aynı fikir release klasörleriyle uygulanır: dotnet publish çıktısını rsync ile /srv/bookstore/releases/<sha>'ya kopyala, current symlink'ini ln -sfn ile yeni klasöre çevir ve sudo systemctl restart bookstore de. deploy kullanıcısına sudoers'ta yalnızca bu tek komut için izin ver. Test container'ımda systemd olmadığı için bu varyantı koşmadım; systemd unit dosyası ilerideki Web Server'lar ve Hosting serisinin systemd yazısında. Kubernetes'e deploy ise bambaşka bir model: orada workflow'un işi image'ı push'lamak ve manifest'teki digest'i güncellemek, cluster'a uygulamayı Kubernetes serisi ve GitOps serisi anlatacak.

Üretimde Dikkat

CI key'ini yalnızca CI için üret ve kapsamını daralt. authorized_keys'te key'in başına restrict,from="..." seçenekleri eklenebilir; GitHub-hosted runner'ların IP aralıkları geniş ve değişken olduğu için from kısıtı ancak self-hosted runner veya sabit IP'li larger runner ile anlamlı.

Sunucuyu SSH'ye açmak istemiyorsan modeli ters çevir. Sunucuda çalışan bir self-hosted runner veya sunucunun kendisinin registry'yi izlemesi (pull tabanlı deploy) dışarıdan gelen bağlantı ihtiyacını kaldırır. Runner seçeneklerini 14. yazıda konuşacağız.

Health check'i sunucunun içinden değil, kullanıcının geldiği yerden yap. Bizim örneğimizde runner'dan $APP_URL/health'e gidiyoruz; reverse proxy veya TLS (Transport Layer Security) ayarı bozulduysa container içi health check bunu görmez.

Production için aynı job'ı ikinci bir environment ile tekrar yazmak yerine 12. yazıdaki reusable workflow'u kullan; staging ve production'ın birbirinden ayrışması en çok kopyala-yapıştır deploy job'larında olur.

Disk'i izle. Her deploy yeni bir image çeker; docker image prune'u zamanlanmış bir iş olarak çalıştırmazsan birkaç ay sonra sunucu dolar. Rollback için son birkaç image'ı tutmak yeterli.

Sık Düşülen Tuzaklar

StrictHostKeyChecking=no veya workflow içinde ssh-keyscan. İkisi de "kim olursa olsun bağlan" demek.

root ile SSH. Deploy için root gerekmez; deploy kullanıcısı, Docker grubu (veya tek komutluk sudoers) yeterli. Docker grubunun pratikte root'a eşdeğer olduğunu da unutma; bu yüzden key'in kendisini korumak kritik.

latest tag'iyle deploy. İki sunucu farklı anlarda çekerse farklı sürümler çalıştırır ve rollback'te "önceki latest" diye bir şey yoktur. Digest veya en azından SHA tag'i kullan.

Private key'i echo ile yazmak. Bazı shell'lerde echo ters bölüleri yorumlar ve satır sonlarını bozar; printf '%s\n' güvenli. Key'in sonundaki newline kaybolursa OpenSSH key'i yükleyemeyebilir.

Cache scope'unu vermemek. Monorepo'da API ve web image'ları aynı gha scope'unu paylaşırsa her build diğerinin cache'ini ezer ve "cache neden hiç tutmuyor" sorusu başlar.

Heredoc'u tırnaksız yazmak. <<EOF ile $1 ve $(...) runner'da genişler; sunucuda çalışmasını istediğin komutlar runner'da çalışır.

Küçük Bir Deneme

★☆☆ Workflow'a workflow_dispatch ile tetiklenen bir rollback job'ı ekle: SSH ile sunucuya bağlanıp .env.previous'ı .env'e kopyalasın ve docker compose up -d --wait çalıştırsın.

★★☆ authorized_keys'teki CI key'ini command="..." seçeneğiyle yalnızca tek bir deploy script'ini çalıştırabilir hâle getir ve image referansını SSH_ORIGINAL_COMMAND'dan okut. Key ele geçirilse bile neyin yapılamayacağını listele.

★★★ Web image'ı için ikinci bir build step'i ekle (scope=bookstore-web) ve iki image'ı aynı Compose dosyasında digest'le deploy et. Ardından iki build'i aynı scope ile çalıştırıp cache hit oranının nasıl düştüğünü log'daki CACHED satırlarından say.

Bu iki yazıda staging ve production için neredeyse aynı job'ları tekrar tekrar yazdık; build adımları da her workflow'da kopyalandı. Bir sonraki yazıda bu tekrarı composite action, reusable workflow ve YAML (YAML Ain't Markup Language) anchor'larıyla azaltıyoruz.