karawaci.kode

2026-06-30 · 6 min

GitHub Actions Deploy Pipeline ke Cloudflare: Setup 90 Detik

Tiga bulan lalu, deploy SaaS klien Tangerang masih 6-8 menit dari git push sampai live di Cloudflare Pages. Saya tahu bisa lebih cepat, tapi rewrite pipeline berarti potensi outage. Setelah satu weekend rewrite + 2 minggu monitoring, pipeline saya sekarang: 87 detik p50, 134 detik p95, zero downtime selama 12 minggu.

Share setup dan failure mode yang harus diantisipasi.

Konteks app

  • Stack: Astro 6 (static front), Cloudflare Workers (API), Postgres (Hetzner).
  • Deploy target: Cloudflare Pages (front) + Workers (API) + D1 migration runner.
  • Repo size: 22k LOC, 380 file.
  • Test suite: 247 test (unit + integration), runtime 2 menit 40 detik.
  • Deploy cadence: 4-7 deploy/hari ke production.

Pipeline lama:

  • Total: 6m 40s p50
  • Install deps: 1m 50s
  • Lint + typecheck: 1m 10s
  • Test: 2m 40s
  • Build: 35s
  • Deploy: 25s

Saya tidak terima 6 menit. Tim mulai batch-deploy (kumpulkan 3-4 PR, deploy sekali) yang violate continuous deployment principle.

Strategi optimasi

Tiga area:

  1. Cache dependency install agresif
  2. Parallel test sharding
  3. Conditional build per changed path

1. Dependency cache

Sebelumnya saya pakai actions/setup-node@v4 default cache. Itu cache node_modules cache tapi tidak full restore.

Setelah migrate ke Bun (lihat Bun vs Node Astro install):

- uses: oven-sh/setup-bun@v2
  with:
    bun-version: 1.3.12

- name: Cache Bun deps
  uses: actions/cache@v4
  with:
    path: |
      ~/.bun/install/cache
      node_modules
    key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }}
    restore-keys: |
      bun-${{ runner.os }}-

- run: bun install --frozen-lockfile

Cold cache: 38 detik (vs 1m 50s sebelumnya). Warm cache: 4 detik (vs 22 detik sebelumnya).

2. Test sharding

247 test serial = 2m 40s. Pakai matrix shard:

jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bun test --shard ${{ matrix.shard }}/4

4 shard parallel: 42 detik total (limited by slowest shard). Speedup 3.8x.

Catatan: GitHub Actions free tier kasih 20 concurrent job. Untuk tim Pro plan (kami subscribe $4/user/mo), unlimited concurrency dalam limit minute. Total cost CI: ~$60/bulan untuk tim 6.

3. Conditional build

Front-end (Astro) dan API (Workers) di monorepo sama. Tidak perlu build keduanya kalau cuma satu yang berubah.

- name: Detect changes
  id: changes
  uses: dorny/paths-filter@v3
  with:
    filters: |
      frontend:
        - 'apps/web/**'
        - 'packages/ui/**'
      api:
        - 'apps/api/**'
        - 'packages/db/**'

- name: Build frontend
  if: steps.changes.outputs.frontend == 'true'
  run: bun run build:web

- name: Build api
  if: steps.changes.outputs.api == 'true'
  run: bun run build:api

Average build time saved: 18-22 detik per deploy.

Pipeline lengkap

name: Deploy Production

on:
  push:
    branches: [main]

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: bun-${{ hashFiles('bun.lockb') }}
      - run: bun install --frozen-lockfile
      - run: bun test --shard ${{ matrix.shard }}/4

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bun run lint
      - run: bun run typecheck

  deploy:
    needs: [test, lint]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile

      - uses: dorny/paths-filter@v3
        id: changes
        with:
          filters: |
            frontend: ['apps/web/**']
            api: ['apps/api/**']

      - name: Build & deploy frontend
        if: steps.changes.outputs.frontend == 'true'
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
        run: |
          bun run build:web
          bunx wrangler pages deploy apps/web/dist --project-name=kami-web

      - name: Build & deploy API
        if: steps.changes.outputs.api == 'true'
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
        run: |
          bun run build:api
          bunx wrangler deploy --env production

      - name: Smoke test
        run: |
          sleep 8
          curl -sf https://api.kami.id/health || exit 1
          curl -sf https://app.kami.id/ | grep -q "<title>Kami" || exit 1

      - name: Rollback on failure
        if: failure() && steps.changes.outputs.api == 'true'
        run: bunx wrangler rollback --env production

Timing breakdown

StepTime
Checkout + Bun setup4s
Install (warm cache)4s
Test (shard 4, parallel)42s
Lint + typecheck (parallel)28s
Build frontend14s
Deploy Pages9s
Smoke test12s
Total p5087s

P95 134 detik karena occasional cold cache atau Cloudflare deploy queue delay.

Yang break

1. Concurrent deploy race

Tanpa concurrency block, 2 PR merge cepat bisa trigger 2 deploy parallel. Kalau deploy A masih running saat B mulai, Pages bisa kasih state inconsistent (asset versi B tapi html versi A di edge cache).

Fix: concurrency: { group: deploy-production, cancel-in-progress: false }. Queue serial, no overwrite.

2. Cloudflare API rate limit

Bulan kedua, kami push 12 deploy dalam 2 jam (refactor sprint). API token kena rate limit Cloudflare (1.200 req/5 menit untuk Pages API). Deploy ke-7 gagal dengan 429 Too Many Requests.

Fix: bukan ganti API, tapi consolidate deploy. Batch PR dengan label deploy-after-merge, automation merge batch tiap 30 menit.

3. Smoke test false negative

Smoke test saya nge-test <title> di response. Suatu hari saya rename app dari “Kami” ke “Kami Cloud” di production tag. Smoke test gagal, auto-rollback trigger. Production sebenarnya OK, tapi rollback bikin downtime 90 detik karena DNS cache.

Fix: smoke test jangan check string yang bisa berubah. Sekarang saya cek HTTP status 200 + presence of meta tag name="generator" (lebih stable).

4. Secret rotation

CF_API_TOKEN saya pakai expire 90 hari (Cloudflare policy our team). Bulan ke-3 expired, deploy gagal jam 2 pagi (saat ada hotfix). Tidak ada notification.

Fix: workflow scheduled tiap Senin pagi yang cek token validity:

on:
  schedule:
    - cron: '0 1 * * 1'  # Monday 08:00 WIB

jobs:
  check-token:
    runs-on: ubuntu-latest
    steps:
      - run: |
          response=$(curl -s -o /dev/null -w "%{http_code}" \
            -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
            https://api.cloudflare.com/client/v4/user/tokens/verify)
          if [ "$response" != "200" ]; then
            echo "Token invalid or expired"
            exit 1
          fi

Plus integration ke Slack: kalau gagal, notify channel #ops.

Verdict

90 detik deploy itu titik manis untuk SaaS ukuran SMB. Lebih cepat dari ini butuh trade-off: skip test (no), atau skip smoke test (risky). Konfigurasi di atas balance speed vs safety.

Saya tidak akan rekomendasi pipeline ini untuk app yang lebih dari 50k LOC atau test > 500 — di skala itu, sharding tidak linear scale lagi dan ada overhead orchestration matrix. Untuk SMB Indonesia: cukup. Lihat juga Cloudflare Pages vs Vercel untuk konteks platform.

Ditulis oleh Reza Pradipta