Skip to main content
BlogDevOps

Cara Deploy Next.js ke VPS dengan Docker dan GitHub Actions

Cara deploy Next.js ke VPS untuk pemula: Docker standalone, GHCR, dan GitHub Actions lewat SSH. Ada perintah siap salin, output, dan tabel error umumnya.

Matthews Wong

Software Engineer

Oktober 202611 menit baca
Cara Deploy Next.js ke VPS dengan Docker dan GitHub Actions

Ringkasan Utama

Cara deploy Next.js ke VPS yang rapi: aktifkan output standalone, bungkus aplikasi dalam Docker image, push image ke GHCR lewat GitHub Actions, lalu workflow masuk ke VPS dengan SSH dan menjalankan docker compose up -d --wait. Setiap push ke main otomatis menjadi deploy, dan rollback cukup mengganti tag image.

Kamu sudah punya aplikasi Next.js yang jalan di laptop dan sebuah VPS kosong. Lalu macet: apakah harus npm run build di server? Salin folder lewat FTP? Instal Node di server? Hampir semua tutorial berhenti di langkah ssh lalu git pull, padahal masalah sebenarnya muncul sesudahnya: build memakan RAM server, versi Node berbeda dengan laptop, dan tiap update harus diulang manual.

Panduan ini menunjukkan urutan kerja yang lebih aman untuk pemula: build sekali di GitHub Actions, kirim hasilnya sebagai Docker image, dan biarkan VPS cukup menarik image itu. Urutannya sama dengan yang dipakai situs ini sendiri, lengkap dengan Dockerfile dan workflow di repo-nya. Semua alamat dan nama di bawah adalah contoh: ganti dengan milikmu.

Mau belajar konsepnya, bukan hanya langkahnya?

Yang kamu butuhkan

Siapkan lima hal ini sebelum mulai. Tanpa salah satunya, langkah di bawah akan berhenti di tengah jalan.

  • Project Next.js (App Router) yang sudah di-push ke repository GitHub, dengan branch utama bernama main.
  • Docker terpasang di laptop untuk mencoba image, dan di VPS (ikuti panduan instalasi resmi Docker untuk distro-mu).
  • VPS Linux dengan akses SSH sebagai user yang punya sudo. Di contoh ini alamatnya 203.0.113.10, alamat contoh yang memang disediakan untuk dokumentasi.
  • Script npm run lint dan npm run build di package.json, karena workflow di langkah 6 memanggilnya.
  • Reverse proxy seperti nginx di VPS untuk membuka aplikasi ke internet. Panduan ini berhenti di container yang sehat di port lokal.

Langkah 1: Aktifkan output standalone dan coba di laptop

Standalone membuat next build menulis folder .next/standalone berisi server.js minimal dan hanya file node_modules yang benar-benar dibutuhkan, jadi folder itu bisa dijalankan tanpa npm install. Ini yang membuat image kecil dan cepat dikirim. Tambahkan satu baris di konfigurasi.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone", // build writes .next/standalone with its own server.js
};

export default nextConfig;

Sebelum membungkusnya dengan Docker, jalankan dulu di laptop. Dokumentasi Next.js menyebut server minimal ini tidak menyalin folder public dan .next/static secara default, jadi kamu menyalinnya sendiri. Perhatikan bedanya antara cara yang salah dan yang benar.

npm run build

# Wrong: start it right away. The page loads but every script and style is a 404.
node .next/standalone/server.js

# Right: copy public and .next/static next to server.js first (Next.js docs)
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
PORT=3000 HOSTNAME=0.0.0.0 node .next/standalone/server.js

Yang akan kamu lihat: server berjalan dan mencetak alamat tempat ia mendengarkan. Pada versi yang salah, halaman HTML muncul tetapi tanpa gaya, dan panel Network di browser dipenuhi 404 untuk file di /_next/static. Pada versi yang benar, halamannya tampil utuh. Kalau sudah begitu, lanjut ke Docker.

Langkah 2: Tulis Dockerfile multi-stage

Dockerfile ini punya tiga stage: deps memasang dependency, builder menjalankan npm run build, dan runner hanya membawa hasil standalone. Ini versi yang disederhanakan dari Dockerfile situs ini. Sesuaikan versi Node dengan yang kamu pakai di project.

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

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

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Listen on every interface inside the container, or the published port
# cannot reach the server.
ENV HOSTNAME=0.0.0.0

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

# Standalone leaves public and .next/static out on purpose: copy them yourself.
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"]

Tambahkan juga .dockerignore supaya node_modules dari laptop dan file .env berisi secret tidak ikut masuk ke image. Lalu coba bangun dan jalankan image-nya di laptop.

# .dockerignore
node_modules
.next
.git
.env
.env.*
docker build -t myapp:test .
docker run --rm -p 3000:3000 myapp:test
# in another terminal:
curl -I http://localhost:3000/

Yang akan kamu lihat: docker build selesai tanpa error, lalu curl -I mengembalikan baris status HTTP dari server Next.js. Kalau curl gagal tersambung, periksa dulu apakah ENV HOSTNAME dan flag -p 3000:3000 sudah ada.

Ingin image-nya lebih ramping lagi? Baca ukuran image Docker standalone Next.js setelah deploy pertamamu berhasil.

Langkah 3: Siapkan VPS, user deploy, dan SSH key

GitHub Actions perlu masuk ke VPS tanpa password, jadi buat key pair khusus untuk pipeline. Jangan pakai key pribadimu, supaya kalau suatu hari bocor kamu cukup mencabut satu key. Perintah ssh-keygen dan ssh-copy-id di bawah memakai -t ed25519, -C untuk komentar, dan -f untuk nama file.

# On your laptop: a dedicated key pair for the pipeline, no passphrase
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key

# On the VPS (as a sudo user): a non-root user that deploys
sudo adduser deploy
sudo usermod -aG docker deploy   # docker must already be installed
sudo -u deploy mkdir -p /home/deploy/app

# Back on your laptop: authorise the PUBLIC key for that user
ssh-copy-id -i ./deploy_key.pub deploy@203.0.113.10

# Prove it works before GitHub ever tries
ssh -i ./deploy_key deploy@203.0.113.10 "docker ps"

Yang akan kamu lihat: perintah terakhir langsung masuk tanpa meminta password, lalu docker ps mencetak tabel container, kosong kalau belum ada. Kalau muncul Permission denied (publickey), key belum terpasang di user deploy; ulangi ssh-copy-id.

Menambahkan user ke grup docker sama dengan memberinya akses setara root. Dokumentasi Docker menulis bahwa grup docker memberi hak istimewa tingkat root. Itu tradeoff yang wajar untuk user deploy, tetapi jangan berikan ke user lain, dan jangan pakai key ini untuk hal selain deploy.

Langkah 4: Tulis docker-compose.yml untuk server

Compose file ini yang menjalankan container di VPS. Perhatikan bahwa nama image tidak ditulis mati: ia dibaca dari variabel IMAGE di file .env yang akan ditulis pipeline di tiap deploy. Itu yang membuat satu commit menunjuk tepat satu image.

# docker-compose.yml  (copied to /home/deploy/app by the pipeline)
services:
  web:
    image: ${IMAGE}
    container_name: myapp
    restart: unless-stopped
    env_file: .env
    ports:
      # Wrong: "3000:3000" opens the app to the whole internet, bypassing nginx.
      # Right: loopback only. A reverse proxy is the way in.
      - "127.0.0.1:3000:3000"
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

Situs ini memakai pola yang sama: port hanya dibuka di 127.0.0.1 dan nginx di depannya yang menerima trafik dari internet. Health check meminta satu route murah dari dalam container, dan start_period memberi server waktu untuk menyala sebelum kegagalan dihitung. Taruh file ini di repo, di root, supaya workflow bisa menyalinnya ke server.

Langkah 5: Isi secret dan variable di GitHub

Workflow membaca alamat server dan key dari GitHub, bukan dari file di repo. Menurut dokumentasi GitHub, jalurnya seperti ini.

  1. Buka repository di GitHub, lalu klik Settings.
  2. Di bagian Security pada sidebar, klik Secrets and variables, lalu Actions.
  3. Di tab Secrets klik New repository secret, isi Name dan Secret, lalu Add secret. Ulangi untuk tiga secret di tabel.
  4. Pindah ke tab Variables dan buat satu variable, DEPLOY_PATH. Ia bukan rahasia, jadi cukup berupa variable.
NamaIsiJenis
VPS_SSH_HOSTAlamat IP atau hostname VPS, misalnya 203.0.113.10Secret
VPS_SSH_USERNama user di VPS, yaitu deploySecret
VPS_SSH_KEYIsi lengkap file deploy_key (private key), termasuk baris BEGIN dan END. Jangan tempel file .pub.Secret
DEPLOY_PATHFolder di server tempat compose file berada, misalnya /home/deploy/appVariable

Setelah key tersimpan di GitHub, hapus file deploy_key dari laptop atau simpan di password manager. Key privat tidak boleh masuk ke repo, ke chat, atau ke screenshot.

Langkah 6: Tulis workflow GitHub Actions

Buat file .github/workflows/deploy.yml. Workflow ini punya tiga job yang dirangkai: verify memeriksa commit, build-and-push membuat image dan mendorongnya ke GHCR, lalu deploy masuk ke VPS. Ini versi ringkas dari deploy.yml situs ini.

# .github/workflows/deploy.yml
name: Deploy to VPS

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  verify:
    runs-on: ubuntu-latest
    concurrency:
      group: build-${{ github.ref }}
      cancel-in-progress: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npm run lint

  build-and-push:
    needs: verify
    runs-on: ubuntu-latest
    concurrency:
      group: build-${{ github.ref }}
      cancel-in-progress: true
    permissions:
      contents: read
      packages: write
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@v4

      # Registry names must be lowercase; your GitHub name may not be.
      - name: Compute lowercase image ref
        id: meta
        run: |
          ref="$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
          echo "ref=${ref}" >> "$GITHUB_OUTPUT"
          echo "image=${ref}:${{ github.sha }}" >> "$GITHUB_OUTPUT"

      - uses: docker/setup-buildx-action@v3

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

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ steps.meta.outputs.ref }}:latest
            ${{ steps.meta.outputs.ref }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    # A deploy must never be cancelled halfway through its SSH session.
    concurrency:
      group: deploy-vps
      cancel-in-progress: false
    permissions:
      contents: read
      packages: read
    steps:
      - uses: actions/checkout@v4

      - name: Copy compose file to VPS
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.VPS_SSH_HOST }}
          username: ${{ secrets.VPS_SSH_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          source: docker-compose.yml
          target: ${{ vars.DEPLOY_PATH }}

      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_SSH_HOST }}
          username: ${{ secrets.VPS_SSH_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            set -euo pipefail
            cd "${{ vars.DEPLOY_PATH }}"

            # Quoted heredoc: the shell expands nothing inside the values.
            # Add your app secrets here as more KEY=value lines.
            cat > .env <<'ENVEOF'
            IMAGE=${{ needs.build-and-push.outputs.image }}
            ENVEOF
            chmod 600 .env

            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
            docker compose pull
            # --wait: fail the job if the container never becomes healthy
            docker compose up -d --wait --remove-orphans
            docker logout ghcr.io

Tiga hal di sini sering membuat pemula tersandung. Pertama, needs membuat commit yang gagal verify tidak pernah sampai ke server. Kedua, image diberi tag SHA commit, jadi server menjalankan build yang persis lolos tadi, bukan latest yang bisa berpindah. Ketiga, token GITHUB_TOKEN dibuat GitHub untuk tiap job, jadi tidak ada password registry yang perlu kamu simpan. Menurut dokumentasi GitHub, package yang baru dipublikasikan berstatus private secara default, itu sebabnya server perlu docker login sebelum pull.

Situs ini sendiri memakai docker compose up -d tanpa --wait, jadi job bisa hijau padahal health check belum lolos. Menurut dokumentasi Docker, --wait menunggu service berstatus running atau healthy dan otomatis berarti detached mode. Saya menaruhnya di versi di atas karena itu yang saya anggap lebih aman untuk pemula.

Build terasa lambat? Strategi cache di Docker build cache untuk deploy yang lebih cepat bisa dipasang setelah pipeline dasar ini jalan.

Langkah 7: Push, pantau, dan siapkan rollback

Commit semua file lalu push ke main. Buka tab Actions di GitHub dan tunggu ketiga job hijau. Setelah itu cek dari sisi server.

# On the VPS, as the deploy user
cd /home/deploy/app
docker compose ps          # STATUS should read "healthy" once start_period passes
docker compose logs --tail 50 web
curl -I http://127.0.0.1:3000/

Yang akan kamu lihat: STATUS container berubah dari starting menjadi healthy setelah start_period lewat, dan curl mengembalikan respons HTTP dari aplikasimu. Kalau deploy baru bermasalah, kamu tidak perlu membangun ulang apa pun: karena setiap image punya tag SHA, rollback hanya berarti menunjuk IMAGE ke tag lama.

# Roll back: point IMAGE at an older commit tag and recreate the container
cd /home/deploy/app
docker image ls ghcr.io/your-github-name/your-repo     # find the older SHA tag
sed -i 's|^IMAGE=.*|IMAGE=ghcr.io/your-github-name/your-repo:OLD_SHA|' .env
docker compose up -d --wait

Container dibuat ulang saat deploy, jadi ada jeda singkat. Kalau jeda itu tidak bisa diterima, pelajari zero-downtime rolling deploy sebagai langkah berikutnya.

Error umum dan cara memperbaikinya

Ini yang paling sering terjadi saat pertama kali. Cocokkan gejala yang kamu lihat di log dengan kolom pertama.

GejalaPenyebabPerbaikan
Permission denied (publickey) saat deploy atau saat ssh manualPublic key belum ada di authorized_keys user deploy, atau secret VPS_SSH_KEY berisi file yang salah.Ulangi ssh-copy-id, tes dengan ssh -i, lalu tempel ulang private key lengkap (BEGIN sampai END) ke secret.
invalid reference format, repository name must be lowercaseNama owner atau repository di GitHub memakai huruf besar, sedangkan nama image di registry wajib huruf kecil.Ubah ke huruf kecil dengan tr seperti di step Compute lowercase image ref, jangan ketik nama image manual.
pull access denied atau unauthorized saat docker compose pullPackage di GHCR berstatus private dan server belum login ke ghcr.io.Pastikan baris docker login ghcr.io dengan --password-stdin berjalan sebelum pull, dan job deploy punya permission packages: read.
permission denied untuk docker.sock di serverUser deploy belum masuk grup docker, atau sesi belum dimuat ulang.Jalankan sudo usermod -aG docker deploy, lalu keluar dan masuk lagi (atau newgrp docker).
Halaman tampil tanpa gaya dan banyak 404 di /_next/staticFolder public dan .next/static tidak disalin ke image runner.Tambahkan dua baris COPY ke stage runner di Dockerfile, lalu build ulang image.
Container jalan di server tetapi tidak bisa dibuka dari internetPort sengaja dibuka di 127.0.0.1 saja, dan belum ada reverse proxy yang meneruskan ke port itu.Pasang nginx yang proxy ke 127.0.0.1:3000. Jangan ganti ke 3000:3000 hanya supaya cepat terbuka, karena itu melewati proxy.
Job deploy hijau, tetapi situs masih versi lama atau errordocker compose up -d selesai begitu container menyala, sebelum health check lolos, sehingga rilis rusak tetap terlihat sukses.Pakai up -d --wait supaya job gagal kalau container tidak healthy, lalu lihat docker compose logs web.
no space left on device saat build atau pullDisk penuh karena setiap deploy menarik image baru dengan tag SHA yang berbeda dan yang lama menumpuk.Jalankan docker image prune -af --filter until=72h di server. Situs ini menaruh perintah itu di akhir script deploy.

Checklist sebelum push pertama

  • next.config.ts memakai output standalone, dan node .next/standalone/server.js jalan di laptop.
  • Dockerfile menyalin public dan .next/static, dan docker run menjawab di port 3000.
  • ssh -i deploy_key deploy@alamat-vps masuk tanpa password.
  • Tiga secret dan satu variable sudah terisi, dan file deploy_key sudah dihapus dari laptop.
  • Port di compose file terikat ke 127.0.0.1, dan reverse proxy sudah siap.
  • Kamu tahu cara rollback: ganti IMAGE di .env ke tag SHA lama lalu docker compose up -d --wait.

Belajar lebih dalam

Panduan ini menjawab bagaimana urutannya. Kalau kamu ingin paham kenapa tiap bagian dibuat begitu, dua pelajaran gratis ini membahasnya dengan diagram interaktif dan kuis singkat.

Aturan yang bisa kamu bawa: build di CI, kirim image, dan biarkan server hanya menarik dan menjalankan. Satu commit menjadi satu image bertag SHA, dan itu membuat deploy bisa diulang serta di-rollback.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Kenapa container Docker Next.js jalan tetapi halamannya tanpa CSS dan gambar?

Build standalone tidak menyertakan folder public dan .next/static secara default, jadi server.js tidak punya file untuk dilayani. Tambahkan dua baris COPY untuk folder itu di stage runner pada Dockerfile lalu build ulang image. Setelah itu minta satu file di /_next/static langsung untuk memastikan tidak lagi 404.

Apakah saya perlu menginstal Node.js di VPS untuk deploy Next.js dengan Docker?

Tidak perlu. Runtime Node.js sudah ada di dalam Docker image, jadi server hanya butuh Docker dan Docker Compose. Itu juga menghilangkan masalah versi Node yang berbeda antara laptop dan server.

Kenapa GitHub Actions error Permission denied (publickey) saat deploy lewat SSH?

Biasanya public key pasangannya belum ada di authorized_keys user deploy, atau secret berisi public key atau private key yang terpotong. Tes dulu key yang sama dari laptop dengan ssh -i. Lalu tempel ulang private key lengkap, dari baris BEGIN sampai END, ke secret repository.

Apakah aman menambahkan user deploy ke grup docker?

Itu sebuah tradeoff, karena dokumentasi Docker menyebut grup docker memberi hak istimewa tingkat root. Untuk user deploy khusus hal ini lazim, selama key-nya terpisah dan hanya dipegang pipeline. Jangan masukkan user login harian ke grup itu.

Bagaimana cara rollback deploy Next.js dengan GitHub Actions dan Docker?

Setiap deploy menjalankan image bertag SHA commit, jadi image lama masih ada di GHCR. Ganti baris IMAGE di .env server ke tag yang lebih lama lalu jalankan docker compose up -d --wait. Tidak ada yang perlu di-build ulang.