+1

Từ Lý Thuyết Đến Thực Tế: Deploy SaaS $6/Tháng - Docker 4-Stage Build, Neon.tech PostgreSQL, và Những Bài Học Vận Hành Thực Tế

Tiếp theo series Từ Lý Thuyết Đến Thực Tế, cũng đã đi qua các giai đoạn từ ý tưởng đến thiết kế và triển khai các chức năng, màn hình, module tính tiền, giờ đã đến lúc deploy lên VPS, tôi chọn Digital Ocean. Ngoài bài toán chọn VPS nào, vấn đề lớn khác là chi phí bao nhiêu cho vận hành, giai đoạn đầu tôi quyết định 6$ cho mỗi tháng, bài này sẽ đi giải bài toán này.


Đầu Tiên: Chi Phí

Khi bắt đầu, tôi đặt ra một ràng buộc cứng: toàn bộ hạ tầng không được vượt quá $15/tháng trong giai đoạn đầu.

Sau khi so sánh các lựa chọn:

Lựa chọn Chi phí/tháng Trade-off
DigitalOcean App Platform ~$50–100 Managed hoàn toàn, không control được
VPS $24 + Managed DB $15 ~$40 Ổn nhưng vẫn giá cao
VPS $6 + Neon.tech free ~$6 Phải tự quản lý nhiều hơn
Heroku ~$25+ Đã giảm free tier

Tôi chọn DigitalOcean Droplet $6/tháng (1 vCPU, 1GB RAM, Singapore SGP1) kết hợp Neon.tech PostgreSQL free tier. Latency Singapore → Việt Nam khoảng 30ms — chấp nhận được cho một ứng dụng B2B không có yêu cầu real-time.


Dockerfile 4-Stage: Chia Để Trị

Cấu trúc Dockerfile ban đầu — Xdebug chạy trên production — có nguồn gốc từ một Dockerfile duy nhất dùng cho cả dev lẫn prod. Cách fix: tách thành nhiều stage, mỗi stage có mục đích riêng.

# ─────────────────────────────────────
# Stage 1: node-builder — build Vite assets
# ─────────────────────────────────────
FROM node:20-alpine AS node-builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources/ ./resources/
COPY vite.config.ts tsconfig.json ./
RUN npm run build
# Output: public/build/ với content-hashed JS/CSS

# ─────────────────────────────────────
# Stage 2: php-base — shared PHP foundation
# ─────────────────────────────────────
FROM php:8.3-fpm-alpine AS php-base

RUN apk add --no-cache \
    postgresql-dev \
    libpng-dev \
    libzip-dev \
    && docker-php-ext-install \
        pdo_pgsql \
        gd \
        zip \
        opcache

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html

# ─────────────────────────────────────
# Stage 3: development — thêm Xdebug
# ─────────────────────────────────────
FROM php-base AS development

RUN pecl install xdebug \
    && docker-php-ext-enable xdebug

COPY docker/web/php.ini /usr/local/etc/php/conf.d/app.ini
# Source code được mount qua volume — không COPY vào image

# ─────────────────────────────────────
# Stage 4: production — source baked in
# ─────────────────────────────────────
FROM php-base AS production

# KHÔNG có Xdebug
# KHÔNG có dev tools
COPY docker/web/php.ini /usr/local/etc/php/conf.d/app.ini

# Copy source code vào image
COPY --chown=www-data:www-data . /var/www/html

# Copy Vite assets từ node-builder
COPY --from=node-builder /app/public/build /var/www/html/public/build

# Install PHP dependencies (production only)
RUN composer install --no-dev --optimize-autoloader --no-interaction

RUN apk add --no-cache supervisor

EXPOSE 9000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

Lý do chọn thiết kế này:

Stage 1 và Stage 2 độc lập — Vite build không cần PHP, PHP không cần Node. Docker cache từng stage riêng. Thay đổi TypeScript không trigger rebuild PHP layer. Thay đổi composer.json không trigger npm ci.

Stage 3 là superset của Stage 2 — Development chỉ thêm Xdebug lên trên base. Source code được mount qua volume, không baked vào image, nên reload ngay khi sửa file.

Stage 4 không kế thừa Stage 3 — Production extend từ php-base, không phải development. Không thể accidentally có Xdebug trong production image dù bạn muốn.

Source code baked vào production image — Khi image được pull xuống server, không cần git pull, không cần composer install, không cần npm build. Container start là chạy được ngay. Điều này cũng có nghĩa mỗi image là một snapshot bất biến — rollback chỉ là docker pull image:previous-tag.


Vết Start Script cho Entrypoint

Image production build xong còn một bước nữa: entrypoint script chạy khi container khởi động.

#!/bin/sh
set -e

echo "[start] Checking storage directories..."
mkdir -p \
    storage/framework/cache/data \
    storage/framework/sessions \
    storage/framework/views \
    storage/logs \
    storage/app/pdfs
chown -R www-data:www-data storage bootstrap/cache

echo "[start] Copying public assets..."
# Public assets của PHP cần share với Nginx container
# Mỗi lần deploy image mới → Nginx nhận assets mới ngay
cp -rT public.docker public

echo "[start] Running migrations..."
php artisan migrate --force --no-interaction

echo "[start] Caching config and routes..."
php artisan config:cache
php artisan route:cache
php artisan view:cache

echo "[start] Starting services..."
exec supervisord -c /etc/supervisord.conf

Một trick nhỏ nhưng quan trọng: cp -rT public.docker public.

Vite build tạo ra file JS/CSS với content hash trong tên (app.abc123.js). Nginx cần serve những file này. Nhưng Nginx và PHP-FPM là hai container riêng — chúng share volume để Nginx đọc được static file.

Thay vì COPY assets vào volume lúc build (phức tạp), tôi dùng cách đơn giản hơn: PHP container giữ một thư mục public.docker chứa assets, khi khởi động thì copy sang public (là thư mục được mount share với Nginx). Mỗi lần deploy image mới, Nginx nhận assets mới ngay mà không cần restart.


Neon.tech Thay Vì Self-Host PostgreSQL

Câu hỏi lúc đó: có nên chạy PostgreSQL container trong Droplet $6 không?

1GB RAM. PostgreSQL production cần ít nhất 256MB để hoạt động ổn định. PHP-FPM với 10 worker tiêu thêm ~500MB. Nginx, hệ thống, cache — 200MB nữa. Tổng là gần đủ bộ nhớ, không có buffer cho lượng truy cập tăng đột biến hay memory leak nhỏ.

Tôi chọn Neon.tech free tier: PostgreSQL 16 fully managed, 0.5 GB storage, serverless scale-to-zero khi không có truy cập. Latency từ Singapore đến Neon cluster ổn định khoảng 5–15ms cho queries đơn giản — chấp nhận được.

Sự đánh đổi:

Scale-to-zero nghĩa là connection đầu tiên sau một thời gian không dùng có thể mất 1–2 giây "cold start". Với SaaS B2B chạy trong giờ hành chính, điều này thực tế không ảnh hưởng — user đầu tiên mỗi sáng có thể thấy load chậm hơn vài giây.

Free tier giới hạn connections. PostgreSQL mặc định cho phép nhiều concurrent connection, nhưng Neon free tier có giới hạn thấp hơn. Giải pháp: cấu hình DB_POOL_MAX=3 trong Laravel, kết hợp PHP-FPM pool nhỏ hơn mặc định.

Không có persistent storage trên Droplet để lo. Toàn bộ data nằm trên Neon. Khi Droplet die, data an toàn.

.env.prod kết nối Neon:

DB_CONNECTION=pgsql
DB_HOST=ep-xxx-yyy.ap-southeast-1.aws.neon.tech
DB_PORT=5432
DB_DATABASE=neondb
DB_USERNAME=neondb_owner
DB_PASSWORD=your-neon-password
DB_SSLMODE=require

Bỏ Redis — Để tối giản

Hầu hết tutorial Laravel production đều recommend Redis cho cache, session, và queue. Tôi bỏ hẳn Redis

Session: SESSION_DRIVER=database. Bảng sessions trong PostgreSQL. Với vài chục concurrent user, database session hoàn toàn đủ.

Cache: CACHE_STORE=database. Tương tự. Không có heavy caching nào cần in-memory store ở giai đoạn này.

Queue: QUEUE_CONNECTION=database. Job được lưu vào bảng jobs trong PostgreSQL (đã có sẵn trên Neon.tech) và xử lý bất đồng bộ bởi queue worker chạy trong cùng container — không cần Redis, không cần Droplet thêm.

QUEUE_CONNECTION=database

Queue worker chạy song song với PHP-FPM trong container thông qua Supervisor:

; docker/web/supervisord.conf
[supervisord]
nodaemon=true

[program:php-fpm]
command=php-fpm
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0

[program:queue-worker]
command=php /var/www/html/artisan queue:work database --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0

Và entrypoint thay exec php-fpm bằng exec supervisord -c /etc/supervisord.conf.

Sự đánh đổi:

  • Không cần Redis riêng — bảng jobs trong PostgreSQL đủ cho tải nhỏ
  • Job fail được lưu vào bảng failed_jobs — có thể retry thủ công (php artisan queue:retry all) hoặc inspect nguyên nhân
  • Worker chạy trong cùng Docker container, thêm khoảng ~30–50MB RAM — chấp nhận được trong ngân sách 1GB
  • Nếu Brevo SMTP chậm, request trả về ngay, email gửi async sau — không block user

Khi nào cần thêm? Khi failed_jobs tích lũy nhiều mà không có retry pattern rõ ràng, hoặc khi job processing trở thành bottleneck — lúc đó chuyển sang Redis thì có thể chuyển đổi nhanh chóng, không phải thay đổi kiến trúc.


Nginx: Wildcard SSL và Asset Caching

Nginx nằm phía trước PHP-FPM, xử lý hai việc: SSL termination cho wildcard domain và static asset serving.

SSL wildcard *.quanlynhatro.net từ Let's Encrypt:

certbot certonly \
    --standalone \
    -d "*.quanlynhatro.net" \
    -d "quanlynhatro.net" \
    --email admin@quanlynhatro.net \
    --agree-tos \
    --preferred-challenges dns-01

DNS challenge cần tạo TXT record _acme-challenge.quanlynhatro.net tại DNS provider. Một bất tiện nhỏ khi cấp cert lần đầu, nhưng cert wildcard này dùng cho tất cả subdomain tenant mà không cần cấp lại.

Config Nginx cho asset caching bất biến:

# Static assets với content hash — cache 1 năm
location ~* \.(js|css)$ {
    root /var/www/html/public;
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Vary Accept-Encoding;
    gzip_static on;
}

# PHP requests
location / {
    try_files $uri $uri/ @php;
}

location @php {
    fastcgi_pass   app:9000;
    fastcgi_param  SCRIPT_FILENAME /var/www/html/public/index.php;
    include        fastcgi_params;
}

immutable trong Cache-Control là keyword cần quan tâm: nó báo browser "file này sẽ không bao giờ thay đổi với tên này, đừng xác nhận lại dù hết TTL". Kết hợp với content hash của Vite, browser cache JS/CSS một năm và không bao giờ stale — vì khi code thay đổi, tên file thay đổi, browser tự động tải file mới.


Deploy Flow: Từ Git Push Đến Production

# Trên CI hoặc local
docker build \
    --target production \
    -t app:$(git rev-parse --short HEAD) \
    .

docker push registry/app:$(git rev-parse --short HEAD)

# Trên server
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d --build --no-deps app

--no-deps để chỉ rebuild container app, không restart Nginx (tránh thời gian gián đoạn với user đang active).

Entrypoint script lo phần còn lại: migrate, cache, copy assets. Nếu migration fail, container không start. Server bắt buộc phải healthy trước khi nhận traffic.


Ba Sự Cố Thực Tế Và Cách Xử Lý

1. Xdebug Trong Production (sự cố đầu tiên)

Đã đề cập ở mở đầu. Fix: tạo stage riêng như mô tả ở trên. Verify bằng lệnh:

docker run --rm app:latest php -m | grep xdebug
# Phải không ra gì — xdebug không có trong production image

Rút ra là: không chỉ test code, test cả image. docker run --rm image:tag php -m là một smoke test đáng thêm vào CI pipeline.

2. PDF Sinh Lỗi Font Tiếng Việt

DomPDF cần font hỗ trợ ký tự Unicode để render tiếng Việt đúng. Image Alpine mặc định không có font này.

# Trong php-base stage
RUN apk add --no-cache \
    fontconfig \
    freetype \
    && mkdir -p /usr/share/fonts/truetype \
    && wget -q https://github.com/google/fonts/raw/main/ofl/bevietnampro/BeVietnamPro-Regular.ttf \
         -O /usr/share/fonts/truetype/BeVietnamPro-Regular.ttf \
    && fc-cache -f -v

Điểm khó: lỗi này chỉ xuất hiện khi PDF chứa ký tự đặc biệt như "ổ", "ắ", "ệ". Test với tenant name "Nguyễn Văn A" là bắt được ngay.

3. Neon.tech Connection Timeout Khi Idle

Scale-to-zero của Neon hoạt động bằng cách terminate connection sau một khoảng thời gian không activity. Laravel's persistent connection (PDO::ATTR_PERSISTENT = true) lưu connection cũ, khi dùng lại thì fail.

Fix trong config/database.php:

'pgsql' => [
    'driver'   => 'pgsql',
    // ...
    'options' => [
        PDO::ATTR_PERSISTENT         => false, // KHÔNG dùng persistent với Neon
        PDO::ATTR_CONNECT_TIMEOUT    => 5,
        PDO::ATTR_TIMEOUT            => 30,
    ],
],

Đồng thời cấu hình PHP-FPM với pool nhỏ hơn mặc định:

; docker/web/php-fpm.conf
[www]
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500  ; Restart worker process sau 500 requests — giảm memory leak

pm.max_requests = 500 là một best practice: PHP có thể có memory leak tích lũy theo request. Restart worker process định kỳ giữ memory footprint ổn định.


Chi Phí Thực Tế

Dịch vụ Chi phí/tháng Ghi chú
DigitalOcean Droplet $6 1 vCPU, 1GB RAM, SGP1
Neon.tech PostgreSQL $0 Free tier, 0.5GB
Brevo SMTP $0 Free tier, 300 email/ngày
Let's Encrypt SSL $0 Wildcard cert
Tổng $6

Tổng chi phí hạ tầng cho 3 tháng: $18.

Khi nào cần scale? Dấu hiệu tôi theo dõi: RAM usage liên tục trên 80% (hiện tại peak khoảng 65%), response time p95 vượt 800ms (hiện tại khoảng 350ms), Neon connection đạt giới hạn. Chưa có dấu hiệu nào. Khi có, bước tiếp theo là upgrade Droplet lên $12 và chuyển Neon lên paid plan.


Bonus một số cái nên làm thêm

Thêm health check endpoint. Một route /health trả về JSON với status của database connection, disk space, queue depth — sẽ rất cần khi khi debug.

Tag mọi Docker image với git commit hash, không chỉ latest. Rollback thành docker pull app:abc1234 thay vì git checkout HEAD~1 rồi rebuild — nhanh hơn nhiều.

Backup tự động database. Neon có built-in backup, nhưng nên thêm cron job chạy pg_dump hàng ngày và push lên DigitalOcean Spaces — chi phí ~$1/tháng cho 30 ngày backup.

Monitor memory trên Droplet $6. 1GB RAM không nhiều. free -h trong cron mỗi 30 phút, alert email nếu available < 150MB. Không cần Datadog hay Grafana — một script bash 5 dòng là đủ.


Kết

Cuối cùng vói bài toán chi phí 6$ cũng đã chạy được SaaS lên môi trường Production. Việc đánh đổi chọn cái A hay cái B cũng là một quyết định khó khăn cần đo lường theo thời gian, hiện cũng đã có cái checklist theo dõi đơn giản, kiến trúc vps thiết kế ban đầu có thể bóc tách và nâng cấp dễ dàng.

Khi hệ thống lớn hơn có thể những giả định này ko còn đúng nữa, sẽ tiến hành điều tra và nâng cấp dần. Hành trình Serias cuối cùng kết tại bài này, cám ơn các bạn đã dành thời gian xem qua.

Những bài trước có thể xem thêm:


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí