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 /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 . /var/www/html
# Copy Vite assets từ node-builder
COPY /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
jobstrong 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:
- Hành Trình Build SaaS Quản Lý Cho Thuê: Laravel + Vue 3 + PostgreSQL Từ Ý Tưởng Đến Production
- Hành Trình Build SaaS Quản Lý Cho Thuê: Laravel + Vue 3 + PostgreSQL Từ Ý Tưởng Đến Production
- SqlQueryManager — tại sao bỏ Eloquent ORM và build tầng data access bằng raw SQL là quyết định đúng cho SaaS multi-tenant
- Thiết Kế Module Invoice Nâng Cao - 3 Dạng Tính Điện/Nước, Dịch Vụ Động, và InvoiceCalculator Thuần Túy không phụ thuộc database
- Module Subscription - Thiết Kế Gói Cước và Billing Cho SaaS Multi-Tenant Laravel
All rights reserved