Pada bagian ini, fokus kita adalah membawa aplikasi Laravel berbasis Docker dari lingkungan lokal ke Virtual Private Server (VPS) agar bisa diakses publik melalui domain, dilindungi SSL, dan tetap mudah dioperasikan. Target akhirnya bukan sekadar aplikasi bisa berjalan, tetapi memiliki struktur deployment yang rapi, aman secara dasar, dan siap dikembangkan ke tahap hardening, monitoring, serta maintenance.

Artikel ini mengasumsikan Anda sudah memiliki project Laravel yang sudah dikontainerisasi. Jika sebelumnya Anda menjalankan aplikasi hanya di lokal, maka sekarang kita akan menyiapkan server Linux, user non-root, firewall, Docker, Docker Compose, Nginx sebagai reverse proxy, serta sertifikat SSL dari Let's Encrypt. Selain itu, kita juga akan membahas persistence data penting seperti database dan strategi update dengan minimal downtime.

1. Arsitektur deployment yang akan digunakan

Arsitektur sederhananya adalah sebagai berikut:

  • Nginx di host VPS menerima request dari internet pada port 80 dan 443.
  • Nginx meneruskan request ke container aplikasi Laravel yang berjalan di jaringan Docker internal.
  • Laravel dapat terhubung ke container lain seperti MySQL/MariaDB, Redis, queue worker, dan service scheduler.
  • Data database dan file penting disimpan pada Docker volume agar tidak hilang saat container di-recreate.

Pendekatan ini dipilih karena praktis untuk VPS tunggal. Anda bisa memulai dari satu server tanpa langsung masuk ke orchestration seperti Kubernetes. Trade-off-nya, manajemen skala dan high availability masih terbatas, tetapi untuk banyak aplikasi internal, MVP, atau produk tahap awal, pendekatan ini sudah sangat memadai.

2. Persiapan server Linux

2.1 Buat user non-root

Jangan gunakan user root untuk aktivitas harian deployment. Buat user baru, beri akses sudo, lalu gunakan SSH key.

adduser deploy
usermod -aG sudo deploy
mkdir -p /home/deploy/.ssh
nano /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

Setelah public key dimasukkan ke authorized_keys, coba login:

ssh deploy@IP_VPS

Kenapa ini penting? Karena pemisahan user mengurangi risiko kesalahan fatal, misalnya menghapus file sistem saat deployment atau menjalankan proses sebagai root tanpa perlu.

2.2 Update sistem dan pasang paket dasar

sudo apt update && sudo apt upgrade -y
sudo apt install -y curl git ufw ca-certificates gnupg lsb-release nginx certbot python3-certbot-nginx

Paket di atas cukup umum untuk server Debian/Ubuntu. Jika Anda memakai distro lain, sesuaikan manajer paketnya.

2.3 Konfigurasi firewall

Minimal buka port SSH, HTTP, dan HTTPS.

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

Jika Anda memakai port SSH non-standar, pastikan port tersebut dibuka sebelum mengaktifkan firewall agar tidak terkunci dari server sendiri.

3. Instalasi Docker dan Docker Compose

Gunakan repositori resmi Docker agar versi dan dependensinya konsisten.

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Tambahkan user deploy ke grup docker agar tidak perlu selalu memakai sudo:

sudo usermod -aG docker deploy

Logout lalu login kembali agar perubahan grup aktif.

Verifikasi:

docker --version
docker compose version

4. Menyusun direktori deployment

Struktur direktori yang rapi memudahkan backup, rollback, dan troubleshooting. Salah satu pola yang cukup aman:

/var/www/myapp/
├── current/
├── releases/
│   └── 20260325-120000/
├── shared/
│   ├── .env
│   ├── storage/
│   └── logs/
└── docker/
    ├── compose.prod.yml
    └── Dockerfile

Penjelasannya:

  • releases/: hasil kode per deployment.
  • current/: symlink ke release aktif.
  • shared/: file yang harus persisten antar release, misalnya .env dan storage.
  • docker/: konfigurasi Docker production.

Buat direktori awal:

sudo mkdir -p /var/www/myapp/{releases,shared,docker}
sudo chown -R deploy:deploy /var/www/myapp

5. Mengirim source code ke server

Ada beberapa pendekatan umum:

  • git pull di server: paling sederhana.
  • rsync/scp: cocok untuk server kecil atau pipeline manual.
  • CI/CD: paling rapi untuk tim, misalnya GitHub Actions atau GitLab CI.

Untuk seri ini, kita gunakan pendekatan yang mudah dipahami: clone repository ke folder release.

cd /var/www/myapp/releases
git clone https://github.com/username/myapp.git 20260325-120000
ln -sfn /var/www/myapp/releases/20260325-120000 /var/www/myapp/current

Jika repository private, gunakan deploy key atau token dengan akses minimum. Hindari menaruh kredensial Git sembarangan di shell history atau file world-readable.

6. Menyiapkan Dockerfile dan Compose production

6.1 Contoh Dockerfile production

Dockerfile sebaiknya fokus pada image runtime yang ramping dan tidak membawa dependency development yang tidak diperlukan.

FROM php:8.2-fpm-alpine

RUN apk add --no-cache \
    bash curl git unzip libpng-dev libjpeg-turbo-dev freetype-dev \
    icu-dev oniguruma-dev libzip-dev $PHPIZE_DEPS

RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install pdo pdo_mysql mbstring intl gd zip opcache

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader --no-interaction
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache

CMD ["php-fpm"]

Jika aplikasi Anda menggunakan ekstensi lain seperti Redis, Swoole, atau PostgreSQL, tambahkan sesuai kebutuhan. Jangan menginstal paket berlebihan karena akan memperbesar image dan permukaan serangan.

6.2 Contoh docker compose production

services:
  app:
    build:
      context: ../current
      dockerfile: ../docker/Dockerfile
    container_name: myapp-app
    restart: unless-stopped
    env_file:
      - ../shared/.env
    volumes:
      - ../shared/storage:/var/www/html/storage
    expose:
      - "9000"
    depends_on:
      - db
      - redis

  web:
    image: nginx:alpine
    container_name: myapp-web
    restart: unless-stopped
    volumes:
      - ../current:/var/www/html:ro
      - ../shared/storage:/var/www/html/storage
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "127.0.0.1:8080:80"
    depends_on:
      - app

  db:
    image: mysql:8
    container_name: myapp-db
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: myapp
      MYSQL_USER: myapp
      MYSQL_PASSWORD: strongpassword
      MYSQL_ROOT_PASSWORD: rootpassword
    volumes:
      - myapp_db_data:/var/lib/mysql

  redis:
    image: redis:7-alpine
    container_name: myapp-redis
    restart: unless-stopped
    volumes:
      - myapp_redis_data:/data

  queue:
    build:
      context: ../current
      dockerfile: ../docker/Dockerfile
    container_name: myapp-queue
    restart: unless-stopped
    env_file:
      - ../shared/.env
    volumes:
      - ../shared/storage:/var/www/html/storage
    command: php artisan queue:work --sleep=3 --tries=3 --timeout=90
    depends_on:
      - app
      - redis
      - db

  scheduler:
    build:
      context: ../current
      dockerfile: ../docker/Dockerfile
    container_name: myapp-scheduler
    restart: unless-stopped
    env_file:
      - ../shared/.env
    volumes:
      - ../shared/storage:/var/www/html/storage
    command: sh -c "while true; do php artisan schedule:run --verbose --no-interaction; sleep 60; done"
    depends_on:
      - app
      - redis
      - db

volumes:
  myapp_db_data:
  myapp_redis_data:

Kenapa queue dan scheduler dibuat sebagai container terpisah? Karena tanggung jawab proses production sebaiknya dipisah. Web request, worker queue, dan scheduler memiliki karakteristik beban yang berbeda. Pemisahan ini memudahkan restart selektif dan observasi log.

7. Konfigurasi .env production

Buat file /var/www/myapp/shared/.env dan isi dengan konfigurasi production. Jangan salin mentah dari lokal tanpa audit.

APP_NAME=MyApp
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com

LOG_CHANNEL=stack
LOG_LEVEL=info

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=myapp
DB_PASSWORD=strongpassword

CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=redis
REDIS_PORT=6379

Beberapa catatan penting:

  • APP_DEBUG=false wajib di production.
  • APP_URL harus sesuai domain final.
  • Host database dan Redis menggunakan nama service Docker, bukan 127.0.0.1.
  • Simpan secret seperti APP_KEY, kredensial mail, dan API key secara aman.

Jika belum ada APP_KEY, generate sekali dari container:

docker compose -f /var/www/myapp/docker/compose.prod.yml run --rm app php artisan key:generate

8. Menjalankan build, migration, dan optimasi Laravel

Setelah semua siap:

cd /var/www/myapp/docker
docker compose -f compose.prod.yml build
docker compose -f compose.prod.yml up -d

Jalankan migration setelah database hidup:

docker compose -f compose.prod.yml exec app php artisan migrate --force

Tambahkan optimasi umum:

docker compose -f compose.prod.yml exec app php artisan config:cache
docker compose -f compose.prod.yml exec app php artisan route:cache
docker compose -f compose.prod.yml exec app php artisan view:cache

Kenapa memakai --force saat migration? Laravel meminta flag ini di production agar perubahan schema tidak dieksekusi tanpa sengaja.

Sebelum migration besar yang berisiko, selalu backup database. Jangan mengandalkan rollback migration sebagai satu-satunya strategi pemulihan.

9. Reverse proxy Nginx di host VPS

Walaupun ada container Nginx internal untuk melayani aplikasi, kita tetap menggunakan Nginx di host sebagai pintu masuk utama dari internet. Keuntungannya, pengelolaan domain, SSL, dan reverse proxy lebih sederhana.

Buat file konfigurasi host Nginx, misalnya /etc/nginx/sites-available/myapp:

server {
    listen 80;
    server_name example.com www.example.com;

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

Aktifkan konfigurasi:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Pastikan DNS domain Anda sudah mengarah ke IP VPS sebelum lanjut ke SSL.

10. Menghubungkan domain dan memasang SSL Let's Encrypt

Di panel DNS penyedia domain, buat record:

  • A record untuk example.com ke IP VPS
  • A record untuk www.example.com ke IP VPS

Setelah propagasi DNS cukup, pasang sertifikat:

sudo certbot --nginx -d example.com -d www.example.com

Certbot akan memperbarui konfigurasi Nginx secara otomatis untuk HTTPS dan redirect jika Anda menyetujuinya. Verifikasi auto-renew:

sudo systemctl status certbot.timer
sudo certbot renew --dry-run

Kesalahan umum pada tahap ini biasanya:

  • DNS belum mengarah ke VPS.
  • Port 80 atau 443 tertutup firewall.
  • Nginx salah konfigurasi sehingga challenge Let's Encrypt gagal.

11. Persistence database dan volume penting

Salah satu kesalahan paling fatal dalam deployment container adalah menyimpan data penting hanya di layer filesystem container. Saat container dihapus atau di-recreate, data ikut hilang. Karena itu, database dan data runtime tertentu harus memakai volume persisten.

Pada contoh Compose di atas:

  • myapp_db_data menyimpan data MySQL.
  • myapp_redis_data menyimpan data Redis jika persistence diaktifkan.
  • ../shared/storage menyimpan file Laravel seperti log, cache file tertentu, dan upload jika aplikasi Anda menyimpannya lokal.

Jika aplikasi menerima upload pengguna dalam jumlah penting, pertimbangkan memakai object storage seperti S3-compatible storage daripada disk lokal VPS. Disk lokal lebih sederhana, tetapi backup dan migrasinya lebih merepotkan.

12. Strategi update dengan zero/minimal downtime sederhana

Untuk VPS tunggal, zero downtime sempurna tidak selalu realistis, tetapi Anda tetap bisa menekan gangguan layanan menjadi sangat kecil.

12.1 Pola release baru + rebuild

  1. Clone kode ke folder release baru.
  2. Ubah symlink current ke release baru.
  3. Build image baru.
  4. Jalankan container baru atau recreate service terkait.
  5. Jalankan migration yang kompatibel dengan versi lama dan baru.

Kunci utamanya adalah membuat perubahan schema database yang bersifat backward compatible saat rolling update sederhana. Misalnya, menambah kolom baru lebih aman daripada langsung menghapus kolom lama yang masih dipakai versi aplikasi sebelumnya.

12.2 Perintah update sederhana

cd /var/www/myapp/releases
git clone https://github.com/username/myapp.git 20260326-090000
ln -sfn /var/www/myapp/releases/20260326-090000 /var/www/myapp/current
cd /var/www/myapp/docker
docker compose -f compose.prod.yml build app queue scheduler web
docker compose -f compose.prod.yml up -d --no-deps app web queue scheduler
docker compose -f compose.prod.yml exec app php artisan migrate --force

Pendekatan ini biasanya menimbulkan downtime sangat singkat atau bahkan nyaris tidak terasa, tergantung waktu start container dan karakter aplikasi. Jika Anda ingin lebih halus, Anda bisa menambahkan health check dan hanya mengalihkan traffic setelah service sehat.

12.3 Rollback sederhana

Jika deployment bermasalah:

  1. Arahkan kembali symlink current ke release sebelumnya.
  2. Rebuild/restart service memakai release lama.
  3. Pastikan migration yang sudah terlanjur jalan tidak membuat versi lama gagal.

Di sinilah pentingnya desain migration yang aman. Rollback kode jauh lebih mudah daripada rollback data.

13. Debugging dan kesalahan yang sering terjadi

  • 502 Bad Gateway dari Nginx host: cek apakah container web berjalan dan port 127.0.0.1:8080 benar-benar terbuka.
  • Laravel tidak bisa konek database: pastikan DB_HOST=db, bukan localhost.
  • Permission storage/bootstrap cache: pastikan owner dan permission sesuai user runtime.
  • Queue tidak jalan: cek container worker dan koneksi Redis.
  • SSL gagal: cek DNS, firewall, dan validasi konfigurasi Nginx dengan nginx -t.

Perintah yang sering dipakai untuk troubleshooting:

docker compose -f compose.prod.yml ps
docker compose -f compose.prod.yml logs -f app
docker compose -f compose.prod.yml logs -f web
docker compose -f compose.prod.yml logs -f queue
sudo journalctl -u nginx -f

14. Penutup

Sampai tahap ini, Anda sudah memiliki fondasi deployment Laravel Docker ke VPS yang cukup solid: server Linux dengan user non-root, firewall aktif, Docker dan Compose terpasang, struktur release yang rapi, Nginx sebagai reverse proxy, domain dengan SSL Let's Encrypt, service queue dan scheduler terpisah, serta persistence untuk database dan data penting.

Struktur ini belum bisa disebut final untuk production skala besar, tetapi sudah jauh lebih baik daripada menjalankan semua hal secara manual tanpa isolasi, tanpa SSL, dan tanpa persistence. Pada tahap berikutnya, Anda akan lebih siap membahas hardening, backup, log rotation, monitoring, fail2ban, pembatasan akses internal, strategi secret management, dan routine maintenance agar deployment tetap stabil dalam jangka panjang.