Logo

~/saidqb cat wiki/stack/server/deploy-react.md

MDdeploy-react

commit by saidqb

Deploy React/Vite (Static Build + Nginx)

Aplikasi React/Vite hasil build itu murni file statis (HTML/CSS/JS) — tidak butuh Node.js jalan di server, cukup web server yang bisa serve file statis. Struktur repo yang cocok: lihat project-app-build-dan-doc.md (folder project/build/ saja yang di-deploy).

Tiap project dapat Linux user sendiri (/home/<appuser>/app), bukan numpuk di satu folder /var/www bersama — kalau server ini dipakai buat banyak project, satu project ke-compromise tidak otomatis bisa baca file project lain.

Contoh command di dokumen ini pakai Ubuntu/Debian (apt). Kalau server-nya distro RHEL-based (Rocky Linux/AlmaLinux/CentOS Stream, pakai dnf), lihat catatan padanan di tiap langkah yang berbeda — hasil build React murni file statis, jadi server tidak butuh Node.js/PHP/Remi sama sekali, cuma Nginx + rsync.

0. Prasyarat sebelum deploy

  • Node.js & npm terinstall di mesin yang dipakai untuk build (local/CI — bukan server produksi, server cuma serve file statis). Pakai nvm supaya gampang ganti versi Node per proyek:
    • Install nvm: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash (lalu buka terminal baru, atau source ~/.bashrc)
    • Install & pakai Node LTS: nvm install --lts && nvm use --lts
    • Cek hasil install: node -v && npm -v
  • Nginx sudah terinstall di server: sudo apt install nginx -y (Rocky Linux/AlmaLinux: sudo dnf install nginx -y)
  • Akses SSH ke server (user dengan hak sudo) untuk setup Nginx & folder deploy — disiapkan penyedia server, bukan sesuatu yang di-install
  • Domain sudah diarahkan (DNS A record) ke IP server — diatur di panel DNS registrar, bukan di server
  • rsync terinstall di mesin build & server (biasanya sudah ada default di Linux/macOS; kalau belum: sudo apt install rsync -y, atau sudo dnf install rsync -y di Rocky Linux/AlmaLinux)

1. Buat user khusus untuk app

bash
sudo adduser --disabled-password --gecos "" reactapp

--disabled-password — user ini tidak bisa login pakai password (cuma diakses lewat sudo dari user SSH yang sudah masuk). --gecos "" skip pertanyaan nama/nomor telepon dsb yang tidak relevan buat service account. Home directory-nya otomatis jadi /home/reactapp.

bash
ssh user@server 'sudo mkdir -p /home/reactapp/app && sudo chown reactapp:reactapp /home/reactapp/app'

2. Build di local/CI

bash
npm install
npm run build      # hasilnya di folder dist/ atau build/, tergantung tool

3. Salin/deploy hasil build ke server

rsync dijalankan lewat user SSH biasa (bukan reactapp — bikin reactapp bisa login SSH sendiri cuma nambah kerumitan setup key yang tidak perlu untuk static site), lalu kepemilikan file disamakan ke reactapp sesudahnya:

bash
rsync -avz --delete dist/ user@server:/home/reactapp/app/
ssh user@server 'sudo chown -R reactapp:reactapp /home/reactapp/app'

4. Izin baca untuk Nginx

Home directory Linux defaultnya tertutup dari user lain — Nginx (jalan sebagai www-data) butuh akses baca ke folder ini biar bisa serve file statisnya:

bash
sudo chmod o+rx /home/reactapp
sudo chmod -R o+rX /home/reactapp/app

5. Nginx — serve static + SPA fallback

nginx
server {
    listen 80;
    server_name app.example.com;
    root /home/reactapp/app;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;   # penting untuk client-side routing (React Router)
    }

    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

try_files ... /index.html wajib ada kalau pakai client-side router (React Router, dsb) — tanpa ini, refresh di URL selain / (misal /about) akan 404 karena Nginx mencari file /about yang memang tidak ada.

bash
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'   # buka port 80/443 di firewall — tanpa ini situs tidak bisa diakses dari luar meski Nginx sudah jalan benar, kalau ufw aktif (default di banyak VPS)

Rocky Linux/AlmaLinux: tidak ada sites-available/sites-enabled bawaan — taruh langsung file konfigurasinya di /etc/nginx/conf.d/app.conf (tanpa symlink). Firewall default-nya firewalld, bukan ufw:

bash
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload

SELinux aktif default di RHEL-based — kalau Nginx gagal serve file padahal chmod/chown di Langkah 4 sudah benar, biasanya SELinux context yang salah, bukan permission Linux biasa: sudo restorecon -Rv /home/reactapp/app.

6. Urutan tiap kali deploy update

bash
npm run build
rsync -avz --delete dist/ user@server:/home/reactapp/app/
ssh user@server 'sudo chown -R reactapp:reactapp /home/reactapp/app && sudo chmod -R o+rX /home/reactapp/app'

Tidak perlu restart service apa pun — file statis langsung kebaca Nginx begitu di-overwrite. Kalau pakai cache-busting hash di nama file build (default Vite), tidak perlu khawatir user dapat file lama ter-cache browser.

Opsional: HTTPS

bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d app.example.com

Rocky Linux/AlmaLinux: paket certbot-nya dari EPEL (sudo dnf install epel-release -y dulu kalau belum ada):

bash
sudo dnf install certbot python3-certbot-nginx -y
sudo certbot --nginx -d app.example.com

Konfigurasi SSL-nya tidak perlu ditulis manual — plugin --nginx otomatis suntik langsung ke server block yang sudah dibuat di Langkah 5 (listen 443 ssl, path sertifikat, redirect HTTP→HTTPS), dan pasang auto-renewal sendiri (sertifikat Let's Encrypt berlaku 90 hari, diperpanjang otomatis sebelum expired). Hasilnya kira-kira begini:

nginx
server {
    listen 80;
    server_name app.example.com;
    root /home/reactapp/app;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparam.pem; # managed by Certbot
}

server {
    if ($host = app.example.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot

    listen 80;
    server_name app.example.com;
    return 404; # managed by Certbot
}

Kalau nanti edit config ini lagi, jangan hapus baris # managed by Certbot — dan tetap sudo nginx -t && sudo systemctl reload nginx tiap habis edit.

Kalau domain dipasang di belakang Cloudflare (proxy oranye), lihat catatan cloudflare.md — ada penyesuaian mode SSL/TLS & opsi sertifikat yang perlu diperhatikan.

Opsional: batasi hasil build hanya jalan di domain tertentu

Static build (HTML/CSS/JS) berjalan penuh di browser klien — server cuma kirim file mentah, jadi tidak ada cara di sisi server untuk memverifikasi "domain yang sah". Siapa pun bisa rsync/download folder build lalu deploy ulang ke domain lain, dan proteksi apa pun di JS tetap bisa dibaca & dihapus lewat DevTools oleh orang yang niat teknis. Jadi ini bukan proteksi anti-bypass — cuma bikin hasil clone tampil blank secara default untuk pengunjung biasa.

Pendekatannya: index.html cuma sembunyikan tampilan (tanpa expose domain/logika), lalu bundle JS (sudah ter-minify) yang menentukan apakah dikembalikan terlihat.

index.html, taruh di <head> paling atas — dieksekusi sinkron sebelum browser sempat render body:

html
<script>document.documentElement.style.visibility = 'hidden'</script>

Entry point JS (main.jsx/main.js), sebelum render app:

js
const ALLOWED_HOST = 'app.example.com' // domain produksi yang sah

const isLocalDev =
  (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') &&
  window.location.port === '5173' // sesuaikan port dev server

if (window.location.hostname !== ALLOWED_HOST && !isLocalDev) {
  document.body.innerHTML = ''
} else {
  document.documentElement.style.visibility = 'visible'
  // render seperti biasa
}

Kalau proyeknya prerender tiap route jadi file statis (SSG/SSR-lite via renderToString, dst), pastikan index.html yang dipakai sebagai template prerender sudah versi ini — supaya proteksi ikut ter-bake ke semua dist/<route>/index.html, bukan cuma di /.

Set port dev secara eksplisit di config (mis. server.port + strictPort: true di Vite) kalau dipakai untuk exception isLocalDev — port default bisa auto-geser saat bentrok, dan bikin exception di atas jadi salah tanpa disadari.

Trade-off: kalau JS gagal load (network error, JS disabled di browser), halaman stuck hidden selamanya alih-alih tampil normal — cocok untuk SPA yang memang sudah bergantung penuh ke JS, kurang cocok kalau situsnya perlu tetap terbaca tanpa JS.