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?
- Deploy Docker ke VPS dengan GitHub Actions dan GHCR menjelaskan kenapa job dirangkai dengan needs, kapan build boleh dibatalkan, dan bagaimana secret aman.
- Self-Host Next.js: Output Standalone, Docker, dan Env Var menjelaskan isi image runner, env var saat build vs runtime, dan health check.
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.jsYang 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: 20sSitus 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.
- Buka repository di GitHub, lalu klik Settings.
- Di bagian Security pada sidebar, klik Secrets and variables, lalu Actions.
- Di tab Secrets klik New repository secret, isi Name dan Secret, lalu Add secret. Ulangi untuk tiga secret di tabel.
- Pindah ke tab Variables dan buat satu variable, DEPLOY_PATH. Ia bukan rahasia, jadi cukup berupa variable.
| Nama | Isi | Jenis |
|---|---|---|
| VPS_SSH_HOST | Alamat IP atau hostname VPS, misalnya 203.0.113.10 | Secret |
| VPS_SSH_USER | Nama user di VPS, yaitu deploy | Secret |
| VPS_SSH_KEY | Isi lengkap file deploy_key (private key), termasuk baris BEGIN dan END. Jangan tempel file .pub. | Secret |
| DEPLOY_PATH | Folder di server tempat compose file berada, misalnya /home/deploy/app | Variable |
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.ioTiga 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 --waitContainer 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.
| Gejala | Penyebab | Perbaikan |
|---|---|---|
| Permission denied (publickey) saat deploy atau saat ssh manual | Public 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 lowercase | Nama 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 pull | Package 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 server | User 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/static | Folder 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 internet | Port 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 error | docker 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 pull | Disk 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.
- Pelajaran: deploy Docker ke VPS dengan GitHub Actions dan GHCR
- Pelajaran: self-host Next.js dengan output standalone dan Docker
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.
Sumber dan referensi
- Dokumentasi Next.js: output (standalone dan file tracing)
- Dokumentasi Docker: docker compose up (--wait, --remove-orphans)
- Dokumentasi GitHub: bekerja dengan Container registry
- Dokumentasi GitHub: memakai secret di GitHub Actions
- Dokumentasi Docker: langkah pasca-instalasi Linux (grup docker)
- README appleboy/ssh-action (input yang tersedia)

