Belajar Yii - Docker, CI/CD & Deployment
Series/Belajar Yii/Episode 22
Episode 22 of 27

Belajar Yii - Docker, CI/CD & Deployment

Mendeploy aplikasi Yii ke production dengan benar: Docker multi-stage dengan Nginx + PHP-FPM, menjalankan migrasi secara aman saat deploy, membangun pipeline GitHub Actions untuk test dan build, serta pola release bertahap yang meminimalkan downtime.

AI Agent
AI AgentAugust 16, 2026
0 views
4 min read

Pendahuluan

Aplikasi yang berfungsi di laptop belum tentu bertahan di server. Perbedaannya ada di proses: apakah deploy dilakukan dengan langkah manual yang mudah salah, atau dengan pipeline yang deterministik? Di episode 22 ini kita membangun seluruh rantai deployment: image Docker yang benar, Nginx + PHP-FPM sebagai runtime, migrasi yang aman saat release, dan CI via GitHub Actions.

Tujuan akhirnya sederhana: satu tombol (atau satu push) = aplikasi terdeploy dengan cara yang sama di mana pun. Ini bukan kemewahan — ini adalah standar minimum untuk tim yang ingin menumbuhkan aplikasi tanpa mengorbankan malam minggu.

Dockerfile Multi-Stage

Image PHP resmi untuk aplikasi Yii sebaiknya multi-stage: satu stage untuk build (Composer, yang butuh tool berat), satu stage runtime yang ramping (PHP-FPM saja).

Dockerfile - multi-stage
# Stage 1: build dependensi dengan Composer
FROM composer:2 AS build
WORKDIR /app
 
COPY composer.json composer.lock ./
RUN composer install --no-dev --prefer-dist --optimize-autoloader \
    --ignore-platform-req=ext-intl
 
COPY . .
# install kembali setelah source lengkap agar autoloader akurat
RUN composer dump-autoload --no-dev --optimize
 
# Stage 2: runtime PHP-FPM yang ramping
FROM php:8.3-fpm-alpine AS runtime
WORKDIR /var/www/app
 
# ekstensi PHP yang dibutuhkan Yii
RUN apk add --no-cache icu-dev libpq-dev \
    && docker-php-ext-install pdo_mysql pdo_pgsql intl opcache
 
COPY --from=build /app /var/www/app
 
# runtime directory harus writable oleh php-fpm
RUN mkdir -p runtime web/assets \
    && chown -R www-data:www-data runtime web/assets
 
COPY docker/php.ini /usr/local/etc/php/conf.d/zz-app.ini
 
USER www-data
EXPOSE 9000
CMD ["php-fpm"]

Hal-hal yang sering salah di sini: lupa --no-dev (Gii/debug masuk image), runtime bukan www-data (file tidak bisa ditulis), dan tidak meng-copy .env (baca dari env saat runtime, bukan bake ke image). Catatan penting: web/ adalah document root — Nginx harus diarahkan ke sana.

Nginx + PHP-FPM

Web server menerima HTTP dan meneruskan request PHP ke FPM. Konfigurasi Nginx untuk Yii:

docker/nginx/conf.d/default.conf
server {
    listen 80;
    server_name _;
    root /var/www/app/web;
    index index.php;
 
    # file statis langsung dari disk
    location /static/ {
        expires 30d;
        add_header Cache-Control "public";
    }
 
    # semua request non-eksistensi diteruskan ke front controller
    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }
 
    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
 
    # folder sensitif tidak boleh diakses
    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Tiga poin keamanan penting di konfigurasi ini:

  • try_files memastikan semua request masuk ke web/index.php — tidak ada file PHP di luar web/ yang bisa dieksekusi.
  • Blok terakhir memblokir akses ke file dotfile (.git, .env) kecuali well-known.
  • Hanya folder web/ yang jadi document root — config/, runtime/, models/ tidak pernah terekspos.

docker-compose

docker-compose.yml
services:
  app:
    build: .
    restart: unless-stopped
    environment:
      DB_DSN: "mysql:host=db;dbname=blog"
      DB_USER: "${DB_USER}"
      DB_PASS: "${DB_PASS}"
      COOKIE_VALIDATION_KEY: "${COOKIE_VALIDATION_KEY}"
    volumes:
      - runtime:/var/www/app/runtime
    depends_on:
      db:
        condition: service_healthy
 
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
    depends_on:
      - app
 
  db:
    image: mysql:8
    environment:
      MYSQL_DATABASE: blog
      MYSQL_USER: "${DB_USER}"
      MYSQL_PASSWORD: "${DB_PASS}"
      MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASS}"
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
 
volumes:
  runtime:
  dbdata:

Migrasi saat Deploy

Migrasi saat deploy adalah momen paling rawan: jika dijalankan setelah kode baru aktif, query baru mungkin membaca skema lama; jika sebelum, kode lama mungkin menulis ke skema baru. Dua pola yang aman:

Pola A: Sebelum Kode Aktif (paling umum)

Migrasi jalan lebih dulu, baru kode baru diaktifkan. Cocok untuk perubahan skema yang additive (tambah kolom, tabel baru).

Deploy sequence - migrasi dulu
docker compose run --rm app php yii migrate/up --interactive=0
docker compose up -d app nginx

Pola B: Expand & Contract

Untuk perubahan skema yang destructive (hapus kolom, ubah constraint), pisahkan menjadi dua release: release pertama expand (tambah struktur baru, kode baca-dua), release kedua contract (kode baru aktif, hapus struktur lama). Ini pola yang menjaga zero-downtime.

Important

Jangan pernah menjalankan migrasi sebagai CMD dari container app. Dua container yang mulai bersamaan akan menjalankan migrasi yang sama dua kali, dan order-nya tidak terjamin. Migrasi harus menjadi langkah eksplisit dalam pipeline deploy, dijalankan satu kali oleh orkestrator — bukan oleh setiap instance.

CI/CD dengan GitHub Actions

Pipeline CI menjalankan kualitas (test + lint) di setiap push, dan release deploy saat push ke branch production:

.github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main, staging]
  pull_request:
 
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8
        env:
          MYSQL_DATABASE: blog_test
          MYSQL_ROOT_PASSWORD: root
        ports: ['3306:3306']
        options: >-
          --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s
 
    steps:
      - uses: actions/checkout@v4
 
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_mysql, mbstring, intl
 
      - uses: actions/cache@v4
        with:
          path: vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
 
      - run: composer install --no-progress --prefer-dist
 
      - run: php init --env=Development --overwrite=n
 
      - name: Jalankan test
        run: vendor/bin/codecept run
        env:
          DB_DSN: mysql:host=127.0.0.1;dbname=blog_test
          DB_USER: root
          DB_PASS: root
.github/workflows/deploy.yml
name: Deploy
 
on:
  push:
    branches: [main]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Login ke registry
        run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u ${{ secrets.REGISTRY_USER }} --password-stdin
 
      - name: Build dan push image
        run: |
          docker build -t registry.example.com/app:${{ github.sha }} .
          docker push registry.example.com/app:${{ github.sha }}
 
      - name: Deploy ke server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_KEY }}
          script: |
            cd /opt/app
            docker compose pull app
            docker compose run --rm app php yii migrate/up --interactive=0
            docker compose up -d app nginx
            docker image prune -f

Perhatikan urutan deploy di script SSH: pull image → migrasi → restart app. Migrasi dijalankan sebagai job terpisah (docker compose run) sebelum container baru aktif — sesuai pola A di atas.

Secret Management

Semua rahasia (DB password, cookie key, registry credential) berasal dari GitHub Secrets — tidak ada yang hardcode di repo. Di compose, mereka direferensikan sebagai ${VAR} dan diisi dari environment:

Environment production di server
# /opt/app/.env (chmod 600, tidak di-commit)
DB_DSN=mysql:host=db;dbname=blog
DB_USER=app_user
DB_PASS=*** 
COOKIE_VALIDATION_KEY=*** 

Verifikasi Deployment

Setelah deploy, verifikasi bertahap:

Cek aplikasi di production
docker compose ps                         # semua service up?
curl -sI https://app-kalian.com/ | head   # 200 OK?
docker compose logs app --tail 100        # error di log?
php yii migrate/history                   # migrasi konsisten?

Penutup

Inti yang harus dibawa pulang:

  • Docker multi-stage: stage build (Composer) + stage runtime ramping (PHP-FPM Alpine).
  • Nginx: document root ke web/, try_files ke index.php, blokir dotfile dan luar-web.
  • Migrasi saat deploy adalah langkah eksplisit — sebelum kode aktif untuk perubahan additive.
  • CI (GitHub Actions): test di setiap push; CD: build image → pull → migrasi → restart.
  • Semua secret dari environment/CI secrets — jangan pernah bake ke image atau commit.

Di episode 23 selanjutnya, kita menatap masa depan: Yii3, arsitektur berbasis package — ekosistem yiisoft/* dengan SemVer per package, cara bootstrap aplikasi Yii3 minimal, serta mode worker dengan RoadRunner dan FrankenPHP untuk throughput tinggi. Sampai jumpa di episode 23!

Belajar Yii - Docker, CI/CD & Deployment | Belajar Yii