Ahmad Lazim

· 4 menit baca · #Next.js #Docker #Cloudflare

Deploy Aplikasi Next.js ke VPS dengan Docker dan Cloudflare

Panduan praktis dari Dockerfile multi-stage, docker compose dengan PostgreSQL, reverse proxy, sampai SSL Full (strict) di Cloudflare dan redeploy yang hampir tanpa downtime.

Oleh · Software Engineer

Platform seperti Vercel sangat nyaman, tetapi ada kalanya VPS sendiri lebih masuk akal: Anda butuh database di server yang sama, ingin biaya yang tetap, atau sekadar ingin memahami apa yang terjadi di balik layar. Artikel ini membahas satu susunan yang sederhana dan mudah dirawat.

Alurnya seperti ini:

Pengunjung
-> Cloudflare (DNS proxy + SSL)
-> VPS :443 (Caddy)
-> web:3000 (Next.js)
-> db:5432 (PostgreSQL)

Hanya Caddy yang membuka port ke internet. Next.js dan PostgreSQL berjalan di jaringan internal Docker.

Persiapan

  • VPS dengan Ubuntu 24.04 dan user non-root yang bisa sudo.
  • Domain yang DNS-nya sudah dikelola Cloudflare.
  • Docker Engine dan plugin compose. Cara tercepat adalah skrip resmi curl -fsSL https://get.docker.com | sh.

1. Aktifkan output standalone

Di next.config.ts:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = { output: 'standalone' }

export default nextConfig

Dengan opsi ini, next build menghasilkan folder .next/standalone berisi server.js dan hanya modul yang benar-benar dipakai. Image akhir menjadi jauh lebih kecil karena tidak perlu menyalin seluruh node_modules. Folder public dan .next/static tidak ikut otomatis, jadi kita salin sendiri.

2. Dockerfile multi-stage

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

FROM node:24-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:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -S nodejs && adduser -S -G nodejs 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"]

Beberapa hal penting:

  • Setiap stage hanya membawa yang diperlukan. Compiler dan dependensi build tertinggal di stage builder.
  • HOSTNAME=0.0.0.0 membuat server mendengarkan semua interface di dalam container. Tanpa ini, proxy tidak bisa menjangkaunya.
  • Container berjalan sebagai user nextjs, bukan root.
  • Tambahkan .dockerignore berisi node_modules, .next, .git, dan .env* agar build cepat dan rahasia tidak ikut masuk ke image.

Satu jebakan yang sering muncul: variabel NEXT_PUBLIC_* ditanam ke JavaScript saat build, bukan saat container dijalankan. Kirim nilainya lewat ARG di stage builder. Sebaliknya, variabel server seperti DATABASE_URL dibaca saat runtime, sehingga cukup diberikan di compose. Kalau ada halaman yang mengakses database saat build, jadikan halaman itu dinamis agar build tidak gagal karena database belum tersedia.

3. docker compose dengan PostgreSQL

services:
  web:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      retries: 10

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./certs:/etc/caddy/certs:ro
      - caddy_data:/data
    depends_on: [web]

volumes:
  pgdata:
  caddy_data:

Nilai ${POSTGRES_PASSWORD} diambil dari file .env di folder yang sama. Buat file itu langsung di server, jalankan chmod 600 .env, dan jangan pernah memasukkannya ke Git. Perhatikan juga bahwa web dan db sama sekali tidak memiliki ports. Mereka saling menjangkau lewat nama service (db, web) di jaringan internal compose.

4. Reverse proxy dengan Caddy

example.com, www.example.com {
  tls /etc/caddy/certs/origin.pem /etc/caddy/certs/origin-key.pem
  encode zstd gzip
  reverse_proxy web:3000 {
    lb_try_duration 30s
  }
}

Caddy menerima HTTPS, mengompresi respons, dan meneruskannya ke Next.js. Opsi lb_try_duration membuat Caddy terus mencoba menghubungi upstream selama 30 detik ketika container web sedang dimulai ulang, sehingga pengunjung melihat respons yang sedikit lambat alih-alih error 502. Nginx juga bisa dipakai dengan konsep yang sama (proxy_pass http://web:3000).

5. Cloudflare: DNS proxy dan SSL Full (strict)

  1. Di DNS, buat record A untuk @ dan www yang mengarah ke IP VPS, dengan status Proxied (awan oranye). IP server Anda kini tersembunyi di balik Cloudflare.
  2. Di SSL/TLS > Origin Server, buat Origin Certificate. Simpan sertifikat dan private key sebagai certs/origin.pem dan certs/origin-key.pem. Sertifikat ini hanya dipercaya oleh Cloudflare, dan itu sudah cukup karena pengunjung tidak pernah terhubung langsung ke server.
  3. Di SSL/TLS > Overview, pilih mode Full (strict).
  4. Di Edge Certificates, aktifkan Always Use HTTPS.

Kenapa harus strict? Mode Flexible menghubungi server Anda lewat HTTP biasa, sehingga lalu lintas antara Cloudflare dan VPS tidak terenkripsi, dan sering menimbulkan redirect loop. Mode Full memakai HTTPS tetapi tidak memvalidasi sertifikat. Hanya Full (strict) yang memastikan Cloudflare benar-benar berbicara dengan server Anda.

6. Redeploy yang hampir tanpa downtime

Simpan skrip ini sebagai deploy.sh di server:

#!/usr/bin/env bash
set -euo pipefail
cd /srv/app
git pull --ff-only
docker compose build web
docker compose up -d --no-deps web
docker image prune -f

Proses build berjalan sementara container lama masih melayani pengunjung. Setelah image baru siap, up -d --no-deps web hanya mengganti container web tanpa menyentuh database. Jeda beberapa detik saat container baru menyala ditutupi oleh lb_try_duration di Caddy. Kalau ada migrasi database, jalankan sebelum container diganti dan pastikan migrasinya kompatibel dengan versi kode yang lama.

Build di VPS kecil bisa menghabiskan RAM. Alternatifnya, build image di CI (misalnya GitHub Actions), dorong ke registry, lalu di server cukup jalankan docker compose pull dan docker compose up -d. Beri tag image dengan hash commit agar rollback semudah mengganti tag. Untuk benar-benar tanpa downtime, Anda membutuhkan dua replika atau pola blue-green.

7. Hardening dasar

SSH hanya dengan key

Buat /etc/ssh/sshd_config.d/00-hardening.conf:

PasswordAuthentication no
PermitRootLogin no

Nama berawalan 00- penting karena OpenSSH memakai nilai pertama yang ditemukan, sementara image cloud sering menyertakan file lain yang mengaktifkan login password. Uji dengan sudo sshd -t, lalu sudo systemctl restart ssh. Jangan menutup sesi lama sebelum login baru berhasil.

Firewall

Jalankan sudo ufw allow OpenSSH, sudo ufw allow 80,443/tcp, lalu sudo ufw enable. Ingat bahwa port yang dipublikasikan Docker melewati aturan UFW, sehingga satu-satunya pengaman yang pasti adalah tidak mempublikasikan port database sama sekali. Kalau ingin membatasi port 80 dan 443 hanya untuk rentang IP Cloudflare, lakukan di firewall bawaan penyedia VPS atau di chain DOCKER-USER, bukan di UFW, karena alasan yang sama.

Pembaruan dan log

Pastikan unattended-upgrades terpasang dan aktif agar patch keamanan terpasang sendiri.

Log Docker juga bisa memenuhi disk diam-diam. Atur di /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

Restart Docker setelahnya. Aturan ini berlaku untuk container yang dibuat sesudah perubahan, jadi jalankan ulang docker compose up -d --force-recreate.

Backup database

Jadwalkan dengan cron, simpan salinannya di luar server, dan sesekali uji proses restore:

docker compose exec -T db pg_dump -U app app | gzip > backup-$(date +%F).sql.gz

Penutup

Susunan ini cukup untuk banyak aplikasi kecil sampai menengah: satu docker compose up -d menghidupkan semuanya, Cloudflare menangani SSL dan menyembunyikan IP server, dan redeploy cukup satu skrip. Setelah dasarnya stabil, langkah berikutnya yang layak dipertimbangkan adalah build di CI, monitoring uptime, dan Cloudflare Tunnel agar server tidak perlu membuka port sama sekali.

Baca juga