Dasar CI/CD untuk Deployment

Dasar CI/CD untuk Deployment

Bitnesia • Aug 28, 2026 91 •EN

Bab 41 menutup dengan sebuah janji: repository Git yang sudah jadi fondasi workflow modern developer akan disambungkan ke server yang baru saja kita bangun lewat pipeline CI/CD. Instance vm-cloud01 sendiri sudah dibersihkan mengikuti kebiasaan jujur soal biaya di Bagian 41.4.4, jadi bab ini menepati janji tersebut di server yang tetap hidup sepanjang series ini, yaitu app01 (192.168.1.40) yang sudah kita kenalkan sebagai managed node Ansible sejak Bagian 36.2.2 dan sudah menjalankan aplikasi demo-node sebagai service systemd sejak Bagian 15.6. Seluruh pola yang dipraktikkan di bab ini, mulai dari pipeline, kunci deploy terbatas, sampai rollback, berlaku identik seandainya target deployment-nya adalah instance cloud publik seperti vm-cloud01, disertai satu tambahan pertimbangan jaringan yang disinggung di Bagian 42.4.2.

Skenario yang mendasari bab ini sangat umum terjadi begitu tim mulai bertambah. Developer sudah bisa menulis kode aplikasi Node.js sejak Bab 15, dan Sysadmin sudah bisa mengotomasi konfigurasi server lewat Ansible sejak Bab 36, tetapi kedua kemampuan ini masih dihubungkan secara manual. Developer selesai coding, mengabari lewat chat bahwa "sudah siap deploy", lalu Sysadmin login ke server, menjalankan script atau playbook, dan berharap tidak ada langkah yang lupa dijalankan. Proses ini rawan tertunda jika Sysadmin sedang sibuk atau tidak online, serta rawan human error karena bergantung pada seseorang yang harus mengetik urutan perintah yang benar setiap kali. CI/CD menutup celah ini dengan menjadikan git push itu sendiri sebagai pemicu otomatis, bukan pesan chat yang menunggu direspons manusia.

Bab ini dimulai dari konsep inti Continuous Integration dan Continuous Deployment, dilanjutkan peran Git sebagai fondasi yang memicu seluruh proses, lalu menyiapkan app01 dengan struktur direktori berbasis release dan akses yang sengaja dibatasi untuk otomasi. Bagian praktik intinya mempraktikkan tiga cara mengirim rilis dari repository ke server, yaitu script manual, GitHub Actions dengan self-hosted runner, dan playbook Ansible yang dipanggil dari pipeline, sebelum ditutup strategi rollback yang memanfaatkan pola symlink yang sama.

42.1 Konsep Continuous Integration/Continuous Deployment

42.1.1 Continuous Integration: Menggabungkan dan Menguji Kode Secara Otomatis

Continuous Integration (CI) adalah praktik menggabungkan perubahan kode dari banyak Developer ke satu branch bersama secara sering, biasanya beberapa kali sehari, dan setiap penggabungan langsung diuji otomatis lewat proses build dan test tanpa menunggu campur tangan manusia. Sebelum CI populer, tim sering menunda proses penggabungan kode sampai mendekati jadwal rilis, sehingga konflik antarperubahan baru ketahuan belakangan dan jauh lebih sulit diurai. CI membalik urutan itu: penggabungan dilakukan sesering mungkin, dan setiap penggabungan langsung diverifikasi lewat pipeline otomatis, sehingga masalah ketahuan dalam hitungan menit, bukan minggu.

Praktisnya, sebuah repository yang menerapkan CI selalu punya definisi "berhasil" yang jelas dan bisa dijalankan mesin, misalnya "semua unit test lulus" atau "proses build tidak menghasilkan error". Bagian 42.2.3 mempraktikkan langsung definisi ini pada aplikasi demo-node.

42.1.2 Continuous Delivery vs Continuous Deployment

Dua istilah ini sering dianggap sama padahal bedanya cukup penting bagi Sysadmin yang bertanggung jawab atas server produksi.

  • Continuous Delivery berarti setiap perubahan yang lulus tahap CI otomatis disiapkan sampai benar-benar siap dirilis, tetapi langkah terakhir untuk benar-benar mendorongnya ke server produksi masih menunggu persetujuan manusia, biasanya lewat satu klik tombol "deploy" atau approval di pull request.
  • Continuous Deployment melangkah lebih jauh: setiap perubahan yang lulus tahap CI langsung dirilis ke produksi tanpa ada gerbang persetujuan manual sama sekali. Kecepatan ini hanya masuk akal jika cakupan test dan mekanisme rollback-nya sudah cukup dipercaya, sesuatu yang baru kita bangun sendiri di Bagian 42.5.

Bab ini mempraktikkan pola Continuous Deployment penuh, sebab lab dengan satu server tunggal dan trafik terbatas adalah tempat paling aman untuk belajar tanpa risiko besar. Di lingkungan produksi dengan trafik nyata, kombinasi Continuous Delivery untuk rilis besar dan Continuous Deployment untuk perbaikan kecil yang sudah teruji baik justru lebih umum ditemui.

42.1.3 Anatomi Pipeline CI/CD dan Posisi Sysadmin di Dalamnya

Sebuah pipeline CI/CD pada dasarnya adalah rangkaian tahap otomatis yang dijalankan berurutan setiap kali ada trigger, umumnya git push. Empat tahap yang paling umum ditemui, meski penamaannya bisa berbeda-beda antar-tool, adalah build (menyiapkan aplikasi agar bisa dijalankan, misalnya npm install), test (menjalankan pengujian otomatis), deploy (mengirim hasilnya ke server target), dan verify (memastikan hasil deploy benar-benar sehat sebelum dianggap selesai).

Developer biasanya paling peduli pada dua tahap pertama, memastikan kode yang ditulis benar secara fungsional. Sysadmin justru paling banyak berperan di dua tahap terakhir: menyiapkan server target agar bisa menerima deployment dengan aman, membatasi akses yang dipakai pipeline seketat mungkin, dan menyiapkan jalan keluar jika deployment ternyata bermasalah. Bagian 42.3 sampai 42.5 bab ini seluruhnya berada di wilayah tanggung jawab Sysadmin tersebut.

42.2 Git sebagai Fondasi Workflow Deployment

42.2.1 Kenapa Commit Git Jadi Satu-Satunya Sumber Kebenaran Rilis

Bagian 36.1.1 sudah memperkenalkan masalah configuration drift pada konfigurasi server, dan masalah yang sama persis terjadi pada kode aplikasi jika tidak ada satu sumber kebenaran yang jelas. Tanpa Git, "versi yang berjalan di server" bisa jadi hasil scp manual yang sudah beberapa kali ditimpa file tambal sulam langsung di server, tanpa riwayat yang jelas mengenai siapa yang mengubah apa dan kapan. Git menutup celah ini dengan menjadikan setiap commit sebagai unit perubahan yang tercatat permanen dan punya identitas unik (hash SHA), sehingga "versi yang sedang berjalan di server" selalu bisa dijawab tegas dengan satu commit hash tertentu, bukan tebakan.

Bab ini mengasumsikan pembaca sudah familier dengan perintah Git dasar seperti git add, git commit, dan git push, karena fokus bab ini adalah bagaimana Git terhubung ke pipeline deployment, bukan tutorial Git dari nol.

42.2.2 Trunk-Based Development vs Git-Flow untuk Deployment Otomatis

Cara tim mengatur branch Git secara langsung memengaruhi seberapa lancar CI/CD bisa berjalan. Dua pola yang paling sering dibandingkan adalah trunk-based development dan Git-Flow.

AspekTrunk-Based DevelopmentGit-Flow
Branch utamaSatu branch (main), jadi sumber deploy langsungBeberapa branch permanen (main, develop) plus branch rilis terpisah
Umur feature branchPendek, idealnya kurang dari sehari sebelum di-mergeBisa berumur panjang sampai fitur benar-benar selesai
Kecocokan dengan Continuous DeploymentTinggi, tiap merge ke main langsung berarti kandidat rilisRendah, perlu tahap merge tambahan ke branch rilis dulu
Risiko utamaKode belum matang bisa ikut ter-deploy jika tidak dibantu feature flagMerge konflik besar menumpuk karena branch berumur panjang

Pipeline yang dibangun di bab ini memakai pola trunk-based, dengan setiap push ke branch main langsung memicu deployment. Pola ini sengaja dipilih karena selaras dengan Continuous Deployment yang dibahas di Bagian 42.1.2; branch fitur yang berumur panjang ala Git-Flow justru menunda nilai utama CI/CD, yaitu umpan balik cepat begitu kode digabungkan.

42.2.3 Menyiapkan Repository demo-node dan Push ke GitHub

Aplikasi demo-node yang sudah berjalan sebagai service di app01 sejak Bagian 15.6.2 belum pernah tersimpan di Git sama sekali; source code-nya hanya ada di /opt/demo-node/server.js milik server itu sendiri. Bagian ini membuat repository-nya, sekaligus menambahkan sedikit struktur pada server.js supaya ada logika yang benar-benar bisa diuji otomatis pada tahap CI.

Langkah Praktik

  1. Di komputer kerja kita (bukan di app01), buat direktori project baru dan inisialisasi Git.
    mkdir ~/demo-node && cd ~/demo-node
    git init
  2. Buat server.js, melanjutkan kode dari Bagian 15.6.1, ditambah sedikit refactor agar fungsi pembentuk response bisa diimpor dan diuji terpisah dari proses listen.
    nano server.js
  3. Isi dengan kode berikut.
    const http = require('http');
    const os = require('os');
    
    const port = process.env.PORT || 3000;
    
    function buildResponseText(hostname, date) {
      return `Node.js aktif di ${hostname}, waktu server: ${date.toISOString()}\n`;
    }
    
    const server = http.createServer((req, res) => {
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end(buildResponseText(os.hostname(), new Date()));
    });
    
    if (require.main === module) {
      server.listen(port, () => {
        console.log(`Server berjalan di port ${port}`);
      });
    }
    
    module.exports = { buildResponseText };
    Blok if (require.main === module) memastikan server.listen hanya berjalan saat file ini dieksekusi langsung lewat node server.js, persis seperti dipakai ExecStart pada unit systemd Bagian 15.6.2, tetapi tidak ikut berjalan saat file ini diimpor sebagai module oleh test.
  4. Buat package.json minimal, cukup untuk mendaftarkan perintah test.
    nano package.json
  5. Isi dengan konfigurasi berikut.
    {
      "name": "demo-node",
      "version": "1.0.0",
      "private": true,
      "scripts": {
        "test": "node --test"
      }
    }
    Perintah node --test memakai test runner bawaan Node.js (module node:test), stabil sejak beberapa versi LTS terakhir, sehingga tidak perlu memasang dependency test framework pihak ketiga sama sekali untuk contoh sesederhana ini.
  6. Buat direktori test dan file pengujian pertama.
    mkdir test
    nano test/server.test.js
  7. Isi dengan pengujian sederhana terhadap buildResponseText.
    const test = require('node:test');
    const assert = require('node:assert');
    const { buildResponseText } = require('../server');
    
    test('buildResponseText menyertakan hostname dan format ISO date', () => {
      const sample = new Date('2026-08-28T00:00:00.000Z');
      const text = buildResponseText('app01', sample);
      assert.match(text, /app01/);
      assert.match(text, /2026-08-28T00:00:00\.000Z/);
    });
  8. Jalankan test secara lokal sebelum di-commit, memastikan tahap CI nanti punya dasar yang sudah terbukti jalan.
    node --test
  9. Commit seluruh file, lalu buat repository baru di GitHub (privat, karena Bagian 42.4.2 nanti menjalankan self-hosted runner yang menurut dokumentasi resmi GitHub sebaiknya tidak dipasangkan ke repository publik), dan push.
    git add server.js package.json test/
    git commit -m "Inisialisasi demo-node dengan test dasar"
    git branch -M main
    git remote add origin [email protected]:contoh-user/demo-node.git
    git push -u origin main

Verifikasi dan Troubleshooting

  • Output node --test yang sehat menampilkan ringkasan # pass 1 dan # fail 0. Kegagalan pada baris assert.match kedua biasanya berarti argumen tanggal yang dikirim ke buildResponseText bukan objek Date yang valid.
  • Ganti [email protected]:contoh-user/demo-node.git dengan URL repository milik kita sendiri. Perintah git push yang gagal dengan Permission denied (publickey) berarti akun GitHub kita belum terdaftar SSH key, langkah yang polanya sama seperti autentikasi berbasis key di Bagian 3.2.2, hanya saja key-nya didaftarkan ke pengaturan akun GitHub, bukan ke authorized_keys sebuah server.

42.3 Menyiapkan app01 sebagai Target Deployment

42.3.1 Migrasi ke Struktur Direktori Berbasis Release

Struktur /opt/demo-node/server.js yang dipakai sejak Bab 15 cukup untuk belajar systemd, tetapi tidak cocok untuk deployment berulang: menimpa server.js langsung saat rilis baru sedang berjalan berarti ada jendela waktu singkat ketika proses lama masih membaca file yang sudah setengah tertimpa. Pola yang jauh lebih aman, dipakai luas oleh tool deployment seperti Capistrano dan dipraktikkan di bagian ini secara manual, adalah menaruh setiap rilis di direktori sendiri lalu mengarahkan satu symlink bernama current ke rilis yang aktif.

Langkah Praktik

Jalankan seluruh langkah berikut di app01.

  1. Hentikan sementara service yang sedang berjalan sebelum strukturnya diubah.
    sudo systemctl stop demo-node.service
  2. Buat direktori releases, lalu pindahkan server.js yang sudah ada ke dalam rilis pertama, diberi nama timestamp saat ini.
    sudo mkdir -p /opt/demo-node/releases
    RELEASE_ID=$(date +%Y%m%d%H%M%S)
    sudo mkdir "/opt/demo-node/releases/$RELEASE_ID"
    sudo mv /opt/demo-node/server.js "/opt/demo-node/releases/$RELEASE_ID/"
  3. Buat symlink current menunjuk ke rilis pertama ini.
    sudo ln -sfn "/opt/demo-node/releases/$RELEASE_ID" /opt/demo-node/current
  4. Samakan kepemilikan seluruh struktur baru ke user nodeapp yang sudah dibuat di Bagian 15.6.2.
    sudo chown -R nodeapp:nodeapp /opt/demo-node
  5. Sesuaikan unit systemd agar menunjuk ke symlink current, bukan lagi ke path tetap.
    sudo nano /etc/systemd/system/demo-node.service
  6. Ubah baris WorkingDirectory dan ExecStart menjadi seperti berikut, baris lain tetap sama seperti Bagian 15.6.2.
    WorkingDirectory=/opt/demo-node/current
    ExecStart=/usr/bin/node /opt/demo-node/current/server.js
  7. Muat ulang systemd dan jalankan kembali service-nya.
    sudo systemctl daemon-reload
    sudo systemctl start demo-node.service

Verifikasi dan Troubleshooting

  • Uji dengan curl seperti biasa untuk memastikan aplikasi tetap berjalan normal setelah migrasi struktur.
    curl http://localhost:3000
  • Perintah readlink -f /opt/demo-node/current semestinya menampilkan path lengkap ke direktori rilis pertama tadi, bukti symlink sudah terpasang benar.
  • Kegagalan systemctl start dengan pesan menyinggung MODULE_NOT_FOUND atau path tidak ditemukan hampir selalu berarti langkah 6 salah ketik path, atau langkah daemon-reload di langkah 7 terlewat sehingga systemd masih memakai definisi unit lama dari cache-nya.

42.3.2 Membuat Deploy User dengan SSH Key Terbatas (Forced Command)

Pipeline CI butuh cara masuk ke app01 tanpa campur tangan manusia. Menyerahkan key SSH pribadi Sysadmin ke sebuah pipeline otomatis, seperti sempat dilakukan untuk kebutuhan manusia di Bagian 41.2.3, bukan praktik yang aman untuk konteks ini: jika credential pipeline bocor, seluruh akses yang dimiliki key itu ikut bocor. Solusinya adalah user dan key khusus yang aksesnya dibatasi ketat hanya untuk satu tugas: menerima rilis baru.

Langkah Praktik

  1. Buat system user deploy di app01, tanpa hak login shell interaktif.
    sudo useradd --system --create-home --shell /usr/sbin/nologin deploy
  2. Buat direktori .ssh milik user ini dengan permission yang benar.
    sudo -u deploy mkdir -m 700 /home/deploy/.ssh
  3. Di komputer kerja kita (atau di control node yang dipakai di Bagian 42.4.2), buat pasangan key baru khusus untuk deployment, terpisah dari key pribadi Sysadmin.
    ssh-keygen -t ed25519 -f ~/.ssh/deploy_demo_node -C "deploy-ci@demo-node" -N ""
  4. Salin isi ~/.ssh/deploy_demo_node.pub, lalu di app01 tulis authorized_keys milik user deploy dengan opsi forced command yang membatasi key ini hanya boleh menjalankan satu script tertentu.
    sudo -u deploy nano /home/deploy/.ssh/authorized_keys
  5. Isi dengan satu baris berikut, ganti bagian ssh-ed25519 AAAA... dengan isi public key sesungguhnya.
    command="/opt/demo-node/deploy.sh",no-agent-forwarding,no-port-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... deploy-ci@demo-node
  6. Kunci permission file dan direktori ini sesuai syarat OpenSSH.
    sudo chmod 600 /home/deploy/.ssh/authorized_keys
    sudo chown -R deploy:deploy /home/deploy/.ssh

Verifikasi dan Troubleshooting

  • Opsi command= pada authorized_keys ini didokumentasikan resmi di halaman manual sshd(8) bagian AUTHORIZED_KEYS FILE FORMAT: perintah apa pun yang dikirim client lewat SSH akan diabaikan, dan server selalu menjalankan /opt/demo-node/deploy.sh sebagai gantinya. Artinya, sekalipun key ini bocor, penyerang paling jauh hanya bisa memicu script deploy itu sendiri, bukan mendapat shell bebas di app01. Opsi no-pty aman dipakai di sini karena proses pengiriman rilis nanti murni streaming data lewat stdin, tidak butuh terminal interaktif sama sekali.
  • Script /opt/demo-node/deploy.sh yang dirujuk baris ini belum dibuat sampai Bagian 42.3.3 selesai; percobaan koneksi sebelum script itu ada akan gagal dengan pesan No such file or directory, wajar untuk sementara.

42.3.3 Menulis deploy.sh dan Sudoers untuk Restart Service

Bagian ini menulis script yang benar-benar dijalankan setiap kali user deploy menerima koneksi SSH. Script ini sengaja tidak ikut disimpan di repository demo-node dan tidak pernah ikut tertimpa oleh isi rilis yang dikirim CI, karena payload rilis nanti hanya diekstrak ke dalam subdirektori releases/ yang baru, bukan menimpa /opt/demo-node itu sendiri. Pemisahan ini penting: perubahan pada script deploy tetap sepenuhnya berada di kendali Sysadmin lewat akses SSH langsung ke app01, bukan lewat commit siapa pun ke repository aplikasi.

Langkah Praktik

  1. Siapkan direktori releases dengan bit setgid aktif dan grup nodeapp, supaya setiap rilis baru yang dibuat user deploy otomatis bisa dibaca user nodeapp yang menjalankan service, tanpa perlu perintah chown terpisah setiap deploy.
    sudo chown deploy:nodeapp /opt/demo-node/releases
    sudo chmod 2775 /opt/demo-node/releases
    Bit setgid (2 pada awal mode) membuat subdirektori baru yang dibuat di dalam releases otomatis mewarisi grup nodeapp milik direktori induknya, bukan grup utama user yang membuatnya, perilaku standar filesystem Linux untuk direktori dengan bit ini aktif.
  2. Buat file deploy.sh, dimiliki root supaya tidak bisa diubah oleh user deploy sendiri.
    sudo nano /opt/demo-node/deploy.sh
  3. Isi dengan script berikut.
    #!/bin/bash
    set -euo pipefail
    
    RELEASES_DIR=/opt/demo-node/releases
    CURRENT_LINK=/opt/demo-node/current
    KEEP_RELEASES=5
    
    RELEASE_DIR="$RELEASES_DIR/$(date +%Y%m%d%H%M%S)"
    mkdir -p "$RELEASE_DIR"
    tar -xzf - -C "$RELEASE_DIR"
    
    ln -sfn "$RELEASE_DIR" "$CURRENT_LINK.tmp"
    mv -T "$CURRENT_LINK.tmp" "$CURRENT_LINK"
    
    sudo /usr/bin/systemctl restart demo-node.service
    
    cd "$RELEASES_DIR"
    ls -1 | sort -r | tail -n +$((KEEP_RELEASES + 1)) | xargs -r rm -rf --
    Perintah tar -xzf - membaca arsip terkompresi langsung dari stdin, cocok dipasangkan dengan CI yang mengirim isi repository lewat pipa SSH yang sama seperti dipraktikkan di Bagian 42.4.1. Pergantian symlink sengaja ditulis dua langkah, membuat symlink sementara current.tmp lalu memindahkannya menimpa current lewat mv -T, karena rename() pada filesystem yang sama dijamin atomik oleh POSIX, sementara membuat ulang symlink langsung di atas nama yang sama (ln -sfn tanpa langkah pemindahan) masih menyisakan jendela waktu singkat tanpa symlink sama sekali. Nama direktori rilis sengaja berformat timestamp YYYYMMDDHHMMSS supaya bisa diurutkan leksikografis lewat sort -r, tidak bergantung pada metadata waktu modifikasi filesystem yang bisa saja meleset.
  4. Beri izin eksekusi. Grup deploy yang dirujuk di sini tidak perlu dibuat terpisah, sebab useradd di Bagian 42.3.2 langkah pertama sudah otomatis membuat grup baru bernama sama dengan usernya sebagai grup utama, perilaku default Ubuntu selama pengaturan USERGROUPS_ENAB di /etc/login.defs tidak diubah dari yes.
    sudo chmod 750 /opt/demo-node/deploy.sh
    sudo chown root:deploy /opt/demo-node/deploy.sh
  5. Baris sudo systemctl restart di dalam script butuh privilege yang belum dimiliki user deploy. Buat rule sudoers baru, mengikuti konvensi sudoers.d/ yang sudah dipakai sejak Bagian 4.2.2, dibatasi hanya untuk satu perintah spesifik ini.
    sudo visudo -f /etc/sudoers.d/deploy-demo-node
  6. Isi dengan satu baris berikut.
    deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart demo-node.service

Verifikasi dan Troubleshooting

  • Rule sudoers di atas sengaja tidak memakai wildcard seperti systemctl * atau ALL, mengikuti prinsip least privilege yang sudah dibahas di Bagian 34.2.1: user deploy hanya boleh me-restart satu service tertentu, tidak lebih, sekalipun kredensialnya bocor.
  • sudo visudo -f pada langkah 5 memvalidasi sintaks file sebelum menyimpannya, mencegah kesalahan ketik yang bisa merusak seluruh mekanisme sudo di server, kebiasaan yang sama pentingnya dengan validasi cloud-init schema di Bagian 41.2.3.
  • Bagian ini belum bisa diuji end-to-end sampai ada CI yang benar-benar mengirim rilis; pengujian penuh dilakukan di Bagian 42.4.1.

42.4 Deployment Sederhana: dari Repository ke Server

42.4.1 Uji Manual: Mengirim Rilis Lewat Tar over SSH

Sebelum menyerahkan proses ini ke pipeline otomatis, kita uji dulu secara manual dari komputer kerja sendiri, persis pola yang sama dipakai Bagian 36.3.2 untuk menguji konektivitas Ansible lewat ad-hoc command sebelum menulis playbook sungguhan.

Langkah Praktik

  1. Dari direktori ~/demo-node yang sudah dibuat di Bagian 42.2.3, kemas seluruh isi project jadi arsip tar terkompresi, kecualikan direktori .git yang tidak perlu ikut terdeploy, lalu alirkan langsung lewat SSH ke app01 memakai key deploy dari Bagian 42.3.2.
    tar czf - --exclude=.git . | ssh -i ~/.ssh/deploy_demo_node [email protected]

Verifikasi dan Troubleshooting

  • Perintah di atas tidak menampilkan output apa pun jika berhasil, sesuai isi deploy.sh yang memang tidak mencetak apa-apa ke stdout. Verifikasi keberhasilan lewat curl seperti biasa.
    curl http://192.168.1.40:3000
  • Periksa juga bahwa direktori rilis baru benar-benar terbentuk dan symlink berpindah.
    ssh -i ~/.ssh/deploy_demo_node [email protected] readlink -f /opt/demo-node/current
    Perhatikan bahwa argumen readlink -f /opt/demo-node/current yang dikirim di sini sebenarnya diabaikan sepenuhnya oleh server, sesuai perilaku command= yang sudah dijelaskan di Bagian 42.3.2; perintah ini tetap hanya menjalankan deploy.sh dan mengirim rilis kosong (karena kita tidak mengalirkan tar apa pun kali ini). Untuk sekadar mengecek symlink tanpa memicu deploy baru, jalankan readlink -f /opt/demo-node/current langsung dari sesi SSH biasa milik Sysadmin, bukan lewat key deploy yang memang dibatasi khusus untuk deploy saja.
  • Error Permission denied berarti public key belum cocok dengan baris authorized_keys di Bagian 42.3.2. Error tar: This does not look like a tar archive di sisi server biasanya berarti koneksi terputus di tengah jalan sebelum seluruh stream terkirim.

42.4.2 Mengotomasi Trigger dengan GitHub Actions Self-Hosted Runner

Perintah manual di Bagian 42.4.1 sudah bekerja, tetapi tetap butuh seseorang mengetiknya setiap kali ada rilis baru, persis masalah yang sama dibahas di pembuka bab ini. Bagian ini memasang self-hosted runner GitHub Actions di control node yang sama dipakai untuk Ansible sejak Bab 36, sehingga runner ini bisa langsung menjalankan perintah tar dan ssh di atas begitu ada push ke branch main.

Langkah Praktik

  1. Di halaman repository demo-node pada GitHub, buka Settings > Actions > Runners > New self-hosted runner, pilih OS Linux dan arsitektur x64. Salin persis perintah curl dan ./config.sh yang ditampilkan halaman tersebut, jangan disalin dari sumber lain, karena perintah itu menyertakan nomor versi runner terbaru dan token registrasi sekali pakai yang unik untuk repository serta sesi kita sendiri.
  2. Jalankan perintah yang disalin tadi di control node, pola umumnya seperti berikut (nilai VERSION dan TOKEN harus diganti sesuai yang ditampilkan GitHub).
    mkdir actions-runner && cd actions-runner
    curl -o actions-runner-linux-x64.tar.gz -L \
      https://github.com/actions/runner/releases/download/vVERSION/actions-runner-linux-x64-VERSION.tar.gz
    tar xzf ./actions-runner-linux-x64.tar.gz
    ./config.sh --url https://github.com/contoh-user/demo-node --token TOKEN
  3. Pasang runner sebagai service systemd, sesuai script bawaan yang disediakan paket runner itu sendiri, mengikuti prinsip yang sama seperti unit service kustom di Bagian 5.2.
    sudo ./svc.sh install
    sudo ./svc.sh start
  4. Salin isi private key deploy dari Bagian 42.3.2 (~/.ssh/deploy_demo_node, bukan file .pub-nya), lalu tambahkan sebagai secret repository bernama DEPLOY_SSH_KEY lewat Settings > Secrets and variables > Actions > New repository secret. Key ini sengaja tidak pernah tersimpan sebagai file permanen di control node maupun di mana pun selain GitHub; step workflow pada langkah berikutnya merekonstruksinya jadi file sementara hanya selama satu kali eksekusi, lalu menghapusnya lagi begitu selesai.
  5. Kembali di komputer kerja, buat file workflow.
    mkdir -p .github/workflows
    nano .github/workflows/deploy.yml
  6. Isi dengan definisi berikut.
    name: Deploy demo-node ke app01
    
    on:
      push:
        branches: [main]
    
    jobs:
      deploy:
        runs-on: self-hosted
        steps:
          - name: Checkout kode
            uses: actions/checkout@v4
    
          - name: Jalankan test
            run: node --test
    
          - name: Kirim rilis lewat SSH
            env:
              DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
            run: |
              install -m 600 /dev/null /tmp/deploy_key
              echo "$DEPLOY_KEY" > /tmp/deploy_key
              tar czf - --exclude=.git . | \
                ssh -i /tmp/deploy_key -o StrictHostKeyChecking=accept-new [email protected]
              rm -f /tmp/deploy_key
    Urutan step ini sengaja menjalankan test lebih dulu sebelum langkah kirim rilis; jika node --test gagal, workflow berhenti di step itu dan step pengiriman rilis tidak pernah dieksekusi sama sekali, inilah bentuk konkret Continuous Integration yang dibahas di Bagian 42.1.1 benar-benar mencegah kode bermasalah sampai ke tahap deploy.
  7. Commit dan push file workflow ini ke branch main.
    git add .github/workflows/deploy.yml
    git commit -m "Tambahkan pipeline deploy ke app01"
    git push

Verifikasi dan Troubleshooting

  • Tab Actions pada repository GitHub menampilkan riwayat jalannya workflow setiap kali ada push ke main, lengkap dengan log tiap step. Status hijau berarti seluruh step termasuk pengiriman rilis berhasil; klik run yang gagal untuk melihat step mana persisnya yang berhenti.
  • Catatan keamanan yang jujur perlu disampaikan di sini. Runner self-hosted menjalankan isi workflow langsung di sistem operasi control node dengan privilege user yang menjalankan service runner, bukan di lingkungan sandbox terpisah seperti runner yang disediakan GitHub sendiri. Dokumentasi resmi GitHub secara eksplisit tidak merekomendasikan self-hosted runner untuk repository publik, karena siapa pun yang bisa membuka pull request berpotensi menyisipkan perintah berbahaya ke dalam workflow dan menjalankannya di mesin kita. Repository demo-node di bab ini privat, tetapi kombinasi ini tetap jadi alasan kenapa Bagian 42.3.2 dan 42.3.3 sengaja membatasi apa yang bisa dilakukan credential deploy sendiri lewat forced command dan sudoers sempit, bukan sekadar mengandalkan repository yang privat saja sebagai satu-satunya lapisan pertahanan.
  • Jika control node berada di jaringan yang berbeda dari 192.168.1.0/24 atau target deployment-nya adalah instance cloud publik seperti vm-cloud01 di Bab 41, dua opsi umum dipakai di lapangan: memasang self-hosted runner yang sama ini di jaringan yang bisa menjangkau server target lewat SSH biasa (persis pola bab ini), atau memakai runner terkelola milik GitHub sendiri (runs-on: ubuntu-latest) yang berjalan di infrastruktur cloud GitHub dan butuh security group cloud (Bagian 41.3.2) dibuka untuk rentang IP publik GitHub Actions yang dipublikasikan lewat API meta GitHub, sesuatu yang lebih merepotkan dijaga tetap akurat dibanding cukup mengizinkan satu IP tetap milik control node internal.

42.4.3 Alternatif: Memanggil Playbook Ansible dari Pipeline

Pendekatan tar-over-SSH di Bagian 42.4.2 cukup untuk satu server, tetapi begitu jumlah managed node bertambah seperti skenario pembuka Bab 36, menulis ulang logika symlink dan cleanup rilis di setiap server jadi pekerjaan berulang. Karena Ansible sudah terpasang di control node yang sama sejak Bab 36, cara paling wajar untuk berkembang bukan menulis ulang deployment dari nol, melainkan menyuruh CI memanggil playbook yang polanya sama persis dengan install-nginx.yml di Bagian 36.4.1.

Langkah Praktik

  1. Di direktori ~/ansible-project yang sudah ada dari Bab 36, buat playbook baru.
    cd ~/ansible-project
    nano deploy-demo-node.yml
  2. Isi dengan definisi berikut.
    ---
    - name: Deploy rilis terbaru demo-node
      hosts: webservers
      become: true
      vars:
        release_id: "{{ lookup('pipe', 'date +%Y%m%d%H%M%S') }}"
        release_path: "/opt/demo-node/releases/{{ release_id }}"
        app_src: "{{ playbook_dir }}/../demo-node/"
    
      tasks:
        - name: Buat direktori rilis baru
          ansible.builtin.file:
            path: "{{ release_path }}"
            state: directory
            owner: deploy
            group: nodeapp
            mode: "2775"
    
        - name: Salin source code ke direktori rilis
          ansible.posix.synchronize:
            src: "{{ app_src }}"
            dest: "{{ release_path }}/"
            rsync_opts:
              - "--exclude=.git"
    
        - name: Arahkan symlink current ke rilis baru
          ansible.builtin.file:
            src: "{{ release_path }}"
            dest: /opt/demo-node/current
            state: link
            force: true
    
        - name: Restart service demo-node
          ansible.builtin.systemd:
            name: demo-node.service
            state: restarted
    
        - name: Bersihkan rilis lama, sisakan 5 terbaru
          ansible.builtin.shell: |
            cd /opt/demo-node/releases && ls -1 | sort -r | tail -n +6 | xargs -r rm -rf --
          changed_when: false
    Module ansible.posix.synchronize membungkus rsync, bagian dari collection ansible.posix yang sudah ikut terpasang lewat metapackage ansible sejak Bagian 36.2.1. Playbook ini butuh become: true penuh di managed node, cakupan privilege yang lebih luas dibanding sudoers sempit khusus restart pada Bagian 42.3.3, konsekuensi wajar dari memakai user Ansible yang sama dipakai untuk seluruh konfigurasi server, bukan user deploy yang sengaja dipersempit. Variable app_src sengaja dipisah jadi default yang bisa ditimpa, sebab lokasi source code yang disalin task ini berbeda tergantung siapa yang menjalankan playbook: dijalankan manual dari control node, default {{ playbook_dir }}/../demo-node/ sudah tepat karena mengasumsikan direktori ~/demo-node sejajar dengan ~/ansible-project; dipanggil dari GitHub Actions, checkout milik runner tidak pernah berada di path itu, melainkan di direktori workspace milik job itu sendiri.
  3. Ganti step "Kirim rilis lewat SSH" pada deploy.yml Bagian 42.4.2 dengan pemanggilan playbook ini, dijalankan dari checkout kode demo-node yang sudah ada di workspace runner. Timpa app_src lewat flag -e mengarah ke $GITHUB_WORKSPACE, environment variable bawaan GitHub Actions yang selalu menunjuk ke direktori checkout job yang sedang berjalan, supaya playbook tidak keliru mengambil source code dari ~/demo-node milik sesi manual Sysadmin.
          - name: Deploy dengan Ansible
            run: |
              ansible-playbook -i ~/ansible-project/inventory.ini \
                ~/ansible-project/deploy-demo-node.yml \
                -e "app_src=$GITHUB_WORKSPACE/"

Verifikasi dan Troubleshooting

  • Jalankan playbook ini manual sekali dari control node sebelum mengandalkannya lewat CI, mengikuti kebiasaan uji ad-hoc yang sama seperti Bagian 36.4.2. Perintah manual ini sengaja tidak menyertakan flag -e app_src=..., karena nilai default app_src pada playbook sudah tepat untuk kondisi ini.
    ansible-playbook -i inventory.ini deploy-demo-node.yml
    PLAY RECAP yang sehat menampilkan changed=4 pada eksekusi pertama (empat task benar-benar mengubah kondisi server), dan sebagian changed turun jadi lebih kecil pada eksekusi berikutnya untuk task yang sifatnya idempotent seperti pembuatan direktori.
  • Dua pendekatan pada Bagian 42.4.2 dan 42.4.3 ini bukan soal mana yang "lebih benar", melainkan trade-off yang jujur perlu disampaikan: script tar-over-SSH dengan forced command punya blast radius paling kecil jika credential-nya bocor, cocok untuk satu atau dua server penting. Ansible lebih mudah diperluas ke banyak server sekaligus tanpa menulis ulang logika deploy, tetapi menyerahkan privilege become yang jauh lebih luas ke tangan pipeline CI. Pilih sesuai jumlah server dan tingkat kepercayaan terhadap pipeline itu sendiri, bukan sekadar preferensi tool.

42.5 Rollback Strategy Dasar

42.5.1 Symlink Swap Atomik sebagai Dasar Rollback Cepat

Rollback adalah proses mengembalikan aplikasi ke versi rilis sebelumnya begitu rilis terbaru ternyata bermasalah. Pola direktori berbasis release yang sudah dibangun sejak Bagian 42.3.1 membuat rollback jadi operasi yang sangat murah: karena setiap rilis lama tetap tersimpan utuh di releases/ sampai giliran dihapus lewat cleanup di Bagian 42.3.3, rollback cukup mengarahkan ulang symlink current ke direktori rilis sebelumnya lalu restart service, tanpa perlu proses build ulang atau menarik ulang kode dari Git sama sekali.

Bandingkan dengan pola deployment naif yang menimpa file langsung di tempat tanpa struktur release: rollback pada pola itu berarti harus menyiapkan ulang seluruh file versi lama dari awal, proses yang jauh lebih lambat justru di saat kecepatan paling dibutuhkan, yaitu ketika produksi sedang bermasalah.

42.5.2 Script rollback.sh dan Menjaga Riwayat Release

Berbeda dari deployment rutin yang cocok sepenuhnya otomatis, rollback biasanya tetap melibatkan keputusan sadar seorang Sysadmin, sebab rollback berarti mengakui rilis terbaru bermasalah dan perlu ditangani lebih lanjut di sisi kode. Karena itu, rollback.sh pada bagian ini dijalankan manual lewat akses sudo biasa, bukan lewat key deploy yang dibatasi forced command di Bagian 42.3.2.

Langkah Praktik

  1. Di app01, buat file rollback.sh.
    sudo nano /opt/demo-node/rollback.sh
  2. Isi dengan script berikut.
    #!/bin/bash
    set -euo pipefail
    
    RELEASES_DIR=/opt/demo-node/releases
    CURRENT_LINK=/opt/demo-node/current
    
    CURRENT_RELEASE=$(basename "$(readlink -f "$CURRENT_LINK")")
    PREVIOUS_RELEASE=$(cd "$RELEASES_DIR" && ls -1 | sort -r | grep -v "^${CURRENT_RELEASE}$" | head -n1)
    
    if [ -z "$PREVIOUS_RELEASE" ]; then
      echo "Tidak ada rilis sebelumnya untuk rollback." >&2
      exit 1
    fi
    
    echo "Rollback dari $CURRENT_RELEASE ke $PREVIOUS_RELEASE"
    ln -sfn "$RELEASES_DIR/$PREVIOUS_RELEASE" "$CURRENT_LINK.tmp"
    mv -T "$CURRENT_LINK.tmp" "$CURRENT_LINK"
    systemctl restart demo-node.service
    Logika pemilihan PREVIOUS_RELEASE memakai teknik pengurutan nama direktori yang sama seperti cleanup di Bagian 42.3.3, mengambil entri paling baru yang bukan rilis yang sedang aktif sekarang.
  3. Beri izin eksekusi, dimiliki root karena script ini dijalankan lewat sudo.
    sudo chmod 750 /opt/demo-node/rollback.sh
    sudo chown root:root /opt/demo-node/rollback.sh

Verifikasi dan Troubleshooting

  • Jalankan rollback dan konfirmasi hasilnya.
    sudo /opt/demo-node/rollback.sh
    curl http://localhost:3000
  • Pesan Tidak ada rilis sebelumnya untuk rollback yang keluar dengan exit code bukan nol adalah perilaku yang disengaja, bukan bug, mencegah script mencoba rollback ke rilis yang tidak ada saat baru pertama kali deploy.
  • Karena cleanup di Bagian 42.3.3 hanya menyisakan lima rilis terakhir, rollback lewat script ini hanya bisa mundur satu langkah ke rilis tepat sebelumnya, bukan ke rilis mana pun di masa lalu. Kebutuhan rollback ke rilis yang lebih lama dari itu berarti kembali mengandalkan Git: checkout commit lama, lalu jalankan ulang proses deploy di Bagian 42.4 seperti rilis baru biasa.

42.5.3 Menutup Celah dengan Health Check Sebelum Rollback Otomatis

Bab ini konsisten menjalankan rollback secara manual, tetapi pipeline production yang matang umumnya melangkah satu tahap lebih jauh: menambahkan step health check tepat setelah deploy, memanggil endpoint aplikasi lewat curl persis seperti langkah verifikasi yang sudah berulang kali dipraktikkan sepanjang series ini, lalu memanggil rollback.sh secara otomatis jika health check gagal dalam beberapa kali percobaan. Pola inilah yang sesungguhnya membedakan Continuous Deployment yang dipercaya penuh dari sekadar "auto deploy tanpa jaring pengaman", perbedaan yang sudah disinggung di Bagian 42.1.2.

Bab ini sengaja berhenti di rollback manual sebagai fondasi, karena health check otomatis yang baik butuh definisi "sehat" yang spesifik untuk tiap aplikasi, di luar cakupan pengenalan CI/CD. Prinsip dasarnya tetap sama seperti pengecekan systemctl status dan log yang sudah kita kuasai sejak Bab 5, hanya saja pemicunya kini otomatis lewat pipeline, bukan diketik manual oleh Sysadmin.

Sampai di sini, aplikasi demo-node di app01 sudah punya alur deployment yang utuh: kode tersimpan di Git sebagai satu sumber kebenaran, pipeline CI menguji setiap perubahan sebelum sempat menyentuh server produksi, dua jalur deployment (script terbatas dan Ansible) siap dipilih sesuai kebutuhan, dan rollback bisa dilakukan dalam hitungan detik lewat pola symlink yang sama dipakai saat deploy. Pipeline ini masih sesederhana mungkin, cukup untuk satu aplikasi dan satu server target, belum menyentuh isu seperti deployment tanpa downtime ke banyak server sekaligus atau strategi blue-green yang jadi standar di lingkungan produksi besar. Bab 43 menutup series ini dengan sisi yang selalu menyertai sistem otomatis mana pun, yaitu troubleshooting, termasuk saat pipeline yang sudah kita bangun di bab ini sendiri suatu saat gagal di tengah malam tanpa ada Sysadmin yang menungguinya secara langsung.