Sau khi đã có Nginx, PHP-FPM và MariaDB, VPS của bạn mới chỉ có đủ “linh kiện” của một stack LEMP. Để stack đó thật sự có giá trị, bạn cần ghép các thành phần lại thành một website chạy được, có cấu trúc thư mục rõ ràng, có file cấu hình môi trường, có health-check, có log để debug và có quy trình deploy không làm hỏng production chỉ vì một lần copy file sai.
Bài Hoàn thiện LEMP: deploy demo app và workflow systemd sẽ đi qua toàn bộ quá trình dựng một demo app PHP nhỏ trên Ubuntu 24.04 LTS. App này sẽ chạy sau Nginx, xử lý bằng PHP-FPM, kết nối MariaDB, có endpoint /healthz để kiểm tra trạng thái, và có workflow systemd để kiểm tra sau deploy.
Mục tiêu không phải là tạo một framework phức tạp. Mục tiêu là hiểu cách một ứng dụng web thật nên được đặt vào server: code nằm ở đâu, file secret nằm ở đâu, Nginx trỏ vào thư mục nào, PHP-FPM xử lý ra sao, MariaDB được kiểm tra thế nào, deploy xong cần reload service nào, và nếu có lỗi thì rollback bằng cách nào.
1. Hiểu đúng mục tiêu khi hoàn thiện LEMP trên Ubuntu 24.04
1.1. LEMP chưa hoàn thiện nếu chưa có app chạy thật
LEMP là viết tắt thường gặp cho Linux, Nginx, MariaDB hoặc MySQL, và PHP. Nhưng việc cài đủ package chưa đồng nghĩa với việc bạn đã có một web platform vận hành được.
Một stack LEMP chỉ thật sự hoàn thiện khi bạn có thể trả lời được các câu hỏi sau:
- Ứng dụng nằm ở thư mục nào?
- Nginx đang phục vụ thư mục public nào?
- PHP-FPM đang xử lý file PHP qua socket nào?
- App đọc database credential từ đâu?
- Endpoint health-check nằm ở đâu?
- Deploy xong kiểm tra bằng lệnh nào?
- Nếu deploy lỗi, rollback về bản trước thế nào?
- Log của Nginx, PHP-FPM và MariaDB xem ở đâu?
Nếu chưa trả lời được những câu này, website có thể chạy được trong vài phút đầu, nhưng sẽ rất khó vận hành khi có lỗi 500, 502, permission denied, database connection failed hoặc deploy nhầm file.
1.2. Vì sao nên có health-check ngay từ đầu
Health-check là một endpoint đơn giản để trả lời câu hỏi: “Ứng dụng có đang sống không?”. Với app PHP dùng database, health-check tốt không chỉ trả về HTML tĩnh, mà nên kiểm tra được PHP đang chạy và database còn kết nối được.
Trong bài này, endpoint /healthz sẽ kiểm tra kết nối MariaDB bằng PDO. Nếu PHP-FPM lỗi, endpoint không chạy. Nếu database lỗi, endpoint trả về HTTP 500. Nếu mọi thứ ổn, endpoint trả về JSON với trạng thái ok.
Đây là thói quen nhỏ nhưng cực kỳ hữu ích. Sau này khi bạn thêm monitoring, uptime check, deploy script hoặc load balancer, health-check sẽ là điểm kiểm chứng đáng tin cậy hơn việc chỉ nhìn trang chủ.
1.3. Workflow systemd trong bài này có nghĩa là gì
Với một app PHP truyền thống, bản thân app không phải lúc nào cũng là một daemon dài hạn. Nginx, PHP-FPM và MariaDB mới là các service chính do systemd quản lý. Vì vậy, “workflow systemd” trong bài này không có nghĩa là ép mọi app PHP thành một service riêng.
Workflow systemd ở đây gồm ba lớp:
- Dùng
systemctlđể kiểm tra, reload, restart các service lõi: Nginx, PHP-FPM, MariaDB. - Tạo một script kiểm tra sau deploy để chạy cú pháp PHP, test Nginx config và gọi health-check.
- Đóng gói script đó thành một
oneshot systemd serviceđể thao tác nhất quán và có log trong journal.
Cách làm này phù hợp với VPS nhỏ, blog WordPress, app PHP tự viết, landing page có database, hoặc môi trường staging cần quy trình deploy rõ ràng nhưng chưa cần CI/CD phức tạp.
2. Chuẩn bị trước khi deploy demo app PHP với Nginx, PHP-FPM và MariaDB
2.1. Kiểm tra hệ điều hành và service nền
Bài này giả định VPS đang chạy Ubuntu 24.04 LTS và đã đi qua các bước trước trong series: cấu hình Ubuntu ban đầu, tạo user sudo, bật UFW, cài Nginx, tạo server block, cài PHP-FPM và cài MariaDB.
Kiểm tra nhanh:
lsb_release -a || cat /etc/os-release
whoami
sudo -v
sudo ufw status verbose
Kiểm tra các service chính:
sudo systemctl status nginx --no-pager
sudo systemctl status php*-fpm --no-pager
sudo systemctl status mariadb --no-pager
Nếu một trong ba service đang lỗi, đừng deploy app mới ngay. Hãy xử lý lỗi nền trước, vì deploy thêm code mới vào một stack chưa ổn sẽ làm bạn khó biết lỗi nằm ở app, Nginx, PHP-FPM hay database.
2.2. Kiểm tra socket PHP-FPM đang dùng
Trên Ubuntu 24.04 LTS, PHP-FPM thường chạy qua Unix socket trong thư mục /run/php/. Kiểm tra:
ls -la /run/php/
systemctl list-units --type=service | grep fpm
Bạn có thể thấy socket kiểu:
/run/php/php8.3-fpm.sock
Ghi nhớ đường dẫn này, vì Nginx sẽ dùng nó ở dòng fastcgi_pass.
2.3. Kiểm tra database app đã có từ bài “Cài MariaDB hoặc MySQL, hardening và tạo user/schema cho app 2026“
Ở bài trước, database mẫu có thể là:
- Database:
appdb - User:
appuser - Host:
localhost
Test đăng nhập:
mariadb -u appuser -p appdb
Sau khi nhập mật khẩu, chạy:
SELECT DATABASE();
SELECT USER();
SELECT 1;
EXIT;
Nếu lỗi Access denied, hãy quay lại kiểm tra user, password và quyền bằng SHOW GRANTS. Demo app trong bài này sẽ cần credential database hoạt động trước khi deploy.
2.4. Tạo biến dùng chung trong bài
Để các command dễ đọc, bài này dùng domain mẫu example.com. Khi áp dụng thực tế, hãy thay bằng domain của bạn.
export APP_DOMAIN="example.com"
export APP_ROOT="/srv/apps/${APP_DOMAIN}"
export PHP_FPM_SOCK="/run/php/php8.3-fpm.sock"
Kiểm tra lại:
echo "$APP_DOMAIN"
echo "$APP_ROOT"
echo "$PHP_FPM_SOCK"
Nếu socket PHP-FPM trên server khác php8.3-fpm.sock, hãy đổi lại biến PHP_FPM_SOCK cho đúng.
3. Thiết kế cấu trúc thư mục deploy app trên VPS
3.1. Vì sao không nên copy app thẳng vào một thư mục duy nhất
Cách nhanh nhất là copy toàn bộ source vào /var/www/example.com rồi trỏ Nginx vào đó. Cách này chạy được, nhưng không tốt cho deploy lâu dài.
Khi mọi thứ nằm chung một thư mục, bạn dễ gặp các vấn đề sau:
- Deploy dở giữa chừng khiến source ở trạng thái nửa cũ nửa mới.
- Không biết phiên bản trước nằm ở đâu để rollback.
- File
.envhoặc file upload bị ghi đè khi deploy. - Khó tách code, secret và dữ liệu runtime.
- Khó audit permission khi có nhiều website trên cùng VPS.
Cấu trúc tốt hơn là dùng mô hình releases, current và shared.
3.2. Cấu trúc releases, current và shared
Trong bài này, app sẽ nằm ở:
/srv/apps/example.com/
├── current -> /srv/apps/example.com/releases/20260501120000
├── releases/
│ └── 20260501120000/
│ └── public/
│ └── index.php
└── shared/
├── .env
└── storage/
Ý nghĩa:
releases: chứa từng bản deploy theo timestamp.current: symlink trỏ đến release đang chạy.shared: chứa file không bị thay mỗi lần deploy, như.env, upload, cache runtime hoặc storage.
Nginx sẽ luôn trỏ vào:
/srv/apps/example.com/current/public
Khi deploy bản mới, bạn chỉ cần tạo release mới và đổi symlink current. Nếu bản mới lỗi, rollback bằng cách trỏ current về release trước.
3.3. Tạo thư mục app
sudo mkdir -p "$APP_ROOT/releases"
sudo mkdir -p "$APP_ROOT/shared/storage"
sudo chown -R "$USER:www-data" "$APP_ROOT"
sudo chmod -R 750 "$APP_ROOT"
sudo chmod -R 770 "$APP_ROOT/shared/storage"
Kiểm tra:
ls -la /srv/apps/
ls -la "$APP_ROOT"
Ở đây, user deploy hiện tại là owner để thao tác release dễ hơn, còn group www-data giúp Nginx/PHP-FPM đọc được file cần thiết. Thư mục shared/storage được cấp quyền ghi cho group vì app có thể cần ghi cache, log hoặc file runtime.
4. Tạo demo app PHP có kết nối MariaDB
4.1. Tạo release đầu tiên
export RELEASE_ID="$(date +%Y%m%d%H%M%S)"
export RELEASE_DIR="$APP_ROOT/releases/$RELEASE_ID"
mkdir -p "$RELEASE_DIR/public"
mkdir -p "$RELEASE_DIR/config"
echo "$RELEASE_ID" > "$RELEASE_DIR/REVISION"
Kiểm tra:
tree "$APP_ROOT" || find "$APP_ROOT" -maxdepth 3 -type d -print
Nếu máy chưa có tree, có thể cài:
sudo apt install -y tree
4.2. Tạo file môi trường .env trong shared
File .env nên nằm ngoài thư mục public và không nên nằm trong Git repository public. Tạo file:
cat > "$APP_ROOT/shared/.env" <<'EOF'
APP_ENV=production
APP_NAME="LEMP Demo App"
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=appdb
DB_USER=appuser
DB_PASS=CHANGE_THIS_TO_DATABASE_PASSWORD
EOF
Đặt quyền:
chmod 640 "$APP_ROOT/shared/.env"
chown "$USER:www-data" "$APP_ROOT/shared/.env"
Mở file và thay mật khẩu database thật:
nano "$APP_ROOT/shared/.env"
Không đặt file .env trong public/. Nếu file chứa secret nằm trong webroot, chỉ một cấu hình sai cũng có thể làm lộ credential.
4.3. Tạo file config đọc .env
cat > "$RELEASE_DIR/config/bootstrap.php" <<'PHP'
<?php
declare(strict_types=1);
function app_env(): array
{
$envPath = '/srv/apps/example.com/shared/.env';
if (!is_readable($envPath)) {
throw new RuntimeException('Environment file is not readable.');
}
$env = parse_ini_file($envPath, false, INI_SCANNER_TYPED);
if ($env === false) {
throw new RuntimeException('Environment file cannot be parsed.');
}
return $env;
}
function db_pdo(array $env): PDO
{
foreach (['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASS'] as $key) {
if (!array_key_exists($key, $env)) {
throw new RuntimeException("Missing environment key: {$key}");
}
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$env['DB_HOST'],
$env['DB_PORT'],
$env['DB_NAME']
);
return new PDO($dsn, (string) $env['DB_USER'], (string) $env['DB_PASS'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
PHP
Vì trong file này đang hard-code đường dẫn /srv/apps/example.com/shared/.env, nếu bạn đổi domain thật thì hãy đổi đường dẫn tương ứng. Đây là demo app tối giản để người đọc hiểu luồng deploy, không phải framework hoàn chỉnh.
4.4. Tạo app index.php có route /healthz
cat > "$RELEASE_DIR/public/index.php" <<'PHP'
<?php
declare(strict_types=1);
require __DIR__ . '/../config/bootstrap.php';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
if ($path === '/healthz') {
header('Content-Type: application/json; charset=utf-8');
try {
$env = app_env();
$pdo = db_pdo($env);
$pdo->query('SELECT 1')->fetchColumn();
http_response_code(200);
echo json_encode([
'status' => 'ok',
'app' => $env['APP_NAME'] ?? 'LEMP Demo App',
'database' => 'ok',
'time' => gmdate('c'),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'database' => 'failed',
'time' => gmdate('c'),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
exit;
}
header('Content-Type: text/html; charset=utf-8');
?>
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LEMP Demo App</title>
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 760px;
margin: 60px auto;
padding: 0 20px;
line-height: 1.6;
color: #102033;
}
code {
background: #eef4ff;
padding: 2px 6px;
border-radius: 6px;
}
.card {
border: 1px solid #d7e2f0;
border-radius: 16px;
padding: 24px;
box-shadow: 0 10px 30px rgba(15, 23, 42, .08);
}
</style>
</head>
<body>
<main class="card">
<h1>LEMP Demo App đang chạy</h1>
<p>Nginx đã phục vụ request, PHP-FPM đã xử lý PHP, và app đã được deploy bằng cấu trúc release rõ ràng.</p>
<p>Kiểm tra health-check tại <code>/healthz</code>.</p>
</main>
</body>
</html>
PHP
4.5. Kiểm tra cú pháp PHP trước khi bật release
php -l "$RELEASE_DIR/config/bootstrap.php"
php -l "$RELEASE_DIR/public/index.php"
Nếu có lỗi syntax, dừng lại sửa trước. Không trỏ symlink current vào một release chưa qua bước kiểm tra tối thiểu.
4.6. Trỏ current vào release đầu tiên
ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.new"
mv -Tf "$APP_ROOT/current.new" "$APP_ROOT/current"
ls -la "$APP_ROOT"
Kiểm tra:
readlink -f "$APP_ROOT/current"
cat "$APP_ROOT/current/REVISION"
5. Cấu hình Nginx cho demo app PHP
5.1. Tạo server block cho app
Tạo file Nginx:
sudo nano "/etc/nginx/sites-available/${APP_DOMAIN}"
Nội dung mẫu:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /srv/apps/example.com/current/public;
index index.php index.html;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
client_max_body_size 32M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
Trong production thật, hãy thay example.com, www.example.com, đường dẫn root và socket PHP-FPM cho đúng với server của bạn.
Ý nghĩa các phần quan trọng:
roottrỏ vào thư mụcpublic, không trỏ vào toàn bộ source.try_filesưu tiên file tĩnh trước, sau đó chuyển request vàoindex.php.fastcgi_passtrỏ đến socket PHP-FPM.access_logvàerror_logtách riêng theo domain để debug dễ hơn.- Block deny dotfiles giúp giảm rủi ro lộ file như
.env,.git,.htaccess.
5.2. Bật site và test Nginx config
sudo ln -sfn "/etc/nginx/sites-available/${APP_DOMAIN}" "/etc/nginx/sites-enabled/${APP_DOMAIN}"
sudo nginx -t
Nếu nginx -t báo lỗi, không reload. Hãy đọc đúng dòng lỗi, sửa file config rồi test lại.
Khi test thành công:
sudo systemctl reload nginx
5.3. Test bằng curl từ chính server
Nếu DNS chưa trỏ về server, có thể test bằng Host header:
curl -i -H "Host: ${APP_DOMAIN}" http://127.0.0.1/
curl -i -H "Host: ${APP_DOMAIN}" http://127.0.0.1/healthz
Kết quả mong đợi của /healthz là HTTP 200 và JSON có "status": "ok".
Nếu nhận 500, kiểm tra database và PHP log. Nếu nhận 404, kiểm tra root, try_files và symlink current. Nếu nhận 502, kiểm tra socket PHP-FPM và trạng thái service PHP-FPM.
6. Tạo workflow systemd để kiểm tra sau deploy
6.1. Vì sao nên đóng gói post-deploy check thành script
Sau mỗi lần deploy, bạn thường cần chạy cùng một chuỗi kiểm tra:
- Kiểm tra release hiện tại có tồn tại không.
- Kiểm tra cú pháp PHP.
- Test cấu hình Nginx.
- Reload Nginx nếu cấu hình hợp lệ.
- Restart hoặc reload PHP-FPM nếu cần.
- Gọi endpoint
/healthz. - Ghi log kết quả để sau này xem lại.
Nếu làm thủ công mỗi lần, bạn dễ quên một bước. Đưa logic này vào script giúp deploy nhất quán hơn.
6.2. Tạo script post-deploy check
sudo nano /usr/local/sbin/example-com-post-deploy-check.sh
Nội dung:
#!/usr/bin/env bash
set -euo pipefail
APP_DOMAIN="example.com"
APP_ROOT="/srv/apps/${APP_DOMAIN}"
PHP_FPM_SERVICE="php8.3-fpm"
echo "[deploy-check] App domain: ${APP_DOMAIN}"
echo "[deploy-check] Current release: $(readlink -f "${APP_ROOT}/current")"
test -d "${APP_ROOT}/current/public"
test -f "${APP_ROOT}/current/public/index.php"
echo "[deploy-check] Checking PHP syntax..."
find "${APP_ROOT}/current" -type f -name "*.php" -print0 | xargs -0 -n1 php -l
echo "[deploy-check] Checking Nginx config..."
nginx -t
echo "[deploy-check] Reloading Nginx..."
systemctl reload nginx
echo "[deploy-check] Restarting PHP-FPM..."
systemctl restart "${PHP_FPM_SERVICE}"
echo "[deploy-check] Checking core services..."
systemctl is-active --quiet nginx
systemctl is-active --quiet "${PHP_FPM_SERVICE}"
systemctl is-active --quiet mariadb
echo "[deploy-check] Calling health-check..."
curl -fsS -H "Host: ${APP_DOMAIN}" "http://127.0.0.1/healthz" | grep -q '"status": "ok"'
echo "[deploy-check] OK"
Cấp quyền thực thi:
sudo chmod +x /usr/local/sbin/example-com-post-deploy-check.sh
Chạy thử:
sudo /usr/local/sbin/example-com-post-deploy-check.sh
Nếu script fail ở bước nào, hãy sửa đúng bước đó trước khi đưa vào systemd service.
6.3. Tạo systemd oneshot service cho deploy check
Tạo file unit:
sudo nano /etc/systemd/system/example-com-deploy-check.service
Nội dung:
[Unit]
Description=Post-deploy check for example.com LEMP app
Wants=nginx.service php8.3-fpm.service mariadb.service
After=nginx.service php8.3-fpm.service mariadb.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/example-com-post-deploy-check.sh
[Install]
WantedBy=multi-user.target
Reload systemd:
sudo systemctl daemon-reload
Chạy service:
sudo systemctl start example-com-deploy-check.service
Xem kết quả:
sudo systemctl status example-com-deploy-check.service --no-pager
sudo journalctl -u example-com-deploy-check.service --since "10 minutes ago"
Vì đây là oneshot service, nó chạy một lần rồi kết thúc. Đây là hành vi đúng cho post-deploy check. Không cần ép service này chạy nền như Nginx hay PHP-FPM.
6.4. Có nên enable oneshot deploy-check không?
Trong đa số trường hợp, không cần enable service này để chạy mỗi lần boot. Nó nên được chạy sau deploy, không phải lúc nào server khởi động cũng chạy.
Khi deploy thủ công, chỉ cần chạy:
sudo systemctl start example-com-deploy-check.service
Nếu sau này bạn có CI/CD, pipeline có thể SSH vào server, đổi symlink release, rồi gọi chính service này để kiểm tra. Như vậy logic kiểm tra vẫn nằm trên server, dễ xem log bằng journalctl.
7. Deploy phiên bản mới theo cách có rollback
7.1. Tạo release thứ hai
Giả sử bạn muốn deploy một bản mới, tạo release mới:
export RELEASE_ID="$(date +%Y%m%d%H%M%S)"
export RELEASE_DIR="$APP_ROOT/releases/$RELEASE_ID"
mkdir -p "$RELEASE_DIR/public"
mkdir -p "$RELEASE_DIR/config"
cp "$APP_ROOT/current/config/bootstrap.php" "$RELEASE_DIR/config/bootstrap.php"
cp "$APP_ROOT/current/public/index.php" "$RELEASE_DIR/public/index.php"
echo "$RELEASE_ID" > "$RELEASE_DIR/REVISION"
Sửa nhẹ nội dung trang chủ để phân biệt bản mới:
sed -i "s/LEMP Demo App đang chạy/LEMP Demo App version ${RELEASE_ID}/" "$RELEASE_DIR/public/index.php"
Kiểm tra syntax:
php -l "$RELEASE_DIR/config/bootstrap.php"
php -l "$RELEASE_DIR/public/index.php"
7.2. Chuyển current sang release mới
export PREVIOUS_RELEASE="$(readlink -f "$APP_ROOT/current")"
ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.new"
mv -Tf "$APP_ROOT/current.new" "$APP_ROOT/current"
echo "Previous: $PREVIOUS_RELEASE"
echo "Current: $(readlink -f "$APP_ROOT/current")"
Chạy deploy check:
sudo systemctl start example-com-deploy-check.service
sudo journalctl -u example-com-deploy-check.service --since "5 minutes ago"
Nếu health-check OK, release mới đã hoạt động.
7.3. Rollback nếu release mới lỗi
Nếu sau khi đổi current, health-check fail hoặc app lỗi, rollback bằng cách trỏ lại release trước:
ln -sfn "$PREVIOUS_RELEASE" "$APP_ROOT/current.new"
mv -Tf "$APP_ROOT/current.new" "$APP_ROOT/current"
sudo systemctl start example-com-deploy-check.service
Kiểm tra:
readlink -f "$APP_ROOT/current"
curl -i -H "Host: ${APP_DOMAIN}" http://127.0.0.1/healthz
Rollback bằng symlink thường rất nhanh vì không cần copy lại toàn bộ source. Điều kiện là bạn phải giữ lại ít nhất vài release gần nhất.
7.4. Dọn release cũ
Không nên giữ release mãi mãi vì sẽ tốn disk. Có thể giữ 5 bản gần nhất:
cd "$APP_ROOT/releases"
ls -1dt */ | tail -n +6
Nếu danh sách đúng, xóa:
cd "$APP_ROOT/releases"
ls -1dt */ | tail -n +6 | xargs -r rm -rf
Trước khi chạy lệnh xóa trên production, hãy chắc chắn bạn đang đứng đúng thư mục releases. Lệnh xóa release cũ nên được dùng cẩn thận, nhất là khi bạn mới bắt đầu làm quen với deploy thủ công.
8. Kiểm tra log sau khi deploy
8.1. Xem log Nginx theo site
sudo tail -n 100 /var/log/nginx/example.com.access.log
sudo tail -n 100 /var/log/nginx/example.com.error.log
Access log cho biết request nào đang vào site, status code là gì, user agent ra sao. Error log giúp tìm lỗi Nginx, lỗi FastCGI, permission denied hoặc file not found.
8.2. Xem log PHP-FPM
sudo journalctl -u php8.3-fpm --since "30 minutes ago"
sudo systemctl status php8.3-fpm --no-pager
Nếu app báo 502, đây là nơi cần xem ngay sau Nginx error log. Các lỗi thường gặp là PHP-FPM không chạy, socket sai đường dẫn, permission socket không đúng hoặc PHP fatal error.
8.3. Xem log MariaDB
sudo journalctl -u mariadb --since "30 minutes ago"
sudo systemctl status mariadb --no-pager
Nếu health-check báo database failed, hãy kiểm tra MariaDB service, credential trong .env, quyền user database và disk còn trống hay không.
8.4. Xem log deploy-check trong systemd journal
sudo journalctl -u example-com-deploy-check.service --since "today"
Điểm hay của việc đóng gói post-deploy check thành systemd service là mọi lần chạy đều có log tập trung. Khi một lần deploy lỗi, bạn không cần nhớ lúc đó terminal in gì; chỉ cần đọc journal.
9. Bảo mật cơ bản cho demo app sau khi deploy
9.1. Không để secret trong public webroot
File .env đã được đặt ở:
/srv/apps/example.com/shared/.env
Nginx chỉ phục vụ:
/srv/apps/example.com/current/public
Cách tách này giúp giảm rủi ro lộ secret. Ngoài ra, Nginx config đã có block chặn dotfiles:
location ~ /\.(?!well-known).* {
deny all;
}
Đây không phải lý do để đặt secret bừa bãi trong webroot. Nguyên tắc vẫn là: secret nên nằm ngoài public path.
9.2. Không in lỗi chi tiết ra người dùng
Trong endpoint /healthz, demo app không in nội dung exception ra response. Đây là chủ ý tốt cho production. Người dùng hoặc bot ngoài internet không cần biết host database, username, stack trace hay đường dẫn file thật trên server.
Khi cần debug, hãy xem log server thay vì in lỗi trực tiếp ra trình duyệt.
9.3. Không để endpoint nội bộ quá nhiều thông tin
Health-check trong bài này chỉ trả về trạng thái cơ bản. Không nên đưa thông tin nhạy cảm vào health-check như:
- Database username.
- Đường dẫn đầy đủ của app.
- Biến môi trường.
- Phiên bản package chi tiết.
- Stack trace khi lỗi.
Endpoint health-check càng ít thông tin càng tốt, miễn là đủ để xác định app còn hoạt động.
9.4. Chuẩn bị cho HTTPS ở bài tiếp theo
Bài này chỉ dùng HTTP để hoàn thiện luồng LEMP và deploy app. Trước khi đưa website ra production thật, bước tiếp theo nên là bật HTTPS bằng Let’s Encrypt, kiểm tra redirect HTTP sang HTTPS và đảm bảo Certbot renew hoạt động ổn.
10. Lỗi thường gặp khi hoàn thiện LEMP và deploy app
10.1. Lỗi 502 Bad Gateway
502 thường xuất hiện khi Nginx không nói chuyện được với PHP-FPM.
Kiểm tra:
sudo nginx -t
sudo systemctl status php8.3-fpm --no-pager
ls -la /run/php/
sudo tail -n 100 /var/log/nginx/example.com.error.log
sudo journalctl -u php8.3-fpm --since "30 minutes ago"
Các nguyên nhân phổ biến:
fastcgi_passtrỏ sai socket.- PHP-FPM chưa chạy.
- Socket PHP-FPM đổi version sau khi nâng cấp PHP.
- Permission khiến Nginx không truy cập được socket hoặc source.
10.2. Lỗi 404 dù file tồn tại
Kiểm tra root và symlink:
sudo nginx -T | grep -n "root /srv/apps" -A5 -B5
readlink -f "$APP_ROOT/current"
ls -la "$APP_ROOT/current/public"
Nếu Nginx trỏ sai root hoặc current trỏ vào release không có thư mục public, request sẽ không đến đúng file.
10.3. Lỗi permission denied
Kiểm tra quyền theo từng lớp:
namei -l /srv/apps/example.com/current/public/index.php
ls -la /srv/apps/example.com/
ls -la /srv/apps/example.com/current/
ls -la /srv/apps/example.com/current/public/
ls -la /srv/apps/example.com/shared/.env
Nginx/PHP-FPM cần quyền traverse thư mục và đọc file PHP. App cần quyền đọc .env và ghi vào thư mục runtime nếu có. Không sửa bằng chmod 777. Hãy chỉnh owner/group và quyền tối thiểu đúng chỗ.
10.4. Health-check báo database failed
Kiểm tra database service:
sudo systemctl status mariadb --no-pager
sudo journalctl -u mariadb --since "30 minutes ago"
Kiểm tra credential:
cat "$APP_ROOT/shared/.env"
mariadb -u appuser -p appdb -e "SELECT 1;"
Nếu lệnh MariaDB CLI chạy được nhưng app vẫn lỗi, kiểm tra PHP extension:
php -m | grep -i mysql
sudo apt install -y php-mysql
sudo systemctl restart php8.3-fpm
10.5. Deploy-check service fail nhưng app vẫn chạy
Đọc log của service:
sudo journalctl -u example-com-deploy-check.service --since "30 minutes ago"
Đừng bỏ qua lỗi chỉ vì trang chủ vẫn mở được. Nếu deploy-check fail, có nghĩa là một điều kiện vận hành chưa đạt: syntax PHP lỗi, Nginx config không hợp lệ, health-check fail, hoặc một service lõi không active.
11. Checklist hoàn thiện LEMP sau khi deploy demo app
11.1. Checklist cấu trúc app
- Code app nằm trong
/srv/apps/example.com/releases. currentlà symlink trỏ đến release đang chạy.- Secret nằm trong
shared/.env, không nằm trong public webroot. - Nginx root trỏ vào
current/public. - App có endpoint
/healthz.
11.2. Checklist service
nginxđang active.php8.3-fpmđang active.mariadbđang active.nginx -tchạy thành công.- Socket PHP-FPM trong Nginx đúng với file thật trong
/run/php/.
11.3. Checklist deploy
- Release mới đã qua kiểm tra
php -l. - Symlink
currentđã trỏ đúng release mới. - Đã chạy
example-com-deploy-check.service. /healthztrả về HTTP 200.- Có biến
PREVIOUS_RELEASEhoặc cách xác định release trước để rollback.
11.4. Checklist log
- Đã xem Nginx access log.
- Đã xem Nginx error log.
- Đã xem PHP-FPM journal.
- Đã xem MariaDB journal nếu app có kết nối database.
- Đã xem journal của deploy-check service.
12. FAQ
12.1. Có bắt buộc phải dùng thư mục /srv/apps không?
Không bắt buộc. Bạn có thể dùng /var/www nếu muốn giữ theo thói quen Nginx phổ biến. Tuy nhiên, /srv/apps là cách đặt tên rõ ràng cho app tự vận hành, dễ tách với config hệ thống và dễ mở rộng khi VPS có nhiều website.
12.2. Vì sao không trỏ Nginx vào thẳng thư mục source?
Vì nhiều app có file không nên public như .env, config, vendor, storage, script deploy hoặc file tạm. Nginx nên trỏ vào thư mục public để chỉ phục vụ phần được thiết kế cho web request.
12.3. Có cần restart PHP-FPM sau mỗi deploy không?
Không phải lúc nào cũng cần. Với app PHP đơn giản, đổi file PHP thường có hiệu lực ngay. Tuy nhiên, nếu dùng OPcache, framework cache, hoặc thay đổi cấu hình môi trường, restart hoặc reload PHP-FPM sau deploy giúp tránh trạng thái cũ còn nằm trong process. Trong bài này, post-deploy check restart PHP-FPM để người mới dễ có trạng thái nhất quán.
12.4. Có nên dùng systemd cho app PHP không?
Với request web PHP truyền thống, bạn thường không cần service riêng cho app vì PHP-FPM đã là service xử lý PHP. Nhưng nếu app có queue worker, scheduler, websocket server hoặc process chạy nền, systemd là lựa chọn tốt để quản lý process đó bằng Restart, WorkingDirectory, User và journal log.
12.5. Health-check nên kiểm tra những gì?
Health-check tối thiểu nên xác nhận app boot được và các dependency quan trọng còn hoạt động. Với bài này, dependency quan trọng là MariaDB. Với app thật, bạn có thể kiểm tra thêm cache, queue hoặc external API, nhưng đừng biến health-check thành một endpoint quá nặng.
12.6. Khi nào nên rollback?
Nên rollback khi release mới làm health-check fail, tăng lỗi 500/502, migration chưa chạy đúng, hoặc log xuất hiện lỗi nghiêm trọng mà bạn chưa xử lý được ngay. Rollback sớm giúp giảm downtime. Sau đó hãy debug trên staging hoặc release mới khác.
12.7. Sau bài này nên làm gì tiếp?
Sau khi demo app đã chạy ổn trên LEMP, bước tiếp theo là bật HTTPS bằng Let’s Encrypt, kiểm tra renew, cấu hình redirect HTTP sang HTTPS và bắt đầu tối ưu web hosting bằng gzip, cache, headers, HTTP/2 và log format.
13. Kết luận
Hoàn thiện LEMP không chỉ là cài đủ Nginx, PHP-FPM và MariaDB. Một stack production-ready tối thiểu cần có cấu trúc deploy rõ ràng, app chạy thật, secret nằm ngoài webroot, Nginx trỏ đúng thư mục public, PHP-FPM xử lý đúng socket, database credential được kiểm tra, health-check hoạt động và log đủ để debug.
Trong bài này, bạn đã dựng một demo app PHP có endpoint /healthz, kết nối MariaDB, chạy sau Nginx và PHP-FPM, dùng cấu trúc releases/current/shared để deploy có rollback, đồng thời tạo một systemd oneshot service để kiểm tra sau deploy.
Khi đã có workflow này, các bước tiếp theo như bật HTTPS, thêm backup, monitoring, logging nâng cao và tuning hiệu năng sẽ dễ hơn rất nhiều. Bạn không còn vận hành VPS bằng cảm giác, mà đã có một quy trình rõ ràng: deploy, kiểm tra, đọc log, rollback nếu cần, rồi mới tối ưu.

