DevOps

Docker vs PM2 untuk Next.js di VPS: Panduan Deploy 2026

Asep Alazhari

PM2 simpel, Docker powerful. Di VPS dengan 7 GB RAM nganggur, pilihannya bukan soal resource. Ini soal pipeline, portabilitas, dan rencana scale lo ke depan.

Docker vs PM2 untuk Next.js di VPS: Panduan Deploy 2026

Docker vs PM2 untuk Next.js di VPS: Panduan Deploy 2026

Gue lagi lihat dashboard VPS sore itu dan angka-angkanya bikin penasaran. CPU di 0%. RAM cuma terpakai 423 MB dari total 7.8 GB. Disk 4.6 GB dari 58 GB. Load average mentok di 0.00. Aplikasi Next.js gue jalan mulus pakai PM2. Tapi ada satu pertanyaan yang terus muncul: gue udah deploy dengan cara yang bener, atau gue lagi nyimpen masalah buat diri sendiri di masa depan?

Dari sana gue mulai banding-bandingin serius. Resource usage, deployment workflow, integrasi CI/CD, sampai maintenance jangka panjang. Ini hasilnya.

TL;DR: Perbandingan Cepat

FiturNative + PM2Docker
Setup awalCepatSedang
Isolasi environmentTidak adaPenuh per container
Kontrol versi NodeManual (.nvmrc)Dockerfile ARG
Integrasi CI/CDSSH + git pullRegistry push + pull
RollbackRestart build lamadocker run tag lama
Horizontal scalingSulitBawaan Compose
PortabilitasRendahTinggi
Maintenance jangka panjangMakin beratRingan dengan image

Native + PM2: Titik Awal yang Familiar

Hampir semua tutorial produksi Next.js berakhir di sini:

npm install -g pm2
pm2 start npm --name "my-app" -- start
pm2 save
pm2 startup

Selesai. Aplikasi lo jalan di background, restart otomatis kalau crash, dan hidup lagi setelah server reboot. PM2 adalah process manager yang solid. Dia log output, monitor CPU dan memory per process, dan bisa nge-handle banyak aplikasi sekaligus.

Untuk project solo atau deploy cepat ke production, PM2 memang cukup kok. Daya tariknya jelas: tidak ada lapisan abstraksi tambahan. Lo SSH masuk, clone repo, install package, jalanin app. Node langsung jalan di host. Lo bisa lihat di htop. Log ada di tempat yang lo ekspektasikan.

Tapi retaknya mulai kelihatan begitu kompleksitas bertambah.

Mau upgrade Node.js dari 20 ke 22? Lo harus upgrade secara global di server. Semua aplikasi di mesin itu ikut pindah sekaligus. Kalau satu app punya dependency yang belum kompatibel, dia bisa rusak. Rollback artinya downgrade Node secara global dan berharap tidak ada yang ikut patah.

Environment variable hidup di file .env yang lo kelola manual. Deploy ke server baru artinya recreate file itu dari awal. Miss satu variabel dan lo dapat runtime error yang diam-diam merusak fitur tanpa pesan yang jelas.

Mereplikasi environment ke server kedua artinya mengikuti langkah setup yang sama lagi. Tidak ada satu file tunggal yang menangkap seluruh definisi environment. Yang paling mendekati adalah README atau shell script yang bakal drift seiring waktu.

Docker untuk Next.js: Apa yang Berubah

Docker mengemas aplikasi lo dan seluruh runtime-nya ke dalam satu image. Image itu berisi Node, kode aplikasi, dan semua dependency-nya. Dia jalan identik di laptop lo, di CI runner, di staging, dan di production.

Untuk Next.js, ada satu setting yang wajib sebelum build Docker image. Tambahkan ini ke next.config.js:

const nextConfig = {
  output: 'standalone',
}

module.exports = nextConfig

Mode standalone output ini bikin Next.js cuma ngopy server file yang beneran dipakai aplikasi lo ke dalam .next/standalone. Dia trace dependency saat build dan hasilkan direktori yang mandiri. Aplikasi Next.js yang biasanya 500 MB ke atas bisa menyusut jadi 150 sampai 250 MB.

Ini Dockerfile multi-stage yang production-ready untuk Next.js:

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

CMD ["node", "server.js"]

Image ini jalan sebagai non-root user. Final stage tidak mengandung build tools, dev dependencies, atau source file di luar yang dibutuhkan server. Hasilnya: artifact yang kecil, mudah di-audit, dan reproducible.

Kapan Resource VPS Benar-Benar Jadi Masalah

Di sinilah argumen “Docker itu berat” biasanya runtuh kalau ketemu hardware modern.

Lihat angka nyata dari VPS yang gue sebutkan di awal:

  • RAM yang dipakai sebelum ada container: 423 MB
  • Docker daemon jalan idle: sekitar 50 sampai 80 MB overhead
  • Satu container Next.js jalan: sekitar 100 sampai 150 MB

Total konsumsi memori dengan Docker: sekitar 600 sampai 650 MB. Masih sisa lebih dari 7 GB di mesin 7.8 GB itu.

Saat idle, container Docker yang menjalankan Next.js konsumsi CPU-nya mendekati nol. Perilakunya saat tidak ada request tidak beda signifikan dari PM2. Saat request masuk, Node menanganinya di dalam container. Batas container menambah overhead dalam satuan mikrosecond per koneksi, bukan milidetik.

Argumen “Docker terlalu berat” itu valid kalau mesin lo sudah jalan di 85 atau 90 persen kapasitas. Di VPS baru dengan sebagian besar RAM-nya nganggur, overhead-nya tidak relevan sama sekali. Lo tidak sedang menukar performa. Lo menukar beberapa jam setup awal dengan operasional yang jauh lebih bersih ke depannya.

Also Read: Cara Otomatis Docker Tagging di GitLab CI/CD: Panduan Praktis

Kenapa Gue Pilih Docker: 3 Alasan Nyata

1. Portabilitas

Build image sekali. Push ke registry. Pull ke server manapun. App jalan sama persis di semua tempat karena environment-nya ikut terbawa di dalam image.

Onboarding developer baru cukup satu command: docker compose up. Tidak ada install Node global, tidak ada mismatch versi, tidak ada sesi debugging “works on my machine” yang buang waktu. Pindah ke VPS provider baru artinya pull image dan jalankan compose file. Selesai deh.

2. Isolasi dan Keamanan

Setiap container jalan di network namespace sendiri. Service yang tidak seharusnya berkomunikasi tidak akan bisa, kecuali lo secara eksplisit buat koneksinya. Vulnerability Node di satu app tidak mengekspos app lain di host yang sama.

Tidak ada global state yang menumpuk. Container yang lo drop hilang bersih. Recreate dari image mengembalikan persis environment yang sama, bukan salinan yang sudah berlapis-lapis perubahan manual selama berbulan-bulan.

Menjalankan sebagai non-root user di dalam container cukup satu baris di Dockerfile. Melakukan hal yang sama dengan PM2 raw butuh konfigurasi manual lebih banyak dan gampang kelewatan.

3. Maintenance dan Upgrade

Upgrade Node.js artinya edit satu baris di Dockerfile dan rebuild:

FROM node:22-alpine AS deps

Image sebelumnya masih ada di registry, ter-tag dengan commit SHA lama. Rollback tinggal docker run dengan tag lama itu. Tidak ada reinstall, tidak ada perubahan global ke host.

Dengan PM2, upgrade Node di server menyentuh semua app di mesin itu sekaligus. Kalau ada yang rusak, lo debugging perubahan global state sementara production app lo down.

Build Pipeline: GitLab Runner dan GitHub Actions

Di sinilah Docker balik modal biaya setup-nya sepuluh kali lipat deh.

Sinkronisasi Versi Tanpa Drift

Dockerfile adalah satu-satunya sumber kebenaran untuk runtime lo. CI pipeline lo membacanya langsung:

# .gitlab-ci.yml
variables:
  NODE_VERSION: "22"

build-image:
  stage: build
  image: docker:26
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build
        --build-arg NODE_VERSION=$NODE_VERSION
        -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
        -t $CI_REGISTRY_IMAGE:latest
        .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest

Setiap environment pakai image yang sama. Tidak ada versi yang jalan di CI runner tapi tidak di server production.

Memilih Executor

GitLab Runner punya dua opsi utama untuk build Docker image.

Executor docker menjalankan setiap CI job di dalam container baru yang bersih. Untuk build image dari dalam container, lo tambahkan docker:dind sebagai service. Ini memberikan isolasi penuh per job dan environment yang selalu bersih di setiap run.

Executor shell menjalankan job langsung di host runner. Kalau Docker sudah terinstall di host itu, lo bisa build dan push tanpa DinD. Ini lebih simpel dan cepat untuk setup awal, tapi berbagi environment host antar semua job.

Untuk GitHub Actions, pendekatan standarnya pakai ubuntu-latest dengan action build-and-push resmi:

name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Login ke GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build dan push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}

Pipeline Deploy Lengkap untuk GitLab

Pipeline komplit yang build, tag, dan deploy ke VPS:

stages:
  - build
  - deploy

build-image:
  stage: build
  image: docker:26
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main

deploy-production:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - ssh-keyscan -H $SERVER_IP >> ~/.ssh/known_hosts
  script:
    - ssh deploy@$SERVER_IP "
        docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY &&
        docker pull $CI_REGISTRY_IMAGE:latest &&
        docker compose -f /opt/myapp/docker-compose.yml up -d --no-deps app
      "
  only:
    - main
  needs:
    - build-image

Rule only: main bikin pipeline ini cuma jalan di push ke branch main. Step deploy lewat SSH pull image baru dan restart cuma service app tanpa menyentuh bagian lain dari compose stack.

Also Read: Cara Kurangi CPU Server 60% dengan Nginx Caching untuk Next.js

Strategi Deploy Next.js 2026

Docker Compose untuk Production

docker-compose.yml yang praktis untuk Next.js di belakang Nginx:

services:
  app:
    image: registry.gitlab.com/yourorg/yourapp:latest
    restart: always
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
      - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
    expose:
      - "3000"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    networks:
      - app-network

  nginx:
    image: nginx:alpine
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      app:
        condition: service_healthy
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

Konfigurasi Nginx minimal yang proxy ke container:

server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://app:3000;
        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;
    }
}

Docker Socket vs DinD

Docker socket di /var/run/docker.sock memberikan container akses ke Docker daemon host. Mount-nya memungkinkan lo menjalankan perintah Docker dari dalam container seolah-olah lo berada di host secara langsung.

Untuk build di CI, DinD lebih aman. Dia menjalankan Docker daemon sendiri di dalam job container. Build terisolasi dan tidak berbagi state dengan host runner.

Untuk deployment production, hindari rebuild di server aja. Pakai model registry push dan pull. CI pipeline build dan push image-nya. Server hanya pull dan restart. Server tidak perlu build tools dan tidak perlu akses ke source code lo. Ini menjaga attack surface tetap kecil.

Update Zero-Downtime

Saat image baru siap:

docker compose pull app
docker compose up -d --no-deps app

Compose menghentikan container lama dan menjalankan yang baru. Health check yang didefinisikan di compose file memastikan container baru sudah responsif sebelum traffic diarahkan ke sana.

Untuk zero-downtime yang lebih ketat, jalankan dua container di belakang Nginx dengan upstream weight. Deploy ke container yang idle, verifikasi health-nya, lalu pindahkan traffic Nginx dan drain yang lama.

Skalabilitas

PM2 cluster mode scale secara vertikal. Dia fork worker process sesuai jumlah CPU core. Di VPS 4-core lo dapat 4 worker. Itu batas atasnya. Traffic makin banyak artinya VPS yang lebih besar.

Docker Compose scale secara horizontal dengan replicas:

services:
  app:
    image: registry.gitlab.com/yourorg/yourapp:latest
    deploy:
      replicas: 3
    networks:
      - app-network

Di VPS yang gue ceritain di awal artikel ini (4 core, 7.8 GB RAM), menjalankan tiga sampai lima container Next.js di samping Nginx masih jauh di bawah batas resource. Setiap container pakai sekitar 100 sampai 150 MB RAM saat idle.

Kalau satu VPS sudah tidak cukup, lo tambah node kedua dan setup Docker Swarm. Kalau Swarm tidak lagi cukup, lo graduate ke Kubernetes. Abstraksi container tidak berubah di setiap langkah itu. File docker-compose.yml lo sudah sebagian besar dari yang lo butuhkan untuk Swarm.

Jalur dengan PM2 lebih cepat mentok. Scaling vertikal ada batasnya. Migrasi dari PM2 ke container di kemudian hari lebih mahal biayanya dibanding mulai dengan container sekarang.

Mana yang Harus Lo Pilih

SkenarioRekomendasi
Project solo, tidak ada timPM2 oke untuk mulai
Dua atau lebih environmentDocker dari hari pertama
Pipeline CI/CD sudah adaDocker selalu
Staging yang mirror productionDocker selalu
Rencana scale horizontalDocker sekarang, bukan nanti
Maintenance jangka panjang pentingDocker selalu

Kesimpulan

Di VPS dengan 7 GB RAM nganggur dan load average 0.00, overhead Docker bukan isu sama sekali. Yang jadi isu itu reproducibility, pipeline yang bersih, rollback yang gampang, dan jalur scale yang tidak perlu lo rewrite strategi deployment-nya.

PM2 bikin lo running dalam 10 menit. Docker bikin lo running dengan benar, konsisten, dan percaya diri di setiap environment.

Mulai dari output: 'standalone' di next.config.js lo. Tulis Dockerfile-nya. Setup build pipeline-nya. Pertama kali lo deploy fix ke production dalam waktu tiga menit dari satu git push, dengan kemampuan rollback penuh dan audit trail yang bersih, lo bakal ngerti sendiri kenapa itu worthwhile.

Back to Blog

Related Posts

View All Posts »