Đưa một website Laravel mới lên Cloud VPS bằng tay là một chuỗi thao tác dài: tạo database, clone code, sửa .env, chạy composer, viết file cấu hình nginx trên minion rồi trên balancer, reload, kiểm tra. Mỗi bước đều đơn giản, nhưng gộp lại thì rất dễ sót. Tài liệu này hướng dẫn dựng pipeline deploy tự động đầu tiên bằng GitLab CI: một GitLab Runner tự host, SSH vào từng máy và sinh cấu hình nginx từ template, kèm cấu hình mẫu, các rủi ro thiết kế hay gặp và checklist để pipeline không gây hại khi chạy nhầm.

1. Vì sao cần deploy tự động

Theo mô hình Balancer – Minion – Database, một site mới đụng tới ít nhất ba máy:

  1. Database: tạo schema, tạo user, cấp quyền.
  2. Minion: clone code vào /var/www/<domain>, tạo .env, composer install, php artisan migrate, phân quyền, thêm file cấu hình site cho nginx local.
  3. Balancer: thêm /etc/nginx/conf.d/<domain>.conf trỏ về minion, cấu hình SSL, reload nginx.

Làm tay, người vận hành phải nhớ đúng IP minion, đúng tên database, đúng mật khẩu ở cả hai nơi (MySQL và .env), và không gõ nhầm domain trong ba file khác nhau. Sai một ký tự là site trả 502 hoặc lỗi kết nối database. Mục tiêu của deploy tự động rất cụ thể: nhập vài tham số, bấm chạy, mọi bước diễn ra theo đúng một thứ tự, lần nào cũng giống nhau.

2. GitLab CI và GitLab Runner: nền móng của deploy tự động

GitLab CI đọc file .gitlab-ci.yml ở gốc repo. File này mô tả pipeline gồm nhiều stage chạy tuần tự, mỗi stage có một hoặc nhiều job. Cú pháp đầy đủ có trong tài liệu tham chiếu .gitlab-ci.yml. Các khái niệm chính:

  • variables: tham số đầu vào, khai báo trong file, trong CI/CD Variables của project, hoặc truyền lúc bấm chạy pipeline.
  • rules: điều kiện để job được chạy, ví dụ chỉ chạy khi pipeline được kích hoạt từ giao diện web hay qua API.
  • tags: job chỉ được giao cho runner có tag tương ứng.

Bản thân GitLab không chạy lệnh nào. Lệnh được chạy bởi GitLab Runner, một tiến trình cài trên máy của mình, đăng ký với GitLab và liên tục hỏi xem có job mới không. Với kịch bản SSH vào máy chủ, shell executor là lựa chọn đơn giản nhất: job chạy thẳng bằng shell trên máy cài runner, không container, không image.

Với deploy tự động trên Cloud VPS, runner phải tự host vì minion và database chỉ có IP private. Runner dùng chung của GitLab nằm ngoài Internet nên không SSH vào được. Runner đặt trong cùng mạng private gọi được mọi máy, còn GitLab chỉ đóng vai trò lưu pipeline, giao diện bấm chạy và nơi xem log.

3. Pipeline deploy tự động ba bước

Repo chứa pipeline deploy tự động không chứa code website, chỉ chứa .gitlab-ci.yml và các template nginx. Mỗi lần chạy pipeline là một "lệnh deploy" cho một site, với các biến đầu vào ví dụ:

BiếnÝ nghĩa
DOMAINDomain của site, cũng là tên thư mục và tên file cấu hình
MINION_IP, BALANCER_IPMinion chứa code và balancer nhận traffic
DB_HOST, DB_NAME, DB_USER, DB_PASSWORDThông tin database sẽ tạo
REPO_URLRepo code của site
SSL_TYPEletsencrypt hoặc cloudflare
SSH_PRIVATE_KEYBiến kiểu File trong CI/CD Variables, không nằm trong repo

Bước 1: tạo database, chỉ khi chưa tồn tại

stages: [create_database, pull_code, config_nginx]

create_database:
  stage: create_database
  tags: [shell]
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
  script:
    - |
      ssh -i "$SSH_PRIVATE_KEY" deploy@"$DB_HOST" "
        mysql --login-path=admin -N -e \"SHOW DATABASES LIKE '$DB_NAME'\" | grep -q . \
          && { echo 'Database đã tồn tại, dừng'; exit 1; }
        mysql --login-path=admin -e \"CREATE DATABASE \\\`$DB_NAME\\\`;
          CREATE USER '$DB_USER'@'10.0.0.%' IDENTIFIED BY '$DB_PASSWORD';
          GRANT ALL ON \\\`$DB_NAME\\\`.* TO '$DB_USER'@'10.0.0.%';\""

Hai chốt chặn của deploy tự động nằm ngay ở job đầu: rules chỉ cho pipeline chạy khi có người bấm "Run pipeline" hoặc có hệ thống gọi API, không bao giờ tự chạy khi push sửa template; và job dừng nếu database đã tồn tại, thay vì xoá đi tạo lại. Thông tin đăng nhập admin nằm trong mysql_config_editor trên máy đích, không xuất hiện trên dòng lệnh.

Bước 2: kéo code và dựng .env

pull_code:
  stage: pull_code
  tags: [shell]
  script:
    - |
      ssh -i "$SSH_PRIVATE_KEY" deploy@"$MINION_IP" "
        set -e
        test ! -e /var/www/$DOMAIN || { echo 'Thư mục đã có, dừng'; exit 1; }
        git clone $REPO_URL /var/www/$DOMAIN
        cd /var/www/$DOMAIN
        cp .env.example .env
        sed -i 's|^APP_DEBUG=.*|APP_DEBUG=false|; s|^DB_HOST=.*|DB_HOST=$DB_HOST|' .env
        composer install --no-dev
        grep -q '^APP_KEY=.\+' .env || php artisan key:generate
        php artisan migrate --force
        chmod -R ug+rwX storage bootstrap/cache"

set -e (hoặc nối lệnh bằng &&) làm chuỗi lệnh dừng ngay khi một lệnh lỗi, SSH trả mã lỗi và job đỏ trên GitLab. Đây là cơ chế "dừng khi sai" rẻ nhất mà vẫn hiệu quả cho deploy tự động bằng shell. Thông tin database còn lại nên được ghi vào .env bằng công cụ có escape, không dùng sed (xem mục 5).

Bước 3: sinh cấu hình nginx từ template

Phần đáng giá nhất của deploy tự động là cấu hình nginx. Thay vì viết file cấu hình site bằng tay, repo giữ sẵn template với chỗ trống như [DOMAIN], [MINION_IP]. Template cho nginx trên minion (rút gọn):

server {
    listen [MINION_IP]:80;
    server_name [DOMAIN] www.[DOMAIN];
    root /var/www/[DOMAIN]/public/;
    access_log /var/log/nginx/[DOMAIN].access;
    error_log  /var/log/nginx/[DOMAIN].error error;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param HTTPS on;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_read_timeout 180;
    }
}

Job config_nginx copy template lên máy đích, thay chỗ trống, rồi kiểm tra cú pháp trước khi reload. Cách cài file an toàn: ghi ra file tạm, giữ bản cũ, kiểm tra xong mới reload, lỗi thì trả lại trạng thái cũ:

CONF=/etc/nginx/conf.d/$DOMAIN.conf
sed "s/\[DOMAIN\]/$DOMAIN/g; s/\[MINION_IP\]/$IP/g" template.conf > "$CONF.new"
[ -e "$CONF" ] && cp "$CONF" "$CONF.bak"
mv "$CONF.new" "$CONF"
if nginx -t; then
    systemctl reload nginx
else
    # không để file hỏng nằm lại trong conf.d
    if [ -e "$CONF.bak" ]; then mv "$CONF.bak" "$CONF"; else rm -f "$CONF"; fi
    exit 1
fi

nginx -t trước reload là dòng quan trọng nhất của cả pipeline deploy tự động: file sinh ra sai cú pháp thì reload không xảy ra, các site đang chạy không bị ảnh hưởng, vì nginx chỉ áp cấu hình mới khi nhận tín hiệu reload. Bước khôi phục giúp lần reload sau của bất kỳ ai không thất bại vì một file hỏng còn sót.

4. Hai kịch bản SSL trong deploy tự động

Với site đi qua balancer, pipeline deploy tự động nên có hai template, chọn theo biến SSL_TYPE:

config_nginx_balancer_letsencrypt:
  stage: config_nginx
  variables: { TEMPLATE: "balancer_letsencrypt.conf" }
  rules: [ { if: '$SSL_TYPE == "letsencrypt"' } ]

config_nginx_balancer_cloudflare:
  stage: config_nginx
  variables: { TEMPLATE: "balancer_cloudflare.conf" }
  rules: [ { if: '$SSL_TYPE == "cloudflare"' } ]

Template balancer là file reverse proxy quen thuộc trong bài Nginx reverse proxy trên tầng Balancer: khối upstream trỏ về IP minion, proxy_pass, các header X-Forwarded-*. Template Cloudflare có thể chỉ nghe cổng 80 nếu SSL kết thúc ở Cloudflare, hoặc dùng Origin Certificate cho chế độ Full (strict).

Kịch bản Let's Encrypt gặp bài toán con gà và quả trứng: template ghi sẵn đường dẫn /etc/letsencrypt/live/[DOMAIN]/fullchain.pem, nhưng chứng chỉ chỉ có sau khi certbot chạy, mà certbot lại cần nginx đã phục vụ được cổng 80. Lúc chứng chỉ chưa tồn tại, nginx -t báo lỗi. Cách gỡ là tách hai pha như trong bài SSL cho hàng trăm domain với Certbot: sinh cấu hình chỉ có cổng 80, chạy certbot certonly --nginx, rồi mới thêm khối 443.

5. Rủi ro thiết kế hay gặp ở pipeline deploy tự động đầu tiên

Pipeline deploy tự động chạy được không có nghĩa là an toàn. Các lỗi thiết kế dưới đây rất phổ biến ở phiên bản đầu:

Điểm trong pipelineRủi roCách làm đúng
DROP DATABASE IF EXISTS trước CREATEChạy lại cho site đang sống là xoá sạch dữ liệu thậtDatabase đã tồn tại thì dừng. Muốn làm lại thì dump trước
rm -rf $DOMAIN rồi clone lạiMất file người dùng upload, site chết trong lúc cloneTách "tạo site" và "cập nhật code"; cập nhật bằng thư mục release mới rồi đổi symlink
php artisan key:generate mỗi lần chạyĐổi APP_KEY làm mất session, dữ liệu mã hoá không giải mã đượcChỉ sinh key khi .env chưa có key
mysql -u root -p$PASSWORDMật khẩu hiện trong danh sách tiến trình và logDùng mysql_config_editor hoặc file client quyền 600
StrictHostKeyChecking=no, SSH bằng rootKhông phát hiện máy giả mạo; lộ key là lộ quyền root mọi máyGhi sẵn known_hosts, user deploy riêng có quyền giới hạn
Thay biến bằng sedMật khẩu chứa / hoặc & làm hỏng lệnh hoặc ghi sai .envSinh .env bằng công cụ có escape, hoặc giới hạn bộ ký tự mật khẩu
composer install --ignore-platform-reqsCài package đòi PHP cao hơn minion, lỗi chỉ lộ ra lúc chạyBỏ cờ này để composer báo lỗi sớm khi chọn sai nhóm minion

6. Checklist cho pipeline deploy tự động

Trước khi giao pipeline deploy tự động cho cả nhóm dùng, hãy kiểm tra các điểm sau:

  • Chỉ chạy khi bấm tay hoặc gọi API, không tự chạy khi push.
  • Kiểm tra trạng thái trước khi làm: database, thư mục, file cấu hình đã có thì dừng và báo rõ.
  • Mỗi bước chạy lại được: lỗi giữa chừng thì lần chạy sau đi tiếp được, không phải dọn tay.
  • Template là hợp đồng: sửa một lỗi trong template là sửa cho mọi site tạo sau đó; lỗi trong template cũng nhân bản theo, nên template cần review như code.
  • Bí mật không nằm trong repo: key SSH ở CI/CD Variables kiểu File (masked, protected), mật khẩu không đi qua dòng lệnh.
  • Luôn nginx -t trước reload và khôi phục file cũ khi lỗi.

Dù đơn giản, pipeline deploy tự động ba bước đã đổi cách làm việc: tạo site không còn là việc của một người nhớ quy trình, mà là một lệnh có log, có thứ tự và lặp lại được. Các pipeline phức tạp hơn sau này đều có thể phát triển từ khung này.

Bài tiếp theo

Tăng tốc website với Laravel ResponseCache, Redis và Supervisor. Cache nguyên response cho trang công khai, xoá đúng cache khi nội dung đổi, và giữ queue worker sống bằng supervisor.