Lompat ke konten
·25 menit membaca

GitHub Actions CI/CD: Zero-Downtime Deployment yang Benar-Benar Bekerja

Setup GitHub Actions lengkap saya: parallel test jobs, Docker build caching, SSH deployment ke VPS, zero-downtime dengan PM2 reload, manajemen secrets, dan pola workflow yang sudah saya sempurnakan selama dua tahun.

Bagikan:X / TwitterLinkedIn

Setiap proyek yang pernah saya kerjakan pada akhirnya mencapai titik infleksi yang sama: proses deploy menjadi terlalu menyakitkan untuk dilakukan secara manual. Kamu lupa menjalankan test. Kamu build secara lokal tapi lupa menaikkan versi. Kamu SSH ke production dan menyadari orang terakhir yang melakukan deploy meninggalkan file .env yang sudah basi.

GitHub Actions menyelesaikan masalah ini dua tahun lalu buat saya. Tidak sempurna di hari pertama — workflow pertama yang saya tulis adalah mimpi buruk YAML 200 baris yang timeout setengah dari waktunya dan tidak meng-cache apa pun. Tapi iterasi demi iterasi, saya sampai pada sesuatu yang men-deploy situs ini dengan andal, tanpa downtime, dalam waktu kurang dari empat menit.

Ini adalah workflow tersebut, dijelaskan bagian per bagian. Bukan versi dokumentasi. Versi yang bertahan saat berhadapan dengan production.

Memahami Blok-Blok Dasar#

Sebelum kita masuk ke pipeline lengkap, kamu perlu memahami model mental tentang bagaimana GitHub Actions bekerja. Jika kamu pernah menggunakan Jenkins atau CircleCI, lupakan sebagian besar yang kamu tahu. Konsepnya saling berkaitan secara longgar, tapi model eksekusinya cukup berbeda untuk membuatmu tersandung.

Trigger: Kapan Workflow Berjalan#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Setiap Senin jam 6 pagi UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Empat trigger ini mencakup 95% kasus penggunaan:

  • push: Berjalan saat kode di-push ke branch. Ini adalah trigger CI/CD utamamu.
  • pull_request: Berjalan saat PR dibuka, diperbarui, atau dibuka kembali. Gunakan ini untuk quality gate.
  • schedule: Cron job. Bagus untuk nightly build, pembersihan dependency, atau pemindaian keamanan.
  • workflow_dispatch: Trigger manual dengan input. Deploy darurat, one-off task.

Satu hal yang tidak jelas dari dokumentasi: trigger pull_request berjalan terhadap merge commit PR, bukan branch commit. Ini berarti CI kamu menguji apa yang akan terjadi saat di-merge, bukan apa yang ada di branch saat ini. Biasanya ini yang kamu inginkan, tapi bisa membingungkan saat debugging.

Job dan Step: Hubungannya#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

Job berjalan secara paralel secara default. lint dan test di sini dimulai bersamaan di runner yang berbeda. Setiap job mendapatkan VM baru — tidak ada yang dibagikan di antara keduanya. Tidak ada filesystem bersama, tidak ada proses bersama, tidak ada state bersama.

Step dalam sebuah job berjalan secara berurutan di VM yang sama. Step pertama yang gagal menghentikan job (kecuali kamu menggunakan continue-on-error: true atau if: always()).

Dependensi Antar Job#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]
 
  test:
    runs-on: ubuntu-latest
    steps: [...]
 
  build:
    needs: [lint, test]  # Tunggu keduanya selesai
    runs-on: ubuntu-latest
    steps: [...]
 
  deploy:
    needs: [build]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps: [...]

Field needs membuat dependency graph. build menunggu lint dan test selesai. deploy menunggu build. Jika lint atau test gagal, build dan deploy dilewati.

Kondisi if pada deploy memastikan deployment hanya terjadi pada branch main. Push ke PR tetap menjalankan lint, test, dan build — tapi tidak deploy.

Concurrency: Mencegah Deploy yang Bertabrakan#

yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Ini penting. Tanpa kontrol concurrency, push cepat berturut-turut ke main bisa menjalankan dua deployment secara bersamaan. Paling baiknya itu sia-sia, paling buruknya itu berbahaya.

Setting group membuat lock key. Workflow yang berjalan pada branch yang sama bersaing untuk group yang sama. cancel-in-progress: true berarti push baru membatalkan run yang lama — yang kamu inginkan untuk CI pada PR (tidak perlu menyelesaikan CI untuk commit yang sudah di-force-push).

Untuk deploy production, kamu mungkin ingin cancel-in-progress: false — biarkan deploy yang sedang berjalan selesai daripada membatalkannya di tengah jalan.

Matrix Strategy#

Matrix bagus untuk menguji kombinasi, tapi kebanyakan proyek tidak membutuhkannya. Jika kamu hanya menargetkan Node 22, jangan buat matrix — ini hanya membuat workflowmu lebih lambat.

Tapi untuk test matrix (katakanlah, menguji di Node 20 dan Node 22), kamu mungkin ingin melihat semua kegagalan sekaligus:

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: false membiarkan kedua leg matrix selesai. Jika Node 22 gagal tapi Node 20 berhasil, kamu melihat informasi itu segera daripada harus menjalankan ulang.

Caching untuk Kecepatan#

Peningkatan terbesar yang bisa kamu lakukan untuk kecepatan CI adalah caching. Cold pnpm install pada proyek menengah memakan waktu 30-45 detik. Dengan cache yang sudah terisi, hanya 3-5 detik. Kalikan itu dengan empat job paralel dan kamu menghemat dua menit di setiap run.

Cache Store pnpm#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

One-liner ini meng-cache store pnpm (~/.local/share/pnpm/store). Saat cache hit, pnpm install --frozen-lockfile hanya membuat hard-link dari store alih-alih mengunduh. Ini saja memotong waktu install sebesar 80% pada run berikutnya.

Jika kamu butuh kontrol lebih — misalnya, kamu ingin cache berdasarkan OS juga — gunakan actions/cache langsung:

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

restore-keys adalah mekanisme fallback. Jika key yang tepat tidak cocok (karena lockfile berubah), ia menemukan cache terbaru yang cocok dengan prefix. Kamu mendapatkan partial hit — sebagian besar package sudah di-cache, dan hanya yang baru yang perlu diunduh.

Cache Build Next.js#

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

Cache build Next.js menyimpan output kompilasi webpack/turbopack. Pada cache hit, hanya file yang berubah yang di-rebuild. Ini biasanya memotong waktu build dari 60 detik menjadi 15 detik.

Perhatikan strategi key yang berlapis: hash yang tepat terlebih dahulu, lalu fallback hanya dengan hash lockfile, lalu fallback hanya berdasarkan OS. Setiap level fallback kurang optimal tapi lebih baik daripada cold start.

Cache Docker Layer#

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha menggunakan backend cache GitHub Actions. Docker layer di-cache di antara run tanpa registry terpisah. mode=max meng-cache semua layer, tidak hanya layer akhir — ini berarti bahkan layer perantara yang berubah (seperti COPY package.json) mendapatkan cache hit.

Untuk proyek monorepo atau multi-stage build, ini mengubah waktu build Docker dari 3 menit menjadi 30 detik saat hanya kode aplikasi yang berubah.

Build Docker Image#

Jika kamu men-deploy dengan Docker, kamu perlu membangun dan push image. Inilah setup yang saya gunakan:

yaml
docker:
  name: Build Docker Image
  needs: [build]
  if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
  runs-on: ubuntu-latest
  permissions:
    contents: read
    packages: write
 
  steps:
    - uses: actions/checkout@v4
 
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3
 
    - name: Log in to GitHub Container Registry
      uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
 
    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: ghcr.io/${{ github.repository }}
        tags: |
          type=sha,prefix=
          type=raw,value=latest,enable={{is_default_branch}}
 
    - name: Build and push
      uses: docker/build-push-action@v6
      with:
        context: .
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

Poin-poin penting:

permissions: Blok ini memberikan token GITHUB_TOKEN kemampuan untuk push ke GitHub Container Registry. Tanpa packages: write, push gagal dengan 403.

Dua tag: type=sha memberi kamu tag immutable (abc1234). type=raw,value=latest memberi kamu tag floating yang selalu menunjuk ke build terbaru. Gunakan SHA untuk rollback, latest untuk deploy.

Buildx: docker/setup-buildx-action mengaktifkan BuildKit, yang diperlukan untuk caching lanjutan dan build multi-platform. Tanpa BuildKit, kamu menggunakan builder legacy Docker, yang lebih lambat dan tidak mendukung cache-from/cache-to.

Build Multi-Platform#

Jika kamu men-deploy ke arsitektur yang berbeda (AMD64 untuk VPS, ARM64 untuk server Graviton AWS):

yaml
- name: Set up QEMU
  uses: docker/setup-qemu-action@v3
 
- name: Build and push (multi-platform)
  uses: docker/build-push-action@v6
  with:
    context: .
    platforms: linux/amd64,linux/arm64
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

QEMU meng-emulasi arsitektur lain. Build ARM64 di runner AMD64 sekitar 2-3x lebih lambat dari native, tapi kamu hanya menanggung biaya ini di CI, bukan saat deploy.

SSH Deployment ke VPS#

Tidak semua orang menjalankan Kubernetes. Sebagian besar proyek saya berjalan di VPS tunggal — satu server, Nginx di depan, aplikasi di belakang. SSH deployment sederhana dan langsung.

Setup Dasar#

yaml
- name: Deploy via SSH
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    port: ${{ secrets.SSH_PORT }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

Ini bekerja, tapi ada beberapa hal yang perlu kamu ketahui:

script_stop: true: Menambahkan set -e ke skrip, jadi step apa pun yang gagal menghentikan eksekusi. Tanpa ini, deploy bisa "berhasil" meskipun build gagal — pm2 reload akan menjalankan kode lama.

command_timeout: Default-nya 10 menit. Jika build kamu memakan waktu lama, ini bisa timeout. Setel ke sesuatu yang masuk akal:

yaml
- uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script_stop: true
    command_timeout: 5m
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

Skrip Deploy vs Inline Script#

Untuk deploy yang non-trivial, taruh logikanya dalam skrip di server daripada inline di YAML:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
cd "$APP_DIR"
 
echo "=== Deploy dimulai pada $(date) ==="
 
git pull origin main
pnpm install --frozen-lockfile
pnpm build
 
# Verifikasi build berhasil sebelum reload
if [ ! -d ".next" ]; then
  echo "ERROR: Direktori build .next tidak ditemukan"
  exit 1
fi
 
pm2 reload ecosystem.config.js --update-env
 
echo "=== Deploy selesai pada $(date) ==="

Workflow kemudian menjadi satu perintah SSH:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

Ini lebih baik karena: (1) logika deploy tersimpan dalam version control di server, (2) kamu bisa menjalankannya secara manual via SSH untuk debugging, dan (3) kamu tidak perlu meng-escape YAML di dalam YAML di dalam bash.

Strategi Zero-Downtime#

"Zero downtime" terdengar seperti bahasa marketing, tapi punya arti yang presisi: tidak ada request yang mendapat connection refused atau 502 selama deployment. Berikut tiga pendekatan nyata, dari yang paling sederhana hingga paling robust.

Strategi 1: PM2 Cluster Mode Reload#

Jika kamu menjalankan Node.js secara langsung (bukan di Docker), cluster mode PM2 memberi jalur zero-downtime termudah.

bash
# ecosystem.config.js sudah memiliki:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (bukan restart) melakukan rolling restart. Ia memulai worker baru, menunggu mereka siap, lalu mematikan worker lama satu per satu. Tidak pernah ada nol worker yang melayani traffic.

Flag --update-env memuat ulang environment variable dari ecosystem config. Tanpanya, env lama tetap bertahan meskipun setelah deploy yang mengubah .env.

Dalam workflow-mu:

yaml
- name: Deploy dan reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

Ini yang saya gunakan untuk situs ini. Sederhana, andal, dan downtime-nya benar-benar nol — saya sudah mengujinya dengan load generator yang menjalankan 100 req/s selama deploy. Tidak ada satupun 5xx.

Strategi 2: Blue/Green dengan Nginx Upstream#

Untuk deployment Docker, blue/green memberi pemisahan yang bersih antara versi lama dan baru.

Konsepnya: jalankan container lama ("blue") di port 3000 dan container baru ("green") di port 3001. Nginx mengarah ke blue. Kamu mulai green, verifikasi ia healthy, alihkan Nginx ke green, lalu hentikan blue.

Konfigurasi upstream Nginx:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Skrip peralihan:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Saat ini: $OLD_PORT -> Baru: $NEW_PORT"
 
# Mulai container baru di port alternatif
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# Tunggu health
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "Container baru healthy di port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check gagal"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Alihkan Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Hentikan container lama
sleep 5  # Biarkan request yang masih berjalan selesai
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Beralih dari :$OLD_PORT ke :$NEW_PORT"

Sleep 5 detik setelah Nginx reload bukan kemalasan — itu grace time. Reload Nginx bersifat graceful (koneksi yang ada tetap terbuka), tapi beberapa koneksi long-polling atau streaming response perlu waktu untuk selesai.

Strategi 3: Docker Compose dengan Health Check#

Untuk pendekatan yang lebih terstruktur, Docker Compose bisa mengelola pertukaran blue/green:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

Baris order: start-first adalah kuncinya. Artinya "mulai container baru sebelum menghentikan yang lama." Dikombinasikan dengan parallelism: 1, kamu mendapatkan rolling update — satu container pada satu waktu, selalu mempertahankan kapasitas.

Deploy dengan:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose mengawasi healthcheck dan tidak akan merutekan traffic ke container baru sampai lolos. Jika healthcheck gagal, failure_action: rollback otomatis kembali ke versi sebelumnya. Ini sedekat mungkin dengan rolling deployment gaya Kubernetes yang bisa kamu dapatkan di satu VPS.

Manajemen Secrets#

Manajemen secrets adalah salah satu hal yang mudah untuk "hampir benar" dan gagal secara katastrofis di kasus edge yang tersisa.

GitHub Secrets: Dasar-Dasarnya#

yaml
# Atur via GitHub UI: Settings > Secrets and variables > Actions
 
steps:
  - name: Gunakan secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # Nilainya disamarkan di log
      echo "Menghubungkan ke database..."
      # Ini akan mencetak "Menghubungkan ke ***" di log
      echo "Menghubungkan ke $DB_URL"

GitHub otomatis menyunting nilai secret dari output log. Jika secret-mu adalah p@ssw0rd123 dan step apa pun mencetak string itu, log menunjukkan ***. Ini bekerja dengan baik, dengan satu catatan: jika secret-mu pendek (seperti PIN 4 digit), GitHub mungkin tidak menyamarkannya karena bisa cocok dengan string biasa. Jaga agar secret cukup kompleks.

Secret Berlingkup Environment#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying ke ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying ke ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

Nama secret yang sama, nilai berbeda per environment. Field environment pada job menentukan set secret mana yang disuntikkan.

Environment production harus mengaktifkan required reviewers. Ini berarti push ke main men-trigger workflow, CI berjalan otomatis, tapi job deploy terhenti dan menunggu seseorang mengklik "Approve" di GitHub UI. Untuk proyek solo, ini mungkin terasa berlebihan. Untuk apa pun yang memiliki user, ini penyelamat saat pertama kali kamu tidak sengaja merge sesuatu yang rusak.

OIDC: Tidak Perlu Lagi Credential Statis#

Credential statis (AWS access key, file JSON service account GCP) yang disimpan di GitHub Secrets adalah liability. Mereka tidak kedaluwarsa, tidak bisa dibatasi ke workflow run tertentu, dan jika bocor, kamu harus merotasinya secara manual.

OIDC (OpenID Connect) menyelesaikan ini. GitHub Actions bertindak sebagai identity provider, dan cloud provider-mu mempercayainya untuk mengeluarkan credential sementara secara langsung:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Diperlukan untuk OIDC
      contents: read
 
    steps:
      - name: Konfigurasi AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push ke ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

Tidak ada access key. Tidak ada secret key. Action configure-aws-credentials meminta token sementara dari AWS STS menggunakan token OIDC GitHub. Token tersebut terbatas pada repo, branch, dan environment tertentu. Kedaluwarsa setelah workflow run.

Mengatur ini di sisi AWS memerlukan IAM OIDC identity provider dan role trust policy:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

Kondisi sub sangat penting. Tanpanya, repo mana pun yang entah bagaimana mendapatkan detail OIDC provider-mu bisa mengambil alih role tersebut. Dengan kondisi ini, hanya branch main dari repo spesifikmu yang bisa.

GCP memiliki setup setara dengan Workload Identity Federation. Azure memiliki federated credentials. Jika cloud-mu mendukung OIDC, gunakanlah. Tidak ada alasan untuk menyimpan credential cloud statis di tahun 2026.

Kunci SSH untuk Deployment#

Untuk deployment VPS via SSH, buat key pair khusus:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

Tambahkan public key ke ~/.ssh/authorized_keys server dengan pembatasan:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

Prefix restrict menonaktifkan port forwarding, agent forwarding, alokasi PTY, dan X11 forwarding. Prefix command= berarti kunci ini hanya bisa mengeksekusi skrip deploy. Bahkan jika private key terkompromi, penyerang hanya bisa menjalankan skrip deploy-mu dan tidak ada yang lain.

Tambahkan private key ke GitHub Secrets sebagai SSH_PRIVATE_KEY. Ini adalah satu-satunya credential statis yang saya terima — kunci SSH dengan forced command memiliki blast radius yang sangat terbatas.

Workflow PR: Preview Deployment#

Setiap PR layak mendapat preview environment. Ini menangkap bug visual yang terlewat oleh unit test, memungkinkan desainer mereview tanpa checkout kode, dan membuat kehidupan QA jauh lebih mudah.

Deploy Preview saat PR Dibuka#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Komentari PR dengan URL preview
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Terakhir diperbarui: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // Cari komentar yang sudah ada
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

Perhitungan port (4000 + PR_NUM) adalah hack pragmatis. PR #42 mendapat port 4042. Selama kamu tidak memiliki lebih dari beberapa ratus PR terbuka, tidak akan ada tabrakan. Konfigurasi wildcard Nginx merutekan pr-*.preview.akousa.net ke port yang tepat.

Cleanup saat PR Ditutup#

Preview environment yang tidak dibersihkan memakan disk dan memori. Tambahkan job cleanup:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Hapus container preview
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview untuk PR #${PR_NUM} sudah dibersihkan."
 
      - name: Nonaktifkan environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

Status Check yang Wajib#

Di pengaturan repository-mu (Settings > Branches > Branch protection rules), wajibkan check ini sebelum merge:

  • lint — Tidak ada error lint
  • typecheck — Tidak ada error type
  • test — Semua test lulus
  • build — Proyek berhasil di-build

Tanpa ini, seseorang akan merge PR dengan check yang gagal. Bukan dengan sengaja — mereka akan melihat "2 dari 4 check lulus" dan menganggap dua lainnya masih berjalan. Kunci aksesnya.

Aktifkan juga "Require branches to be up to date before merging." Ini memaksa CI berjalan ulang setelah rebase ke main terbaru. Ini menangkap kasus di mana dua PR secara individual lolos CI tapi konflik saat digabungkan.

Notifikasi#

Deployment yang tidak diketahui siapa pun adalah deployment yang tidak dipercaya siapa pun. Notifikasi menutup feedback loop.

Slack Webhook#

yaml
- name: Notifikasi Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Berhasil' || 'Deploy Gagal' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Dipicu oleh:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "Lihat Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

if: always() sangat penting. Tanpanya, step notifikasi dilewati saat deploy gagal — yang justru saat kamu paling membutuhkannya.

GitHub Deployments API#

Untuk pelacakan deployment yang lebih kaya, gunakan GitHub Deployments API. Ini memberi riwayat deployment di UI repo dan mengaktifkan status badge:

yaml
- name: Buat GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Men-deploy ${context.sha.substring(0, 7)} ke production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... langkah deployment sebenarnya ...
 
- name: Perbarui status deployment
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment berhasil'
          : 'Deployment gagal',
      });

Sekarang tab Environments di GitHub menampilkan riwayat deployment lengkap: siapa yang men-deploy apa, kapan, dan apakah berhasil.

Email Hanya saat Gagal#

Untuk deployment kritis, saya juga men-trigger email saat gagal. Bukan melalui email bawaan GitHub Actions (terlalu berisik), tapi melalui webhook yang ditargetkan:

yaml
- name: Peringatan saat gagal
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY GAGAL: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nAktor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

Ini adalah pertahanan terakhir saya. Slack bagus tapi juga berisik — orang-orang mematikan notifikasi channel. Email "DEPLOY GAGAL" dengan tautan ke run mendapat perhatian.

File Workflow Lengkap#

Berikut semuanya terangkai dalam satu workflow siap production. Ini sangat dekat dengan apa yang sebenarnya men-deploy situs ini.

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Lewati test (deploy darurat)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI: Lint, type check, dan test secara paralel
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Jalankan ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Jalankan kompiler TypeScript
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Jalankan test dengan coverage
        run: pnpm test -- --coverage
 
      - name: Upload laporan coverage
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # Build: Hanya setelah CI lolos
  # ============================================================
 
  build:
    name: Build Aplikasi
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache build Next.js
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build aplikasi Next.js
        run: pnpm build
 
  # ============================================================
  # Docker: Build dan push image (hanya branch main)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
 
      - name: Setup QEMU untuk build multi-platform
        uses: docker/setup-qemu-action@v3
 
      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Login ke GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Ekstrak metadata image
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build dan push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # Deploy: SSH ke VPS dan perbarui
  # ============================================================
 
  deploy:
    name: Deploy ke Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Buat GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA dimulai pada $(date) ==="
 
            # Tarik image baru
            docker pull "$IMAGE"
 
            # Jalankan container baru di port alternatif
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Health check
            echo "Menjalankan health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check lolos (percobaan $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check gagal"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Alihkan traffic
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # Grace period untuk request yang masih berjalan
            sleep 5
 
            # Hentikan container lama
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Rename dan reset port
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # Catatan: kita tidak reload Nginx di sini karena yang berubah adalah
            # nama container, bukan port. Deploy berikutnya akan menggunakan port yang benar.
 
            # Catat deployment
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Bersihkan image lama (lebih dari 7 hari)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy selesai pada $(date) ==="
 
      - name: Perbarui status deployment
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notifikasi Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Berhasil' || 'Deploy Gagal' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Aktor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "Lihat Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Peringatan saat gagal
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY GAGAL: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

Alur Lengkap Dijelaskan#

Saat saya push ke main:

  1. Lint, Type Check, dan Test dimulai secara bersamaan. Tiga runner, tiga job paralel. Jika salah satu gagal, pipeline berhenti.
  2. Build berjalan hanya jika ketiganya lolos. Ia memvalidasi bahwa aplikasi bisa dikompilasi dan menghasilkan output yang berfungsi.
  3. Docker membangun image production dan push ke ghcr.io. Multi-platform, layer-cached.
  4. Deploy SSH ke VPS, menarik image baru, memulai container baru, melakukan health-check, mengalihkan Nginx, dan membersihkan.
  5. Notifikasi terkirim terlepas dari hasilnya. Slack mendapat pesan. GitHub Deployments diperbarui. Jika gagal, email peringatan dikirim.

Saat saya membuka PR:

  1. Lint, Type Check, dan Test berjalan. Quality gate yang sama.
  2. Build berjalan untuk memverifikasi proyek bisa dikompilasi.
  3. Docker dan Deploy dilewati (kondisi if membatasinya hanya untuk branch main).

Saat saya butuh deploy darurat (lewati test):

  1. Klik "Run workflow" di tab Actions.
  2. Pilih skip_tests: true.
  3. Lint dan typecheck tetap berjalan (kamu tidak bisa melewatinya — saya tidak mempercayai diri sendiri sampai segitu).
  4. Test dilewati, build berjalan, Docker build, deploy dijalankan.

Ini sudah menjadi workflow saya selama dua tahun. Ia bertahan melalui migrasi server, upgrade major version Node.js, pnpm menggantikan npm, dan penambahan 15 tool ke situs ini. Total waktu end-to-end dari push ke production: 3 menit 40 detik rata-rata. Step paling lambat adalah build Docker multi-platform sekitar ~90 detik. Sisanya di-cache hingga hampir instan.

Pelajaran dari Dua Tahun Iterasi#

Saya akan menutup dengan kesalahan yang saya buat agar kamu tidak perlu mengulanginya.

Pin versi action-mu. uses: actions/checkout@v4 tidak apa-apa, tapi untuk production, pertimbangkan uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (SHA lengkap). Action yang dikompromi bisa mengekstrak secret-mu. Insiden tj-actions/changed-files di 2025 membuktikan ini bukan teoretis.

Jangan cache semuanya. Saya pernah meng-cache node_modules secara langsung (bukan hanya store pnpm) dan menghabiskan dua jam men-debug kegagalan build misterius yang disebabkan oleh native binding yang basi. Cache store package manager, bukan modul yang terinstall.

Setel timeout. Setiap job harus memiliki timeout-minutes. Default-nya adalah 360 menit (6 jam). Jika deploy-mu hang karena koneksi SSH terputus, kamu tidak ingin mengetahuinya enam jam kemudian saat sudah menghabiskan kuota menit bulananmu.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

Gunakan concurrency dengan bijak. Untuk PR, cancel-in-progress: true selalu benar — tidak ada yang peduli dengan hasil CI dari commit yang sudah di-force-push. Untuk deploy production, setel ke false. Kamu tidak ingin commit berikutnya membatalkan deploy yang sedang di tengah jalan.

Uji file workflow-mu. Gunakan act (https://github.com/nektos/act) untuk menjalankan workflow secara lokal. Ia tidak akan menangkap semuanya (secret tidak tersedia, dan lingkungan runner berbeda), tapi ia menangkap error sintaks YAML dan bug logika yang jelas sebelum kamu push.

Monitor biaya CI-mu. Menit GitHub Actions gratis untuk repo publik dan murah untuk yang privat, tapi bisa menumpuk. Build Docker multi-platform memakan 2x menit (satu per platform). Strategi test matrix mengalikan runtime-mu. Pantau halaman billing.

Pipeline CI/CD terbaik adalah yang kamu percaya. Kepercayaan datang dari keandalan, observabilitas, dan peningkatan inkremental. Mulailah dengan pipeline lint-test-build yang sederhana. Tambahkan Docker saat kamu butuh reprodusibilitas. Tambahkan deployment SSH saat kamu butuh otomasi. Tambahkan notifikasi saat kamu butuh kepercayaan diri. Jangan bangun pipeline lengkap di hari pertama — kamu akan salah abstraksinya.

Bangun pipeline yang kamu butuhkan hari ini, dan biarkan ia tumbuh bersama proyekmu.

Artikel Terkait