Organisasi ingin bergerak cepat dan engineer ingin produktif. Begitupula dengan stakeholders yang ingin men-deliver perubahan dengan cepat tanpa harus mengorbankan kualitas. Dan juga pengguna akhir, ingin menggunakan layanan yang stabil.
Salah satu cara untuk mencapai itu adalah dengan membuat Staging server, sebuah server yang seolah-olah seperti Production dengan lingkungan yang hampir sama dengan yang digunakan di Production. Hampir setiap organisasi mungkin sudah memilki staging server sendiri, tulisan ini hanyalah untuk mereka yang mungkin belum memiliki staging server.
Ditulisan ini kita tidak membahas hal-hal di level aplikasi–seperti konfigurasi database khusus staging, konfigurasi environment variable untuk staging, dsb–melainkan hanya di level infrastruktur dengan requirements yang tidak terlalu kompleks.
Mengapa menggunakan Docker?
Banyak alasan mengapa menggunakan Docker (dan juga tidak menggunakannya), alasan gue antara lain:
- Isolasi untuk dependensi level aplikasi. Bila aplikasi membutuhkan
imagemagick
, maka dependensi tersebut harusnya tidak terpasang di server (global). - Isolasi di sumber daya. Selain agar lebih mudah untuk di profiling, juga agar lebih 'aman' sehingga jika misal terdapat kesalahan yang menyebabkan memakan banyak sumber daya, maka yang terkena dampaknya hanya di aplikasi tersebut.
- Menggunakan docker mempermudah di proses CI
Dan masih banyak lagi.
Mengapa menggunakan Traefik?
Traefik adalah sebuah edge router, yang menawarkan reverse proxy & load balancer out-of-the-box. Jika kamu familiar dengan Nginx, harusnya familiar juga dengan yang ditawarkan oleh Traefik.
Ada alasan khusus mengapa gue menggunakan Traefik daripada Nginx. Pertama, karena menggunakan Docker & container gue memiliki port yang dinamis. Tidak ada remapping port yang gue lakukan untuk menghindari konflik & mengingat-ngingat port mana saja yang sudah digunakan.
Juga, banyak hal-hal yang gue lakukan di level aplikasi bisa diatasi di traefik. Seperti auth forwarding, security-things response header, dsb.
Traefik adalah proyek Open Source & memiliki single-executable binary (thanks to Go). Namun di konteks ini, gue menggunakan Traefik sebagai container, bukan executable program.
Mengapa menggunakan Gitlab CI?
Banyak CI yang ada di internet, khususnya yang gratis. Gue menggunakan Gitlab karena sudah terbiasa dengan CI Gitlab, alias males menghafal sintaks untuk konfigurasinya lagi.
Silahkan gunakan CI apapun, pada dasarnya cuma beda dipenulisan konfigurasinya aja.
Workflow
Gue anggap lo menggunakan Docker juga, dan sudah mengenal sedikit tentang bagaimana cara kerja Traefik. Dan ya, sudah familiar sedikit dengan konsep CI khususnya menggunakan Gitlab CI.
Workflow disini sebenarnya mainstream, memiliki 5 stage & 2 lingkungan:
- Build
- Test
- Release
- Deploy (Staging)
- Deploy (Production)
Di stage build ini adalah... proses build? Seperti, install dependensi, copy file ini-itu, compile binary, dsb.
Di stage test, kita melakukan proses automate testing. Unit, integration, dsb.
Di stage release, image tersebut berarti sudah siap dan labelnya akan diubah menjadi staging
.
Di stage deploy (staging), kita akan melakukan pull ke image tersebut (staging
), dan melakukan proses "deploy" ke staging.
Di stage deploy (production), ini harus ada intervensi dari penanggung jawab, entah code owner, qa, team lead, ob, vp, siapapun. Kita tidak menerapkan continuous deployment (hanya continuous delivery), setelah staging server dianggap "OK".
Karena biasanya di stage staging
ini kita melakukan smoke test/uat/apapun kamu menyebutnya. "Men-deploy" dari staging ke production semudah "klik deploy" yang ditawarkan di UI Gitlab.
Jadi, gambaran nya kira-kira seperti ini:
- Developer push branch baru
- CI akan melakukan
build
dantest
- Developer melakukan Merge/Pull Request.
- PR di merge.
- CI akan melakukan proses
build
,test
,release
- Lalu, akan mendeploy ke production
- Jika OK, maka
Tech Stacks
- 1 VM (Ubuntu, 1GB RAM. 1 core)
- CI (gitlab.com CI)
- Docker Image Registry (registry.gitlab.com)
- Docker (+ docker-compose)
- Traefik
- Aplikasi apapun (untuk contoh disini adalah Node.js)
Ya, kita akan menggunakannya ke mesin $5/bulan dengan total 4 aplikasi yang berjalan di production :))
Membuat staging URL
Buat staging URL, anggap https://app-staging.evilfactory.id
dan arahkan ke server (via A record) di DNS kalian.
Membuat instance traefik
Gue anggap kamu sudah meng-install Docker dan sudah mengerti sedikit tentang Docker Compose. Mari kita buat untuk service traefik kita.
version: '3'
services:
traefik:
image: traefik:1.7
command:
- --docker
- --api
ports:
- "80:80"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Kode diatas singkatnya adalah:
- Gunakan sintaks
docker-compose
versi 3 - Kita buat "services", disini salah satu service nya bernama
traefik
. - Di service tersebut kita menggunakan image
traefik
dengan versi1.7
(latest stable) - Lalu "mem-pass" parameter
--docker
untuk memberitahu traefik untuk me-listen docker, dan parameter--api
untuk memberitahu traefik agar kita bisa mengakses dashboard sederhana dari traefik. - Kita lakukan remapping port disini, port 80 ke port 80 di container (edge router) dan port 8080 ke port 8080 di container (traefik dashboard)
- Terakhir, kita "mount" docker.sock (Docker daemon) di server kita ke container traefik.
Lalu "deploy" service tersebut sebagai daemon dengan perintah:
docker-compose up -d traefik
Jika sudah, harusnya kita bisa akses traefik dashboard di <server_ip>:8080
.
Membuat aplikasi Node.js sederhana
Disini kita hanya membuat aplikasi web yang menampilkan <h2>Hello worls</h2>
, dan memiliki 1 API endpoint yakni /healthcheck
untuk... Sesuai namanya, healthcheck (apakah server up?).
Disini kita menggunakan fastify
sebagai framework, silahkan install dependensi dan buat file index.js
// index.js
const fastify = require('fastify')({ logger: true })
const IS_STAGING = process.env.IS_STAGING
fastify.get('/healthcheck', (request, reply) => {
reply.send('OK')
})
fastify.get('/', (request, reply) => {
reply
.type('text/html')
.send(`<h2>Hello worls</h2>${IS_STAGING ? '(staging)' : ''}`)
})
fastify.listen(process.env.PORT, process.env.HOST)
Lalu kita coba jalankan server kita:
PORT=3000 HOST=0.0.0.0 IS_STAGING=true node index.js
Jika sudah berjalan, kita test melakukan "healthcheck"
curl http://localhost:3000/healthcheck
Jika menampilkan OK
, berarti tidak error.
Mengapa kita menggunakan host 0.0.0.0
bukan 127.0.0.1
? Sederhananya, agar "mem-bind" aplikasi kita ke alamat IP dari yang dimiliki oleh server (container) kita.
Membuat Dockerfile
Karena aplikasi kita akan dibuat menjadi docker container, kita harus membuat Dockerfile
untuk proses "build" ini. File nya sederhana saja:
FROM node:10-alpine
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
EXPOSE 3000
RUN yarn
COPY . .
CMD ["npm", "start"]
Pertama, kita menggunakan base image node:10-alpine
. Alpine linux yang sudah ter-bundle Node.js versi 10
Kedua, kita set /app
sebagai workdir (working directory) kita.
Ketiga, copy package.json
ke workdir. Untuk proses instalasi dependensi di container kita nanti
Keempat, copy yarn.lock
ke workdir. Ini berguna agar dependensi yang di-install di local kita konsisten dengan yang ada di container.
Kelima, kita install dependensi yang dibutuhkan.
Keenam, kita copy source code kita ke container. Karena kita tidak menjadikan aplikasi kita sebagai executable binary, bukan?
Terakhir, jalankan aplikasi kita dengan membaca script start
di package.json
.
Perlu diingat, buat file .dockerignore
dengan nilai node_modules
atau dengan nilai yang sama di .gitignore
untuk mencegah docker meng-copy file-file yang sebenarnya hanya dibutuhkan di development environment (dan node_modules of CORS).
Silahkan coba build image tersebut, misal dengan perintah seperti ini:
docker build -t 108kb/my-awesome-project:staging .
Jika sudah, coba test dengan menjalankan aplikasi kita via docker.
docker run --env IS_STAGING=true --env HOST=0.0.0.0 --env PORT=3000 -p 3000:3000 108kb/my-awesome-project:staging
Buka tab terminal baru, dan lakukan test yang sama seperti di section Node.js.
Membuat service baru di Docker Compose & integrasi dengan traefik
Mari kita automate proses deployment aplikasi di docker kita dengan docker-compose
. Kita tambahkan konfigurasi lain docker-compose.yml
kita, lalu buat file berikut:
app_staging:
image: 108kb/my-awesome-project:staging
labels:
- "traefik.port=3000"
- "traefik.frontend.rule=Host: app-staging.evilfactory.id"
environment:
- HOST=0.0.0.0
- PORT=3000
- IS_STAGING=true
Silahkan ubah 108kb/my-awesome-project
dengan nama sesuai kalian, dan ubah app-staging.evilfactory.id
dengan url staging kalian.
Bagian label traefik.port=3000
adalah untuk memberitahu traefik bahwa aplikasi kita menggunakan port 3000, dan bagian traefik.frontend.rule=Host:...
untuk memberitahu traefik bahwa "backend" ini yang bertanggung jawab untuk request ke app-staging.evilfactory.id
.
Silahkan deploy service baru tersebut:
docker-compose up -d app_staging
Dan lakukan test:
curl http://localhost/healthcheck -H "Host: app-staging.evilfactory.id"
Jika response nya "OK", maka traefik berhasil me-route request tersebut ke container kita.

Setting Continuous Integration (Gitlab CI)
Disini kita akan menggunakan 5 fase dan 2 branch. Untuk fase:
- Build
- Test
- Release
- Deploy (Staging)
- Deploy (Production)
Dan untuk branch ada canary
dan master
. Canary sederhananya untuk "nightly build" alias build terbaru (sebenarnya tidak sesederhana itu, untuk sekarang anggap aja seperti itu) untu di staging dan Master untuk kode yang stabil, production.
Rules nya adalah:
- Setiap perubahan harus berdasarkan branch
canary
. - Branch
master
sifatnya merge-only, tidak ada yang bisa push langsung ke branch master. Begitupula dengancanary
canary
untuk stagingmaster
untuk production
Mengapa base nya dari canary
bukan master
?
Karena... Continuous Integration?
Branch canary
adalah perubahan yang sifatnya sudah teruji secara otomatis dan siap untuk dibawa ke Staging, sedangkan master
adalah perubahan yang sifatnya sudah teruji oleh manusia (QA). Pada pembahasan disini kita tidak membahas seputar Canary Release dan Deploy Preview, jadi, mari kita persingkat menjadi kondisi seperti diatas.
Gue anggap kamu sudah menyiapkan hal-hal yang berkaitan dengan Gitlab, untuk kondisi server, singkatnya seperti ini:
- User khusus (non sudoers) di group
docker
khusus untuk deploy - Koneksi SSH khusus untuk user tersebut menggunakan password, selainnya hanya via publickey
Untuk point nomor 2, bisa edit sshd_config
nya menjadi seperti ini:
# /etc/ssh/sshd_config
Match User <some_user_name>
AuthenticationMethods password
Jangan lupa restart service sshd
nya agar perubahan yang terjadi bisa diterapkan.
Sekarang, mari kita buat file .gitlab_ci.yml
nya
image: docker:stable
services:
- docker:dind
variables:
STAGING_URL: https://app-staging.evilfactory.id
PRODUCTION_URL: https://app.evilfactory.id
before_script:
- docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN $CI_REGISTRY
stages:
- build
- test
- release
- staging
- production
build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
test:
stage: test
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker run $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA node -e "console.log('ok')"
staging:
stage: release
only:
- canary
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:staging
- docker push $CI_REGISTRY_IMAGE:staging
deploy_staging:
stage: staging
only:
- canary
script:
- echo "TODO: Deploy to staging server"
environment:
name: staging
url: $STAGING_URL
deploy_production:
stage: production
only:
- master
script:
- echo "TODO: Deploy to production server"
environment:
name: production
url: $PRODUCTION_URL
when: manual
Pusing? Santai, dikit-dikit kita bahas.
Disini, kita menggunakan service docker in docker di CI kita, karena, kita membutuhkan perintah docker
disini.
Kekurangannya, salah satunya adalah proses build menjadi lebih lama karena belum ada nya proses "caching" yang membuat proses tersebut menjadi lebih cepat.
Di variables
, kita men-set env variable yang dibutuhkan (sebenarnya biar rapih aja sih)
Di before_script
, maksudnya adalah "eksekusi perintah-perintah berikut sebelum yang lain disetiap job", untuk make sure kita sudah login ke docker registry nya gitlab.
Untuk stages
, adalah untuk seperti ini:

Dibagian yang paling atas (Build, Test, Staging dan Production) itu adalah stages
. Sedangkan yang dibawah stages tersebut adalah "Job".
Lalu "top-level indent" dibawah stages tersebut adalah job-job yang ada yang isinya:
- Memberitahu perintah apa yang harus dieksekusi (
script
) - Stage apa untuk job tersebut
- Environment apa yang terkait dengan job tersebut
- Terakhir, "apa yang membuat job tersebut ter-trigger". Sebagai contoh, misal, bila ada push ke branch
canary
.
Untuk diatas, kita hanya sampai ke release
terlebih dahulu agar tidak terlalu kehilangan fokus. Job-job diatas kita bahas dikit-dikit:
- Build: Build container image seperti biasa lalu push ke registry dengan tag commit SHA yang ada
- Test: Pull container yang sudah dibuild tersebut, lalu jalankan test yang ada di container tersebut. Misal
npm run test
, karena kita tidak membuat test apapun (di level aplikasi), jadi mari kita persingkat hanya untuk mengeksekusi Node.js di container kita sebagai test - Release: Anggap automate testing kita sudah pass semua, di stage ini kita pull image tersebut, ubah tag nya dari commit SHA menjadi
staging
. Bisa diubah menjadilatest
ataunightly
ataupreview
terserah gimana cocoknya kamu aja.
Untuk Deploy (staging & server) kita skip dulu untuk saat ini, sampai kita lihat Gitlab CI kita berjalan sesuai dengan yang kita harapkan. Push kode tersebut ke repository, dan lihat prosesnya di CI/CD > Pipelines
.
Jika hasilnya kurang lebih seperti gambar di Gitlab Pipeline (diatas), berarti konfigurasi kita benar.
Continuous Delivery
Sampai saat ini kita sudah sampai ke tahap Continuous Integration, sekarang mari kita terapkan Continuous Delivery.
Apa perbedaan Continuous Delivery dengan Integration? Atau dengan Deployment?
Lihat gambar dibawah

Untuk melakukan deploy ke production, harus dilakukan secara manual. Di script .gitlab-ci.yml
tersebut sudah kita definisikan (di bagian when: manual
), sekarang mari kita buat script untuk melakukan proses deployment ke staging nya secara otomatis.
Flow nya seperti ini:
- SSH ke staging server kita
- Pull image yang memiliki tag
staging
- Deploy service via
docker-compose
Tahap opsional nya, setelah berhasil di deploy ke staging server, kirim notif ke group Slack/Discord/Telegram/apapun itu untuk ngasih tau tim.
Mari kita buat script nya, kira-kira seperti ini:
script:
- apk update && apk add sshpass openssh
- export IMAGE_NAME=$CI_REGISTRY_IMAGE:staging
- sshpass -e ssh -o StrictHostKeyChecking=no $SSH_SERVER docker pull $IMAGE_NAME
- sshpass -e ssh -o StrictHostKeyChecking=no $SSH_SERVER docker-compose up -d $STAGING_APP_NAME
- echo "> Done. Check it at $STAGING_URL"
Dengan kondisi:
- Kita menggunakan
sshpass
untuk meng-eksekusi command di server kita - Kita install
openssh
karena alpine by default tidak memilikinya docker-compose.yml
berada di home directory- Personal token untuk akses registry gitlab sudah dibuat
- Server sudah login ke docker registry gitlab
Nilai dari $SSH_SERVER
gue adalah <user>@<ip> -p<port>
, karena gue mengubah port default untuk SSH. Dan si <user>
bisa mengakses docker
. Dan jangan lupa untuk nilai environment variable SSHPASS
di Repository > I/CD > Varibales
karena kita menggunakan sshpass -e
Perlu diketahui bahwa Gitlab akan membaca environment variable kamu (yang di CI/CD) bila kamu men-setting branch tersebut sebagai "protected". Bisa disetting di Settings > Repository > Protected Branches
alias di https://gitlab.com/<username>/<project>/-/settings/repository
Struktur directory home gue adalah seperti ini:
$ pwd
/home/<user>
$ tree -L 1
.
|-- app
|-- data
`-- docker-compose.yml
2 directories, 1 file
Untuk membuat personal access token, bisa akses di Profile > Access Tokens
, alias disini. Beri scope setidaknya read_registry
. Token tersebut harap disimpan karena akan kita gunakan untuk login ke gitlab registry.
Jika server belum login ke docker registry gitlab, silahkan login terlebih dahulu dengan perintah seperti dibawah:
$ docker login -u <username_gitlab> -p <personal_access_token> registry.gitlab.com
Dan dari script diatas, yang kita lakukan hanyalah:
- Pull image container dengan label
staging
- Deploy :))
Disini kita harus mengubah image
kita menjadi registry.gitlab.com
dilanjutkan dengan nama image sebelumnya. Misal menjadi seperti ini registry.gitlab.com/108kb/my-awesome-project
di file docker-compose.yml
kita.
Juga, jangan lupa untuk membuat variable bernama STAGING_APP_NAME
dan PRODUCTION_APP_NAME
di .gitlab-ci.yml
kita.
Lalu setiap perubahan yang terjadi di branch selain canary
akan melakukan build
dan test
, dan di branch canary akan melakukan build
, test
, release
dan deploy_production
.
Setelah itu kita bisa akses di, misalnya: https://app-staging.evilfactory.id
.
Oops, there is a typo!
Ya, yang seharusnya Hello World, malah Hello Worls. Thanks to our QA!
Gak apa-apa, yang penting tahap build
dan test
sudah pass, yang artinya tidak ada kesalahan fatal di level aplikasi. Silahkan buat branch baru, push, dan merge request ke canary
. Contohnya seperti ini nanti jadinya:

Branch tersebut akan ter-merge ke canary
secara otomatis bila job kita pass. Yang berarti, akan men-trigger proses deployment ke staging
server secara otomatis. Yang nanti nya akan menjadi seperti ini bila job sebelumnya pass

Yang bila kita klik View App, akan diarahkan ke staging URL kita yang sudah didefinisikan.
Deploy to production
Oke sebagai bonus.
Prosesnya hampir sama dengan diatas, bedanya, untuk bisa men-deploy kita harus klik tombol dibawah, dengan kondisi branch canary
berhasil di merge ke master
:

Lalu proses yang dilakukan adalah:
- Pull
staging
image - Build image baru berdasarkan image tersebut
- Beri tag image tersebut menjadi
stable
. Ataulatest
, terserah. Btw kita melakukan "versioning" untuk aplikasi kita di level repository. - Push image
- Di server, pull image dengan tag
stable
. - Deploy!
Hal-hal yang terkait dengan "environment variable" di level aplikasi, didefinisikan di docker-compose.yml
. Berikut script untuk stage production nya:
deploy_production:
stage: production
script:
- apk update && apk add sshpass openssh
- export IMAGE_NAME=$CI_REGISTRY_IMAGE:stable
- docker pull $CI_REGISTRY_IMAGE:staging
- docker tag $CI_REGISTRY_IMAGE:staging $CI_REGISTRY_IMAGE:stable
- docker push $CI_REGISTRY_IMAGE:stable
- sshpass -e ssh -o StrictHostKeyChecking=no $SSH_SERVER docker pull $IMAGE_NAME
- sshpass -e ssh -o StrictHostKeyChecking=no $SSH_SERVER docker-compose up -d $PRODUCTION_APP_NAME
- echo "> Done. Check it at $PRODUCTION_URL"
environment:
name: production
url: $PRODUCTION_URL
when: manual
only:
- master
Berikut hasilnya:

Lalu kita bisa akses https://app.evilfactory.id
(atau klik View app) dan aplikasi pun akan berdasarkan staging server kita (namun tanpa embel-embel (staging)
). Dan tanpa typo yang disengaja tersebut yang sudah kita fix :))
P.S
Kamu bisa menggunakan Gitlab CI meskipun sumber kode kamu berada di GitHub, dengan cara seperti ini (New Project > CI/CD for external repo):

Sebagai penutup, berikut link-link yang terkait dengan tulisan ini:
- Sumber kode: https://github.com/108kb/staging-server-demo
- Staging URL: https://app-staging.evilfactory.id
- Production URL: https://app.evilfactory.id