Deployer: Deploy PHP không downtime, không drama
Hướng dẫn chi tiết dùng Deployer để deploy ứng dụng PHP với zero downtime, từ cài đặt, config đến các recipe cho Laravel, Symfony và xử lý sự cố thực tế.
Nguyễn Nhật Long
@nguyennhatlong1303
Deployer: Deploy PHP không downtime, không drama
Hồi mới đi làm, mình deploy PHP kiểu rất "thủ công": SSH vào server, git pull, chạy composer install, rồi cầu nguyện không có gì vỡ. Nghe quen không? Chắc nhiều anh em PHP dev cũng từng trải qua giai đoạn đó. Rồi một ngày đẹp trời, mình deploy lúc 3h chiều, composer install chạy mất 2 phút, trong 2 phút đó user vào thấy trang trắng. Sếp gọi, khách hàng gọi, cả team nhìn mình như nhìn tội đồ.
Từ đó mình bắt đầu tìm hiểu nghiêm túc về deployment tool, và Deployer là thứ đã thay đổi hoàn toàn cách mình làm việc với PHP deployment.
Deployer là gì và tại sao nó "ngon" hơn cách deploy thủ công
Deployer (https://github.com/deployphp/deployer) là một deployment tool viết bằng PHP, chuyên dụng cho PHP projects. Nó có hơn 11k star trên GitHub, 165 releases, và cộng đồng khá active. Điểm khiến mình chọn nó thay vì các tool khác:
- Zero downtime deployment: Deployer dùng chiến lược symlink. Mỗi lần deploy, nó tạo một release folder mới, chuẩn bị mọi thứ xong xuôi rồi mới switch symlink sang. User không bao giờ thấy trang trắng hay lỗi giữa chừng.
- Recipe sẵn cho các framework phổ biến: Laravel, Symfony, WordPress, Yii, CakePHP, Magento... gần như không cần config gì nhiều.
- Viết bằng PHP: Anh em PHP dev đọc hiểu ngay, không cần học thêm Ruby (Capistrano) hay Go.
- Automatic server provisioning: Có thể tự setup server từ đầu.
So sánh nhanh với các tool khác mà mình đã dùng qua:
| Feature | Deployer | Capistrano | Envoy (Laravel) | Shell Script |
|---|---|---|---|---|
| Ngôn ngữ | PHP | Ruby | Blade/PHP | Bash |
| Zero downtime | ✅ Có sẵn | ✅ Có sẵn | ❌ Tự implement | ❌ Tự implement |
| Recipe cho PHP frameworks | ✅ Rất nhiều | ⚠️ Ít | ⚠️ Chỉ Laravel | ❌ Không |
| Learning curve | Thấp (nếu biết PHP) | Trung bình | Thấp | Thấp |
| Rollback | ✅ 1 lệnh | ✅ 1 lệnh | ❌ Tự viết | ❌ Tự viết |
| Parallel deployment | ✅ Có | ✅ Có | ❌ Không | ❌ Không |
| Server provisioning | ✅ Có | ❌ Không | ❌ Không | ⚠️ Tự viết |
Cài đặt và setup từ đầu
Cài Deployer cực kỳ đơn giản. Mình recommend cài global để dùng cho nhiều project:
1curl -LO https://deployer.org/deployer.phar2mv deployer.phar /usr/local/bin/dep3chmod +x /usr/local/bin/dep
Hoặc nếu bạn thích quản lý qua Composer (mình thường dùng cách này cho team để mọi người cùng version):
1composer require deployer/deployer --dev
Sau khi cài xong, chạy dep init trong thư mục project:
1dep init
Nó sẽ hỏi bạn dùng framework gì, rồi tự generate file deploy.php phù hợp. Đây là file config chính của Deployer.
Cấu trúc thư mục trên server hiểu rõ để không bị "hoang mang"
Trước khi đi vào config, bạn cần hiểu cách Deployer tổ chức thư mục trên server, vì đây là core concept cho zero downtime:
1/var/www/myapp/2├── current -> /var/www/myapp/releases/5 # Symlink tới release hiện tại3├── releases/4│ ├── 1/5│ ├── 2/6│ ├── 3/7│ ├── 4/8│ └── 5/ # Release mới nhất9├── shared/10│ ├── .env # File .env dùng chung11│ ├── storage/ # Storage dùng chung (Laravel)12│ └── ...13└── .dep/ # Deployer metadata
Mỗi lần deploy, Deployer tạo một folder mới trong releases/, clone code vào đó, chạy composer install, symlink các shared files/folders, rồi cuối cùng mới đổi symlink current sang release mới. Nginx/Apache trỏ vào current/public, nên user không bao giờ thấy trạng thái "nửa nạc nửa mỡ".
Theo kinh nghiệm của mình, hiểu rõ cấu trúc này giúp debug deployment issues nhanh hơn rất nhiều. Nhiều lần mình SSH vào server check thì thấy vấn đề nằm ở shared files chứ không phải code.
Config deploy.php cho Laravel bản đầy đủ thực chiến
Đây là file deploy.php mà mình đang dùng cho một project Laravel production thật, mình đã strip bớt thông tin nhạy cảm nhưng giữ nguyên logic:
1<?php23namespace Deployer;45require 'recipe/laravel.php';67// Project name8set('application', 'my-laravel-app');910// Repository11set('repository', 'git@github.com:myteam/my-laravel-app.git');12set('branch', 'main');1314// Số lượng releases giữ lại (để rollback)15set('keep_releases', 5);1617// Shared files/dirs giữa các releases18add('shared_files', [19 '.env',20]);21add('shared_dirs', [22 'storage',23]);2425// Writable dirs26add('writable_dirs', [27 'bootstrap/cache',28 'storage',29 'storage/app',30 'storage/app/public',31 'storage/framework',32 'storage/framework/cache',33 'storage/framework/sessions',34 'storage/framework/views',35 'storage/logs',36]);3738// Host config39host('production')40 ->setHostname('192.168.1.100')41 ->setRemoteUser('deployer')42 ->setDeployPath('/var/www/my-laravel-app')43 ->setLabels(['stage' => 'production']);4445host('staging')46 ->setHostname('192.168.1.101')47 ->setRemoteUser('deployer')48 ->setDeployPath('/var/www/my-laravel-app-staging')49 ->setLabels(['stage' => 'staging'])50 ->set('branch', 'develop');5152// Task: Build frontend assets53task('deploy:build', function () {54 cd('{{release_path}}');55 run('npm ci');56 run('npm run build');57});5859// Task: Restart queue workers sau khi deploy60task('deploy:queue_restart', function () {61 run('cd {{release_path}} && php artisan queue:restart');62});6364// Task: Cache config và routes65task('deploy:cache', function () {66 run('cd {{release_path}} && php artisan config:cache');67 run('cd {{release_path}} && php artisan route:cache');68 run('cd {{release_path}} && php artisan view:cache');69});7071// Hook vào flow deploy72after('deploy:vendors', 'deploy:build');73after('deploy:symlink', 'deploy:cache');74after('deploy:symlink', 'deploy:queue_restart');7576// Nếu deploy fail, tự động unlock77after('deploy:failed', 'deploy:unlock');
Anh em lưu ý mấy điểm quan trọng:
keep_releases: Mình để 5, nghĩa là giữ lại 5 bản release gần nhất. Nếu server ít disk space thì giảm xuống 3.shared_filesvàshared_dirs: File.envvà thư mụcstoragephải được share giữa các release, nếu không mỗi lần deploy sẽ mất hết log, uploaded files, sessions.- Build frontend trên server: Mình chạy
npm run buildtrên server luôn. Nếu server yếu, bạn có thể build local rồi upload, nhưng cách đó phức tạp hơn.
Deploy thực tế chạy lệnh và xem chuyện gì xảy ra
Deploy lên staging:
1dep deploy staging
Deploy lên production:
1dep deploy production
Output sẽ trông kiểu thế này:
1✔ Executing task deploy:prepare2✔ Executing task deploy:lock3✔ Executing task deploy:release4✔ Executing task deploy:update_code5✔ Executing task deploy:shared6✔ Executing task deploy:writable7✔ Executing task deploy:vendors8✔ Executing task deploy:build9✔ Executing task deploy:symlink10✔ Executing task deploy:cache11✔ Executing task deploy:queue_restart12✔ Executing task deploy:unlock13✔ Executing task deploy:cleanup14Successfully deployed!
Mỗi bước đều có ý nghĩa rõ ràng. Nếu bất kỳ bước nào fail, Deployer sẽ dừng lại và không switch symlink, nghĩa là production vẫn chạy bản cũ an toàn.
Rollback khi mọi thứ đi sai
Đây là feature mình thích nhất. Giả sử deploy xong phát hiện bug trên production:
1dep rollback production
Một lệnh duy nhất, Deployer switch symlink current về release trước đó. Mất chưa đầy 1 giây. Không downtime, không panic.
Mình đã có lần deploy lúc 5h chiều thứ 6 (đừng hỏi tại sao, deadline mà), phát hiện bug ngay sau đó, rollback trong 2 giây, cả team thở phào. Nếu không có Deployer, chắc mình phải ngồi fix bug đến tối.
Viết custom task sức mạnh thật sự của Deployer
Deployer cho phép bạn viết task tùy ý bằng PHP. Đây là một số task mình hay dùng:
Notify Slack sau khi deploy
1task('notify:slack', function () {2 $webhookUrl = 'https://hooks.slack.com/services/xxx/yyy/zzz';3 $message = json_encode([4 'text' => sprintf(5 '🚀 *%s* deployed to *%s* by *%s* (branch: %s)',6 get('application'),7 get('hostname'),8 get('user'),9 get('branch')10 ),11 ]);1213 runLocally("curl -s -X POST -H 'Content-type: application/json' --data '$message' $webhookUrl");14});1516after('deploy:success', 'notify:slack');
Check PHP version trước khi deploy
1task('deploy:check_php', function () {2 $phpVersion = run('php -r "echo PHP_VERSION;"');3 if (version_compare($phpVersion, '8.1', '<')) {4 throw new \RuntimeException(5 "Server PHP version is $phpVersion, but 8.1+ is required!"6 );7 }8 writeln("PHP version: $phpVersion ✓");9});1011before('deploy:vendors', 'deploy:check_php');
Chạy migration an toàn
1task('deploy:migrate', function () {2 $output = run('cd {{release_path}} && php artisan migrate --force --no-interaction 2>&1');3 writeln($output);45 if (str_contains($output, 'ERROR') || str_contains($output, 'Exception')) {6 throw new \RuntimeException('Migration failed!');7 }8});910after('deploy:symlink', 'deploy:migrate');
Mình thấy cái này hay ở chỗ: vì task viết bằng PHP nên anh em có thể tận dụng mọi thứ PHP cung cấp string manipulation, file handling, HTTP requests, thậm chí query database nếu cần.
Setup server user cho Deployer
Một điểm nhiều người bỏ qua: đừng deploy bằng root. Tạo một user riêng cho deployment:
1# Trên server2sudo adduser deployer3sudo usermod -aG www-data deployer45# Cho phép deployer restart PHP-FPM không cần password6sudo visudo7# Thêm dòng:8# deployer ALL=(ALL) NOPASSWD: /usr/sbin/service php8.2-fpm reload910# Setup SSH key11sudo mkdir -p /home/deployer/.ssh12sudo cp ~/.ssh/authorized_keys /home/deployer/.ssh/13sudo chown -R deployer:deployer /home/deployer/.ssh1415# Tạo thư mục deploy16sudo mkdir -p /var/www/my-laravel-app17sudo chown deployer:www-data /var/www/my-laravel-app
Sau đó, lần đầu tiên chạy:
1dep deploy:prepare production
Lệnh này sẽ tạo cấu trúc thư mục cần thiết trên server. Nhớ SSH vào server và tạo file .env trong thư mục shared/:
1ssh deployer@192.168.1.1002nano /var/www/my-laravel-app/shared/.env3# Paste nội dung .env production vào đây
Nginx config trỏ vào symlink
Nginx cần trỏ vào current/public:
1server {2 listen 80;3 server_name myapp.com;4 root /var/www/my-laravel-app/current/public;56 index index.php;78 # Quan trọng: disable symlink check để Nginx follow symlink9 disable_symlinks off;1011 location / {12 try_files $uri $uri/ /index.php?$query_string;13 }1415 location ~ \.php$ {16 fastcgi_pass unix:/run/php/php8.2-fpm.sock;17 fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;18 include fastcgi_params;19 }20}
Anh em lưu ý dòng $realpath_root thay vì $document_root. Đây là chi tiết nhỏ nhưng cực kỳ quan trọng $realpath_root sẽ resolve symlink thành path thật, tránh lỗi OPcache cache file cũ.
Xử lý sự cố thường gặp
Sau vài năm dùng Deployer, mình tổng hợp lại mấy lỗi hay gặp nhất:
Mẹo debug: thêm flag -vvv để xem chi tiết từng lệnh Deployer chạy trên server:
| Lỗi | Nguyên nhân | Cách fix |
|---|---|---|
| `Permission denied` khi deploy | User deployer không có quyền write | `chown -R deployer:www-data /var/www/myapp` |
| `Deployer is locked` | Deploy trước đó bị fail giữa chừng | `dep deploy:unlock production` |
| `.env not found` | Chưa tạo file .env trong shared/ | SSH vào tạo file `/var/www/myapp/shared/.env` |
| OPcache serve code cũ | Nginx dùng `$document_root` thay vì `$realpath_root` | Sửa Nginx config + restart PHP-FPM sau deploy |
| `npm: command not found` | Node.js chưa cài trên server | Cài Node.js trên server hoặc build local |
| SSH key bị reject | Server chưa có public key của máy deploy | `ssh-copy-id deployer@server` |
1dep deploy production -vvv
Tích hợp với CI/CD
Deployer có sẵn GitHub Action chính thức. Đây là workflow mình đang dùng:
1# .github/workflows/deploy.yml2name: Deploy34on:5 push:6 branches: [main]78jobs:9 deploy:10 runs-on: ubuntu-latest11 steps:12 - uses: actions/checkout@v41314 - name: Setup PHP15 uses: shivammathur/setup-php@v216 with:17 php-version: '8.2'1819 - name: Install Deployer20 run: |21 curl -LO https://deployer.org/deployer.phar22 chmod +x deployer.phar2324 - name: Setup SSH Key25 run: |26 mkdir -p ~/.ssh27 echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa28 chmod 600 ~/.ssh/id_rsa29 ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts3031 - name: Deploy32 run: php deployer.phar deploy production
Push code lên main → GitHub Actions tự chạy deploy → Slack notification → done. Không cần SSH thủ công nữa.
Một vài tips sau nhiều năm dùng Deployer
Theo kinh nghiệm của mình, có mấy điều nên làm từ đầu:
- Luôn test deploy trên staging trước. Nghe hiển nhiên nhưng mình đã thấy nhiều người skip bước này rồi khóc trên production.
- Đừng chạy migration tự động trên production nếu migration có thể break schema. Mình thường tách migration task ra và chạy manual cho production, chỉ auto cho staging.
- Monitor disk space. Mỗi release là một bản copy code đầy đủ + vendor. 5 releases × 200MB = 1GB. Server nhỏ sẽ đầy disk nhanh.
- Dùng
after('deploy:failed', 'deploy:unlock')luôn. Nếu không, lần deploy fail tiếp theo sẽ bị lock và bạn phải SSH vào unlock thủ công.
Deployer không phải là tool phức tạp, nhưng nó giải quyết một vấn đề rất thực tế mà hầu hết PHP dev đều gặp. Từ ngày dùng nó, mình deploy tự tin hơn hẳn kể cả lúc 5h chiều thứ 6. Nếu bạn đang deploy PHP bằng git pull trên server, thử Deployer đi, mình nghĩ bạn sẽ không quay lại cách cũ đâu.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.
Thấy hay? Chia sẻ cho bạn bè!