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.
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#
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
- productionEmpat 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#
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 testJob 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#
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#
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueIni 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:
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 testfail-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#
- 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:
- 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#
- 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#
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=maxtype=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:
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=maxPoin-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):
- 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=maxQEMU 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#
- 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-envIni 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:
- 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-envSkrip Deploy vs Inline Script#
Untuk deploy yang non-trivial, taruh logikanya dalam skrip di server daripada inline di YAML:
#!/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:
script: |
cd /var/www/akousa.net && ./deploy.shIni 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.
# ecosystem.config.js sudah memiliki:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 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:
- 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-envIni 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:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /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:
#!/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:
# 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:
docker compose pull
docker compose up -d --remove-orphansDocker 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#
# 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#
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.netNama 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:
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.comTidak 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:
{
"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:
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#
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:
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 linttypecheck— Tidak ada error typetest— Semua test lulusbuild— 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#
- 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:
- 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:
- 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.
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 }}"
}' || trueAlur Lengkap Dijelaskan#
Saat saya push ke main:
- Lint, Type Check, dan Test dimulai secara bersamaan. Tiga runner, tiga job paralel. Jika salah satu gagal, pipeline berhenti.
- Build berjalan hanya jika ketiganya lolos. Ia memvalidasi bahwa aplikasi bisa dikompilasi dan menghasilkan output yang berfungsi.
- Docker membangun image production dan push ke ghcr.io. Multi-platform, layer-cached.
- Deploy SSH ke VPS, menarik image baru, memulai container baru, melakukan health-check, mengalihkan Nginx, dan membersihkan.
- Notifikasi terkirim terlepas dari hasilnya. Slack mendapat pesan. GitHub Deployments diperbarui. Jika gagal, email peringatan dikirim.
Saat saya membuka PR:
- Lint, Type Check, dan Test berjalan. Quality gate yang sama.
- Build berjalan untuk memverifikasi proyek bisa dikompilasi.
- Docker dan Deploy dilewati (kondisi
ifmembatasinya hanya untuk branchmain).
Saat saya butuh deploy darurat (lewati test):
- Klik "Run workflow" di tab Actions.
- Pilih
skip_tests: true. - Lint dan typecheck tetap berjalan (kamu tidak bisa melewatinya — saya tidak mempercayai diri sendiri sampai segitu).
- 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.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestGunakan 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.