Skip to main content
DevOps dari nol · Pelajaran 6 dari 8 · 14 menit

Deploy Docker ke VPS dengan GitHub Actions dan GHCR

Cara deploy Docker ke VPS dengan GitHub Actions seperti situs ini: verify sebagai gerbang, image di-push ke GHCR, lalu SSH dan Docker Compose dengan aman.

Oleh Matthews WongDitinjau pada

Poin utama

Deploy ke VPS dengan GitHub Actions adalah workflow berisi job yang dirangkai: verify memeriksa commit, build-and-push membuat Docker image dan mendorongnya ke registry seperti GHCR, lalu deploy masuk ke server lewat SSH, menulis pengaturan dari secret, dan membuat ulang container. needs menahan commit rusak agar tidak sampai ke server, dan concurrency per job mencegah deploy terputus di tengah jalan.

Setelah pelajaran ini, Anda bisa

  • Merangkai verify, build-and-push, dan deploy dengan needs, serta memperkirakan job mana yang dilewati saat satu job gagal.
  • Mendorong image ke GHCR dengan GITHUB_TOKEN milik job itu sendiri, lalu men-deploy build yang persis sama lewat SHA commit-nya.
  • Memisahkan concurrency agar build yang sudah usang dibatalkan, sementara deploy tidak pernah terputus di tengah jalan.
Lihat cara kerjanya

Ikuti diagramnya langkah demi langkah

Tekan Putar, atau melangkah dengan panah. Setiap langkah juga ditulis di bawah diagram.

Bagaimana satu push menjadi deploy

Satu push menjalankan verify, lalu build-and-push, lalu deploy. Verify yang gagal membuat sisanya dilewati; run yang lolos mendorong image ke ghcr.io, lalu VPS menariknya dan membuat ulang container.

Langkah 1 dari 7. Satu push ke main menjalankan tiga job yang dirangkai dengan needs. Image yang mereka build berpindah lewat ghcr.io ke container di VPS Anda.on: push: branches: [main]

Tombol: ← → langkah · Home / End · K putar atau jeda

  1. Satu push ke main menjalankan tiga job yang dirangkai dengan needs. Image yang mereka build berpindah lewat ghcr.io ke container di VPS Anda.

    on: push: branches: [main]
  2. verify menjalankan npm ci, tsc --noEmit, dan lint. Satu type error membuatnya gagal, semua job yang membutuhkannya dilewati, dan VPS tetap memakai image terakhir yang sehat.

    - run: npm ci - run: npx tsc --noEmit - run: npm run lintTebak langkah berikutnya Tanpa needs, kapan build-and-push akan mulai? (Setelah verify lolos, secara default · Langsung, bersamaan dengan verify)
  3. Push perbaikannya. verify lolos, lalu build-and-push mulai karena menulis needs: verify. Tanpa needs, ketiga job akan berjalan bersamaan.

    build-and-push: name: Build & push image needs: verify
  4. build-and-push login ke ghcr.io memakai GITHUB_TOKEN milik job itu sendiri, mem-build image, lalu push dua tag: latest dan SHA commit.

    - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io
  5. deploy membutuhkan build-and-push. Lewat SSH, job ini menyalin docker-compose.yml lalu menulis .env dari GitHub secrets. Tidak ada secret aplikasi di bagian lain workflow.

    - name: Deploy over SSH uses: appleboy/ssh-action@v1.0.3
  6. Server login ke ghcr.io, menarik image berdasarkan tag SHA-nya, lalu docker compose up -d membuat ulang container dari image itu.

    docker compose pull docker compose up -d --remove-orphansTebak langkah berikutnya up -d sudah selesai. Apakah job deploy sudah hijau? (Ya, sebelum health check lolos · Belum, job menunggu status healthy dulu)
  7. up -d selesai begitu container menyala, jadi job sudah hijau sebelum health check lolos. Dengan --wait, rilis yang gagal health check ikut menggagalkan run.

    interval: 30s timeout: 5s retries: 3 start_period: 20s

Bagaimana needs dan output merangkai job dalam deploy GitHub Actions?

Secara default, job dalam satu workflow berjalan paralel. needs mengubahnya menjadi rantai: build-and-push menulis needs: verify, dan deploy menulis needs: build-and-push. Aturan GitHub tegas: kalau sebuah job gagal atau dilewati, semua job yang membutuhkannya ikut dilewati. Itulah seluruh jaring pengamannya, jadi commit yang gagal typecheck tidak akan pernah menghasilkan image, apalagi sampai ke server.

Baca selengkapnya (2)

Job juga bisa mengoper nilai ke job berikutnya. build-and-push menulis referensi image lengkap, ghcr.io/<owner>/<repo>:<sha>, ke $GITHUB_OUTPUT lalu mengeksposnya sebagai output image. deploy membacanya sebagai needs.build-and-push.outputs.image dan menuliskannya ke .env sebagai IMAGE, jadi server menarik persis build yang baru saja lolos, bukan apa pun yang sedang ditunjuk latest. Referensinya diubah ke huruf kecil lebih dulu, karena nama repository di registry wajib huruf kecil, sedangkan nama owner GitHub belum tentu.

Permission juga diatur per job. build-and-push meminta packages: write karena ia melakukan push; deploy hanya meminta packages: read karena server cukup menarik image. Kalau satu job disusupi, kerusakannya terbatas pada apa yang memang dibutuhkan job itu.

Kenapa build boleh dibatalkan, tetapi deploy tidak boleh?

Sebuah concurrency group hanya mengizinkan paling banyak satu job yang berjalan dan, secara default, satu job yang menunggu. Saat job baru masuk ke group, job yang menunggu dibatalkan; cancel-in-progress: true juga membatalkan job yang sedang berjalan. Opsi kedua itu tepat untuk build, tetapi salah besar untuk deploy.

Baca selengkapnya (2)

Pada 2026-09-24, situs ini memakai satu group di level workflow dengan cancel-in-progress: false, dan serentetan commit membuat antrean 28 run, 27 di antaranya mem-build kode yang sudah usang. Mengubah group itu menjadi true memang akan mengosongkan antrean, tetapi juga membuka kemungkinan run dibatalkan di tengah deploy lewat SSH, yang bisa meninggalkan server di antara dua image.

Solusinya dua group. verify dan build-and-push berbagi group build-${{ github.ref }} dengan cancel-in-progress: true, jadi push yang lebih baru langsung membuang pekerjaan yang usang. deploy berada sendirian di group deploy-vps dengan cancel-in-progress: false, jadi deploy menunggu giliran dan selalu selesai. Build yang dibatalkan juga tidak pernah meninggalkan deploy setengah jadi: deploy-nya membutuhkan build itu, jadi deploy sudah dilewati sebelum sempat mulai.

Bagaimana workflow login ke GHCR dan menjaga secret tetap di luar repo?

GitHub Container Registry berada di ghcr.io, dan workflow bisa push ke sana memakai GITHUB_TOKEN, token yang dibuat GitHub untuk setiap job. Token ini kedaluwarsa saat job selesai atau saat batas umurnya habis, jadi tidak ada password registry berumur panjang yang bisa bocor. build-and-push memakainya lewat docker/login-action. Job deploy mengirim token miliknya sendiri ke docker login di server, menarik image, lalu menjalankan docker logout di akhir.

Baca selengkapnya (2)

Secret aplikasi tidak pernah menyentuh repository maupun image. Semuanya disimpan sebagai GitHub secrets dan variables, dan hanya muncul di satu tempat: step deploy, yang menuliskannya ke .env di server lewat quoted heredoc lalu menjalankan chmod 600 .env. docker-compose.yml membaca file itu untuk environment container sekaligus untuk substitusi IMAGE dan APP_PORT.

Ada dua batasan yang perlu diketahui. Secret tidak diberikan ke workflow yang dipicu dari fork, dan GitHub menyebut penyamaran otomatis di log tidak dijamin, karena secret yang sudah diubah bentuknya tidak lagi cocok dengan nilai aslinya. Jangan pernah mencetak secret ke log, walaupun Anda yakin nilainya akan disamarkan.

Bagaimana build cache Next.js bertahan di Docker build pada CI?

next build di situs ini hanya bisa inkremental kalau .next/cache dari build sebelumnya tersedia. Runner hosted selalu mulai kosong, jadi job memulihkan folder itu dengan actions/cache. Key-nya diakhiri SHA commit, jadi tidak pernah cocok persis dan setiap run menyimpan entri baru, sementara restore-keys mundur ke cache terbaru untuk lockfile yang sama, lalu ke cache apa pun.

Baca selengkapnya (2)

Build-nya sendiri berjalan di dalam Docker, jadi cache harus pulang-pergi. Baris !.next/cache di .dockerignore membuat folder yang sudah dipulihkan ikut masuk ke build context. Setelah push, build-push-action dijalankan sekali lagi dengan target stage cache-export dan menulis cache yang sudah bertambah kembali ke runner, lalu actions/cache menyimpannya. Cache mount BuildKit kelihatan lebih sederhana, tetapi dokumentasi Docker menyebut BuildKit tidak menyimpan cache mount di cache GitHub Actions secara default.

Dua pagar pengaman menjaga optimasi ini agar tidak merugikan. Step export memakai continue-on-error: true, karena gagal mengekspor cache tidak boleh menahan deploy image yang sudah di-push. Lalu job tidak menyimpan apa pun begitu .next/cache melewati 1.500 MB, karena cache yang dipulihkan, ditambah, lalu disimpan di setiap run hanya akan terus membesar.

Cara situs ini melakukannya

Konfigurasi nyata di balik pelajaran ini

Kutipan dari repositori yang membangun dan men-deploy situs ini, apa adanya.

.github/workflows/deploy.yml:42-54
  build-and-push:
    name: Build & push image
    needs: verify
    runs-on: ubuntu-latest
    # Same group as verify: one supersede cancels the whole build chain.
    concurrency:
      group: build-${{ github.ref }}
      cancel-in-progress: true
    permissions:
      contents: read
      packages: write
    outputs:
      image: ${{ steps.meta.outputs.image }}

needs membuat job ini menunggu verify, dan ia memakai concurrency group yang sama dengan verify, jadi satu push baru membatalkan seluruh rantai build. Hanya job ini yang punya packages: write. Output image-nya adalah cara deploy tahu build mana yang harus dijalankan.

.github/workflows/deploy.yml:185-199
  deploy:
    name: Deploy to VPS
    needs: build-and-push
    runs-on: ubuntu-latest
    # The one group that must NEVER cancel in progress. This step SSHes to the
    # VPS and runs `docker compose pull && up -d`; interrupting it can leave the
    # box between images. Concurrent deploys queue and run one at a time, which
    # is the original intent — it is only the build chain above that should be
    # throwing away superseded work.
    concurrency:
      group: deploy-vps
      cancel-in-progress: false
    permissions:
      contents: read
      packages: read

Separuh lain dari pemisahan itu. Group deploy-vps tidak pernah membatalkan deploy yang sedang berjalan, jadi sesi SSH tidak pernah terputus di antara pull dan up. Job ini hanya bisa membaca package, tidak menulis, dan memang hanya itu yang dibutuhkan server yang cukup menarik image.

.github/workflows/deploy.yml:104-129
      - name: Compute lowercase image ref
        id: meta
        run: |
          ref="$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
          echo "ref=${ref}" >> "$GITHUB_OUTPUT"
          echo "image=${ref}:${{ github.sha }}" >> "$GITHUB_OUTPUT"

      - uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ steps.meta.outputs.ref }}:latest
            ${{ steps.meta.outputs.ref }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Nama image diambil dari github.repository lalu diubah ke huruf kecil dengan tr, jadi path registry tidak pernah diketik manual. Login memakai GITHUB_TOKEN milik job, bukan password yang disimpan. Dua tag di-push: latest untuk manusia, dan SHA commit, yang dibawa output image ke job deploy.

.github/workflows/deploy.yml:87-97
      - name: Restore Next.js build cache
        uses: actions/cache@v4
        with:
          path: .next/cache
          # SHA in the key so every run writes a fresh entry; restore-keys then
          # falls back to the most recent cache for the same lockfile, and
          # finally to any cache at all, rather than starting cold.
          key: next-cache-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
          restore-keys: |
            next-cache-${{ hashFiles('package-lock.json') }}-
            next-cache-

Kalau key cocok persis, cache tidak akan pernah disimpan lagi, jadi key-nya membawa SHA dan selalu baru di setiap run. restore-keys berupa prefix: cache terbaru untuk lockfile ini yang dipakai, dan cache apa pun tetap lebih baik daripada mulai dari nol.

.github/workflows/deploy.yml:136-150
      # Best-effort, because it is an optimisation and the image is already
      # pushed by this point. It is meant to reuse every layer from the build
      # above, but on 2026-10-03 BuildKit had dropped them: this step re-ran
      # npm ci and the whole next build, then died with "EOF" exporting the
      # 572 MB cache. That failed the job, and `needs: build-and-push` then
      # skipped the deploy of an image that had built and pushed cleanly.
      - name: Extract Next.js build cache
        id: extract-next-cache
        continue-on-error: true
        uses: docker/build-push-action@v6
        with:
          context: .
          target: cache-export
          outputs: type=local,dest=/tmp/next-cache
          cache-from: type=gha

Separuh perjalanan pulangnya: build hanya stage cache-export lalu tulis hasilnya ke folder lokal. Komentarnya adalah insiden yang membuat step ini best-effort. Step berikutnya memeriksa steps.extract-next-cache.outcome, yaitu hasil sebelum continue-on-error diterapkan, dan tidak menyimpan cache kalau step ini gagal.

.github/workflows/deploy.yml:224-236
            # Write runtime env from GitHub Secrets/Variables. Quoted heredoc so
            # shell does not expand anything in the (already-rendered) values.
            cat > .env <<'ENVEOF'
            GROQ_API_KEY=${{ secrets.GROQ_API_KEY }}
            RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
            CONTACT_FROM_EMAIL=${{ vars.CONTACT_FROM_EMAIL }}
            CONTACT_TO_EMAIL=${{ vars.CONTACT_TO_EMAIL }}
            IMAGE=${{ needs.build-and-push.outputs.image }}
            APP_PORT=${{ vars.APP_PORT || 3002 }}
            ENVEOF
            chmod 600 .env

            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

Satu-satunya tempat secret aplikasi muncul. GitHub me-render ekspresinya sebelum script berjalan, dan 'ENVEOF' yang diberi tanda kutip mencegah shell mengekspansi tanda $ di dalam nilai. IMAGE adalah referensi bertag SHA dari build-and-push. Token dikirim ke docker login lewat pipe, tidak pernah sebagai argumen.

Yang sering rusak

Kesalahan yang benar-benar membuang waktu

  • Satu concurrency group untuk seluruh workflow

    Dengan cancel-in-progress: false, serentetan commit mengantrekan pipeline lengkap yang mem-build kode usang. Dengan true, push yang lebih baru bisa mematikan deploy di tengah script SSH-nya. Pisahkan group untuk build dan deploy.

  • Optimasi yang justru menahan deploy

    Pada 2026-10-03, export cache mati dengan EOF saat mengekspor cache 572 MB. Job-nya gagal, dan needs melewati deploy image yang sebenarnya sudah di-push. Tandai step yang sifatnya best-effort dengan continue-on-error: true, lalu periksa outcome-nya di tempat yang penting.

  • Runner kehabisan disk

    Pada 2026-10-07, deploy gagal dengan pesan no space left on device di tengah build image. Sekarang job menghapus toolchain yang tidak pernah dipakai sebelum mem-build, lalu mencetak df sebelum dan sesudahnya, jadi sisa ruang disk terlihat di log.

  • Yang akan saya ubah: deploy tidak menunggu health check

    docker compose up -d selesai begitu container menyala, jadi rilis yang kemudian gagal health check tetap meninggalkan run berwarna hijau. up -d --wait menunggu sampai service running atau healthy, dan keluar dengan kode bukan nol kalau tidak. Di uji lokal saya, container yang unhealthy tetap melayani request, jadi --wait hanya memberi tahu ada rilis buruk; rollback tetap tugas Anda.

  • Yang akan saya ubah: action SSH dipin ke tag dan tanpa host key

    appleboy/scp-action@v0.1.7 dan appleboy/ssh-action@v1.0.3 dipin ke tag. Panduan secure use dari GitHub menyarankan SHA commit lengkap, karena tag bisa dipindahkan atau dihapus. Kedua step juga tidak mengisi input fingerprint, yang menurut README action itu membantu mencegah serangan man-in-the-middle.

Build dan deploy: dua concurrency group
Groupbuild-${{ github.ref }}deploy-vps
cancel-in-progresstruefalse
Ada push yang lebih baruBuild yang sedang berjalan dibatalkanMenunggu; deploy yang berjalan selalu selesai
AlasannyaPekerjaan usang hanya buang waktuDeploy setengah jadi bisa meninggalkan server di antara dua image
Coba sendiri

Lakukan di komputer Anda sendiri

Latih seluruh pipeline di laptop Anda memakai folder devops-lab dari pelajaran Docker: gerbang, registry, deploy berdasarkan tag, dan perbedaan yang dibuat --wait. Anda hanya butuh Docker dan Node.

  1. Jalankan gerbangnya seperti needs bekerja: setiap perintah baru jalan kalau perintah sebelumnya berhasil. Rantai kedua gagal di pemeriksaan sintaks, jadi docker build tidak pernah dijalankan.

    npm ci && node --check src/server.js && docker build -t devops-lab:v1 .
    echo "if (" > src/broken.js
    npm ci && node --check src/broken.js && docker build -t devops-lab:v2 .
    rm src/broken.js
  2. Jalankan registry sementara di port 5001 lalu push image dengan dua tag, seperti yang dilakukan build-and-push. Timestamp dipakai sebagai pengganti SHA commit.

    docker run -d -p 5001:5000 --name registry registry:3
    TAG=$(date +%s)
    docker tag devops-lab:v1 localhost:5001/devops-lab:latest
    docker tag devops-lab:v1 localhost:5001/devops-lab:$TAG
    docker push localhost:5001/devops-lab:latest
    docker push localhost:5001/devops-lab:$TAG
  3. Deploy berdasarkan tag yang persis, seperti yang dilakukan server: tulis .env, pull, lalu jalankan dengan --wait agar perintahnya baru selesai setelah health check lolos.

    # compose.yaml
    services:
      web:
        image: ${IMAGE}
        env_file: .env
        ports:
          - "127.0.0.1:8080:3000"
        healthcheck:
          test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
          interval: 5s
          timeout: 3s
          retries: 3
    
    # then, in the same folder
    printf 'IMAGE=localhost:5001/devops-lab:%s\n' "$TAG" > .env
    chmod 600 .env
    docker compose pull
    docker compose up -d --wait
  4. Rusak health check-nya: ubah :3000/ di URL test menjadi :3999/. up -d tetap keluar dengan 0, sedangkan up -d --wait keluar dengan 1 setelah sekitar 15 detik, dan container yang unhealthy tetap menjawab. Bersihkan semuanya di akhir.

    docker compose up -d; echo "exit $?"
    docker compose down
    docker compose up -d --wait; echo "exit $?"
    curl -s http://127.0.0.1:8080/
    docker compose down && docker rm -f registry
Kuis singkat

Tiga pertanyaan sebelum lanjut

Pilih jawaban untuk melihat kenapa benar atau salah. Tidak ada yang disimpan atau dinilai, jadi tebak saja dengan bebas.

  1. 1.Supaya build usang tidak menumpuk, Anda memasukkan seluruh workflow ke satu concurrency group dengan cancel-in-progress: true. Risiko baru apa yang Anda tambahkan?
  2. 2.Step cache-export berjalan di dalam build-and-push setelah image di-push, tanpa continue-on-error. Step itu gagal. Apa yang terjadi pada deploy?
  3. 3.Nilai GITHUB_TOKEN tidak sengaja tercetak di log sebuah job lama. Bisakah seseorang memakainya minggu depan untuk push image ke ghcr.io?
Pikirkan dulu

Jawab dalam hati, lalu cek

  1. 1.Sebuah commit gagal di npx tsc --noEmit. Apa yang terjadi pada build-and-push dan deploy, dan apa yang sedang dilayani VPS?Lihat jawabannya

    Jawaban:Keduanya dilewati, karena job yang membutuhkan job yang gagal atau dilewati ikut dilewati. VPS tetap melayani image terakhir yang lolos, karena tidak ada yang baru sampai ke sana.

  2. 2.Kenapa build-and-push memakai cancel-in-progress: true, sedangkan deploy memakai false?Lihat jawabannya

    Jawaban:Build yang sudah usang hanya membuang waktu, jadi membatalkannya tidak merugikan apa-apa. Deploy yang dibatalkan di tengah SSH bisa meninggalkan server di antara dua image, jadi deploy menunggu giliran.

  3. 3.Kenapa server menarik tag SHA, bukan latest?Lihat jawabannya

    Jawaban:Tag SHA menunjuk tepat satu build: build yang baru saja lolos verify. latest berpindah di setiap push, jadi tag itu tidak bisa memberi tahu build mana yang sedang berjalan atau mengembalikan build lama.

  4. 4.Job deploy berwarna hijau. Apakah itu membuktikan container baru sehat?Lihat jawabannya

    Jawaban:Tidak. docker compose up -d selesai begitu container menyala, sebelum health check-nya lolos. Hanya --wait yang membuat perintah itu, dan job-nya, gagal kalau service tidak sehat.

Pelajari lebih dalam

Bahasan mendalam terkait di situs ini