Nâng cao
9 phút đọc3 tháng 6, 20261

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ế.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Deployer: Deploy PHP không downtime, không drama

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:

FeatureDeployerCapistranoEnvoy (Laravel)Shell Script
Ngôn ngữPHPRubyBlade/PHPBash
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 curveThấp (nếu biết PHP)Trung bìnhThấpThấ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:

Terminal
1curl -LO https://deployer.org/deployer.phar
2mv deployer.phar /usr/local/bin/dep
3chmod +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):

Terminal
1composer require deployer/deployer --dev

Sau khi cài xong, chạy dep init trong thư mục project:

Terminal
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:

TEXT
1/var/www/myapp/
2├── current -> /var/www/myapp/releases/5 # Symlink tới release hiện tại
3├── releases/
4│ ├── 1/
5│ ├── 2/
6│ ├── 3/
7│ ├── 4/
8│ └── 5/ # Release mới nhất
9├── shared/
10│ ├── .env # File .env dùng chung
11│ ├── 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:

PHP
1<?php
2
3namespace Deployer;
4
5require 'recipe/laravel.php';
6
7// Project name
8set('application', 'my-laravel-app');
9
10// Repository
11set('repository', 'git@github.com:myteam/my-laravel-app.git');
12set('branch', 'main');
13
14// Số lượng releases giữ lại (để rollback)
15set('keep_releases', 5);
16
17// Shared files/dirs giữa các releases
18add('shared_files', [
19 '.env',
20]);
21add('shared_dirs', [
22 'storage',
23]);
24
25// Writable dirs
26add('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]);
37
38// Host config
39host('production')
40 ->setHostname('192.168.1.100')
41 ->setRemoteUser('deployer')
42 ->setDeployPath('/var/www/my-laravel-app')
43 ->setLabels(['stage' => 'production']);
44
45host('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');
51
52// Task: Build frontend assets
53task('deploy:build', function () {
54 cd('{{release_path}}');
55 run('npm ci');
56 run('npm run build');
57});
58
59// Task: Restart queue workers sau khi deploy
60task('deploy:queue_restart', function () {
61 run('cd {{release_path}} && php artisan queue:restart');
62});
63
64// Task: Cache config và routes
65task('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});
70
71// Hook vào flow deploy
72after('deploy:vendors', 'deploy:build');
73after('deploy:symlink', 'deploy:cache');
74after('deploy:symlink', 'deploy:queue_restart');
75
76// Nếu deploy fail, tự động unlock
77after('deploy:failed', 'deploy:unlock');

Anh em lưu ý mấy điểm quan trọng:

  1. 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.
  2. shared_filesshared_dirs: File .env và thư mục storage phả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.
  3. Build frontend trên server: Mình chạy npm run build trê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:

Terminal
1dep deploy staging

Deploy lên production:

Terminal
1dep deploy production

Output sẽ trông kiểu thế này:

TEXT
1✔ Executing task deploy:prepare
2✔ Executing task deploy:lock
3✔ Executing task deploy:release
4✔ Executing task deploy:update_code
5✔ Executing task deploy:shared
6✔ Executing task deploy:writable
7✔ Executing task deploy:vendors
8✔ Executing task deploy:build
9✔ Executing task deploy:symlink
10✔ Executing task deploy:cache
11✔ Executing task deploy:queue_restart
12✔ Executing task deploy:unlock
13✔ Executing task deploy:cleanup
14Successfully 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:

Terminal
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

PHP
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 ]);
12
13 runLocally("curl -s -X POST -H 'Content-type: application/json' --data '$message' $webhookUrl");
14});
15
16after('deploy:success', 'notify:slack');

Check PHP version trước khi deploy

PHP
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});
10
11before('deploy:vendors', 'deploy:check_php');

Chạy migration an toàn

PHP
1task('deploy:migrate', function () {
2 $output = run('cd {{release_path}} && php artisan migrate --force --no-interaction 2>&1');
3 writeln($output);
4
5 if (str_contains($output, 'ERROR') || str_contains($output, 'Exception')) {
6 throw new \RuntimeException('Migration failed!');
7 }
8});
9
10after('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:

Terminal
1# Trên server
2sudo adduser deployer
3sudo usermod -aG www-data deployer
4
5# Cho phép deployer restart PHP-FPM không cần password
6sudo visudo
7# Thêm dòng:
8# deployer ALL=(ALL) NOPASSWD: /usr/sbin/service php8.2-fpm reload
9
10# Setup SSH key
11sudo mkdir -p /home/deployer/.ssh
12sudo cp ~/.ssh/authorized_keys /home/deployer/.ssh/
13sudo chown -R deployer:deployer /home/deployer/.ssh
14
15# Tạo thư mục deploy
16sudo mkdir -p /var/www/my-laravel-app
17sudo chown deployer:www-data /var/www/my-laravel-app

Sau đó, lần đầu tiên chạy:

Terminal
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/:

Terminal
1ssh deployer@192.168.1.100
2nano /var/www/my-laravel-app/shared/.env
3# Paste nội dung .env production vào đây

Nginx config trỏ vào symlink

Nginx cần trỏ vào current/public:

Nginx
1server {
2 listen 80;
3 server_name myapp.com;
4 root /var/www/my-laravel-app/current/public;
5
6 index index.php;
7
8 # Quan trọng: disable symlink check để Nginx follow symlink
9 disable_symlinks off;
10
11 location / {
12 try_files $uri $uri/ /index.php?$query_string;
13 }
14
15 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ỗiNguyên nhânCách fix
`Permission denied` khi deployUser 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 serverCài Node.js trên server hoặc build local
SSH key bị rejectServer chưa có public key của máy deploy`ssh-copy-id deployer@server`
Terminal
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:

YAML
1# .github/workflows/deploy.yml
2name: Deploy
3
4on:
5 push:
6 branches: [main]
7
8jobs:
9 deploy:
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v4
13
14 - name: Setup PHP
15 uses: shivammathur/setup-php@v2
16 with:
17 php-version: '8.2'
18
19 - name: Install Deployer
20 run: |
21 curl -LO https://deployer.org/deployer.phar
22 chmod +x deployer.phar
23
24 - name: Setup SSH Key
25 run: |
26 mkdir -p ~/.ssh
27 echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
28 chmod 600 ~/.ssh/id_rsa
29 ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
30
31 - name: Deploy
32 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.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễ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è!