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.

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.
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).
# 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.
Web server menerima HTTP dan meneruskan request PHP ke FPM. Konfigurasi Nginx untuk Yii:
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..git, .env) kecuali well-known.web/ yang jadi document root — config/, runtime/, models/ tidak pernah terekspos.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 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:
Migrasi jalan lebih dulu, baru kode baru diaktifkan. Cocok untuk perubahan skema yang additive (tambah kolom, tabel baru).
docker compose run --rm app php yii migrate/up --interactive=0
docker compose up -d app nginxUntuk 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.
Pipeline CI menjalankan kualitas (test + lint) di setiap push, dan release deploy saat push ke branch production:
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: rootname: 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 -fPerhatikan 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.
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:
# /opt/app/.env (chmod 600, tidak di-commit)
DB_DSN=mysql:host=db;dbname=blog
DB_USER=app_user
DB_PASS=***
COOKIE_VALIDATION_KEY=*** Setelah deploy, verifikasi bertahap:
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?Inti yang harus dibawa pulang:
web/, try_files ke index.php, blokir dotfile dan luar-web.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!