Studi Kasus: Aplikasi Node.js

Studi Kasus: Aplikasi Node.js

Bitnesia Sep 12, 2026 10 EN

Node.js jadi salah satu runtime yang paling sering di-containerize karena ekosistemnya yang dinamis, mulai dari REST API sederhana sampai aplikasi real-time berbasis WebSocket. Tapi containerizing aplikasi Node.js yang asal jalan berbeda jauh dengan containerizing yang benar-benar siap dipakai Developer sehari-hari maupun di-deploy Sysadmin/DevOps Engineer ke production. Bab ini membahas studi kasus penuh: dari menyusun Dockerfile yang efisien, menyambungkan aplikasi ke container database, membangun development workflow dengan hot reload, sampai konfigurasi yang layak dipakai di production seperti graceful shutdown dan health check.

28.1 Containerizing Aplikasi Node.js

Langkah pertama containerizing aplikasi Node.js adalah memastikan Dockerfile memanfaatkan mekanisme layer cache Docker dengan benar, karena instalasi dependency lewat npm biasanya jadi tahap paling lama dalam proses build.

28.1.1 Struktur Project dan Dependency

Studi kasus di bab ini memakai aplikasi Express sederhana dengan struktur project berikut.

myapp/
├── src/
│   └── server.js
├── package.json
├── package-lock.json
└── Dockerfile

File package.json mendefinisikan script start untuk menjalankan aplikasi dan dev untuk mode development dengan hot reload.

{
  "name": "myapp",
  "version": "1.0.0",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },
  "dependencies": {
    "express": "^4.19.2",
    "pg": "^8.11.5"
  },
  "devDependencies": {
    "nodemon": "^3.1.0"
  }
}

Package pg dipakai untuk koneksi ke PostgreSQL, sementara nodemon ditempatkan di devDependencies karena cuma dibutuhkan saat development, bukan saat aplikasi berjalan di production.

28.1.2 Dockerfile Multi-Stage untuk Node.js

Base image resmi node tersedia dalam beberapa varian, dan varian alpine jadi pilihan populer karena ukurannya jauh lebih kecil dibanding image Debian penuh. Dockerfile berikut memakai pola multi-stage build supaya dependency development seperti nodemon tidak ikut terbawa ke image akhir.

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]

Perintah npm ci dipakai alih-alih npm install karena, menurut dokumentasi resmi npm, npm ci menginstal dependency persis sesuai package-lock.json tanpa memodifikasinya, menghapus node_modules yang sudah ada lebih dulu, dan gagal secara eksplisit kalau package.json dan package-lock.json tidak sinkron. Perilaku ini membuat build lebih deterministic dan cocok dipakai di lingkungan otomatis seperti image build maupun pipeline CI/CD, dibanding npm install yang bisa diam-diam memperbarui package-lock.json.

Stage deps memakai opsi --omit=dev supaya cuma dependency production yang terinstal, sementara stage production tinggal menyalin hasil instalasi tersebut lewat COPY --from=deps, tanpa perlu menjalankan npm install ulang. Instruksi USER node memanfaatkan user non-root bernama node yang sudah disediakan oleh base image resmi Node.js, mengurangi risiko kalau ada vulnerability pada aplikasi yang dieksploitasi Attacker untuk mendapat akses root di dalam container.

28.1.3 .dockerignore dan Build Context

Tanpa file .dockerignore, instruksi COPY berisiko ikut menyalin folder node_modules lokal atau file .env yang seharusnya tidak masuk ke image.

node_modules
npm-debug.log
.env
.git
Dockerfile
.dockerignore

Build image dengan memberi tag yang jelas, lalu verifikasi ukurannya.

docker build -t myapp:1.0 .
docker images myapp

Jalankan container hasil build dan pastikan aplikasi merespons di port yang di-expose.

docker run -d -p 3000:3000 --name myapp myapp:1.0
curl http://localhost:3000

Kalau container langsung berhenti setelah dijalankan, periksa log-nya lebih dulu sebelum menebak-nebak penyebabnya.

docker logs myapp

Error yang sering muncul di tahap ini adalah Cannot find module, biasanya karena node_modules di stage deps tidak konsisten dengan versi Node.js yang dipakai stage akhir, terutama untuk package yang punya native binding hasil kompilasi.

28.2 Setup Database Container

Aplikasi Node.js jarang berdiri sendiri tanpa database. Bagian ini menyambungkan aplikasi Express ke container PostgreSQL memakai Docker Compose supaya kedua service bisa dikelola sebagai satu kesatuan.

28.2.1 PostgreSQL dengan Docker Compose

Definisikan service app dan db dalam satu file compose.yaml.

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://myapp:secret@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  db-data:

Opsi condition: service_healthy pada depends_on memastikan service app baru dijalankan setelah db benar-benar siap menerima koneksi, bukan cuma setelah container-nya menyala. Tanpa health check ini, depends_on hanya menjamin urutan start container, bukan kesiapan service di dalamnya, sehingga aplikasi bisa saja mencoba konek ke database yang masih dalam proses inisialisasi dan gagal di percobaan pertama.

docker compose up -d

28.2.2 Koneksi Database via Environment Variable

Aplikasi membaca connection string dari environment variable DATABASE_URL, bukan menuliskannya langsung di kode.

const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

module.exports = pool;

Perhatikan bahwa hostname pada DATABASE_URL memakai nama service db, bukan localhost. Docker Compose secara otomatis menyediakan DNS resolution antar-service dalam satu network default berdasarkan nama service yang didefinisikan di compose.yaml, sehingga app bisa menjangkau db cukup lewat nama tersebut tanpa perlu tahu alamat IP internal container-nya.

Verifikasi koneksi berhasil dengan masuk ke container aplikasi dan mengecek variabel environment-nya, atau langsung melihat log aplikasi.

docker compose logs app

28.2.3 Persistensi Data dengan Named Volume

Volume db-data yang dipasang ke /var/lib/postgresql/data memastikan data PostgreSQL tetap ada meski container db dihapus dan dibuat ulang, karena data sebenarnya tersimpan di named volume yang dikelola Docker, bukan di writable layer container yang bersifat sementara.

docker compose down
docker compose up -d
docker compose exec db psql -U myapp -d myapp -c "\dt"

Tabel yang sudah dibuat sebelumnya tetap muncul meski container db baru saja dibuat ulang oleh perintah docker compose down dan up. Perlu diperhatikan bahwa docker compose down -v akan ikut menghapus volume, termasuk db-data, jadi hindari opsi -v ini di production kecuali memang berniat menghapus seluruh data secara permanen.

28.3 Development Workflow

Development sehari-hari butuh siklus edit-simpan-lihat hasil yang cepat. Membangun ulang image setiap kali ada perubahan baris kode jelas tidak efisien, jadi Developer perlu workflow yang memungkinkan perubahan kode langsung terlihat tanpa rebuild.

28.3.1 Hot Reload dengan Nodemon dan Bind Mount

Buat file compose.override.yaml yang cuma dipakai untuk development, terpisah dari konfigurasi production.

services:
  app:
    command: npm run dev
    volumes:
      - ./src:/app/src
    environment:
      NODE_ENV: development

Bind mount pada ./src:/app/src menghubungkan folder src di host langsung ke folder yang sama di dalam container, sehingga setiap perubahan file di host langsung tercermin di dalam container tanpa perlu rebuild image. Kombinasikan dengan nodemon yang memantau perubahan file dan me-restart proses Node.js secara otomatis begitu ada file yang berubah.

docker compose up

Docker Compose otomatis membaca compose.yaml dan compose.override.yaml sekaligus tanpa perlu argumen tambahan, karena menurut dokumentasi resmi Docker Compose, file compose.override.yaml di direktori yang sama secara default digabungkan ke file compose.yaml utama. Edit src/server.js, simpan, lalu perhatikan log nodemon yang menampilkan proses restart otomatis.

28.3.2 Mengelola node_modules dengan Anonymous Volume

Salah satu jebakan yang sering ditemui Developer saat memakai bind mount untuk source code adalah node_modules di host (kalau ada, atau kosong kalau belum pernah npm install secara lokal) ikut menimpa node_modules yang sudah terinstal di dalam image saat container berjalan. Tambahkan anonymous volume khusus untuk node_modules supaya folder tersebut tetap memakai isi dari dalam image, bukan dari host.

services:
  app:
    command: npm run dev
    volumes:
      - ./src:/app/src
      - /app/node_modules
    environment:
      NODE_ENV: development

Baris /app/node_modules tanpa path sumber di host membuat Docker menyediakan volume kosong khusus untuk path tersebut di dalam container, yang secara efektif "melindungi" isi node_modules hasil npm ci saat build image dari tertimpa bind mount folder src di atasnya. Kalau ada penambahan dependency baru di package.json, tetap perlu docker compose build ulang supaya node_modules di dalam image ikut diperbarui.

docker compose build app
docker compose up -d

28.4 Production Deployment

Konfigurasi yang nyaman untuk development belum tentu aman dan stabil untuk production. Bagian ini membahas penyesuaian yang perlu dilakukan Sysadmin/DevOps Engineer sebelum aplikasi Node.js benar-benar siap melayani trafik nyata.

28.4.1 Image Production Minimal dan NODE_ENV

Image production memakai hasil build multi-stage tanpa nodemon dan tanpa bind mount apa pun, sesuai Dockerfile yang sudah disusun sebelumnya. Set environment variable NODE_ENV=production secara eksplisit, karena banyak framework dan library di ekosistem Node.js, termasuk Express, memakai nilai variabel ini untuk mengaktifkan optimasi seperti view caching dan menonaktifkan pesan error yang terlalu detail yang berpotensi membocorkan informasi internal aplikasi ke Attacker.

docker build -t myapp:1.0 --target production .
docker run -d \
  -p 3000:3000 \
  -e NODE_ENV=production \
  -e DATABASE_URL=postgres://myapp:secret@db:5432/myapp \
  --name myapp-prod \
  myapp:1.0

Opsi --target production memastikan docker build berhenti di stage production dan tidak menyertakan dependency development, meski Dockerfile-nya sama dengan yang dipakai untuk development. Set opsi --target production ini secara eksplisit tiap kali build image untuk production, supaya nodemon dan dependency development lain yang dibutuhkan stage build tetap tidak ikut ke image akhir.

28.4.2 Graceful Shutdown dan Signal Handling

Saat orchestrator seperti Docker Swarm atau Kubernetes menghentikan container, secara default sinyal SIGTERM dikirim lebih dulu untuk meminta proses berhenti secara graceful, baru kemudian SIGKILL dipaksakan kalau proses belum berhenti dalam batas waktu tertentu. Tangkap sinyal ini di kode aplikasi supaya koneksi database dan request yang sedang berjalan bisa diselesaikan dengan baik sebelum proses benar-benar mati.

const port = process.env.PORT || 3000;

const server = app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});

process.on('SIGTERM', () => {
  console.log('SIGTERM diterima, menutup server...');
  server.close(() => {
    pool.end();
    process.exit(0);
  });
});

Pastikan instruksi CMD di Dockerfile ditulis dalam bentuk exec form (["node", "src/server.js"]), bukan shell form (node src/server.js tanpa array). Menurut dokumentasi resmi Dockerfile reference, shell form menjalankan perintah lewat /bin/sh -c, sehingga proses node berjalan sebagai child process dari shell, bukan sebagai proses dengan PID 1. Akibatnya, sinyal SIGTERM yang dikirim Docker ke PID 1 tidak diteruskan secara otomatis ke proses Node.js di baliknya, dan container biasanya baru berhenti setelah grace period habis dan SIGKILL dipaksakan, alih-alih berhenti secara graceful sesuai kode yang sudah ditulis.

Uji perilaku ini dengan mengirim sinyal SIGTERM secara manual dan amati log-nya.

docker stop myapp-prod
docker logs myapp-prod

Log semestinya menampilkan pesan SIGTERM diterima, menutup server... sebelum container benar-benar berhenti, bukan container yang langsung mati tanpa jejak log penutupan.

28.4.3 Health Check dan Orchestration Readiness

Tambahkan endpoint khusus untuk pengecekan kesehatan aplikasi, terpisah dari endpoint bisnis utama.

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok' });
});

Definisikan instruksi HEALTHCHECK di Dockerfile supaya Docker daemon bisa memantau status aplikasi dari dalam lewat mekanisme health check, bukan cuma memantau apakah proses container masih berjalan.

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"

Perintah pengecekan di atas sengaja memakai modul http bawaan Node.js lewat node -e, bukan curl atau wget, karena base image node:20-alpine tidak menyertakan kedua tool tersebut secara default dan menambahkannya cuma untuk keperluan health check berarti memperbesar ukuran image tanpa manfaat lain bagi aplikasi. Verifikasi status health check lewat docker ps atau docker inspect setelah container berjalan cukup lama untuk melewati --start-period.

docker inspect --format='{{.State.Health.Status}}' myapp-prod

Kalau status menunjukkan unhealthy padahal aplikasi terlihat normal saat diakses manual, periksa apakah endpoint /health memerlukan koneksi database yang belum siap, karena health check yang bergantung pada dependency eksternal berisiko membuat container ditandai unhealthy justru saat database sedang mengalami gangguan sementara, bukan karena aplikasi Node.js itu sendiri bermasalah.