Back to blog

Build a Personal Blog — Phase 5: Docker Compose

dockernextjspostgresqldevopstypescript
Build a Personal Blog — Phase 5: Docker Compose

Đây là Phase 5 của series Build a Personal Blog. Blog của bạn đã có MDX rendering, PostgreSQL database, tags, search, và pagination. Chạy ngon lành trên laptop với npm run dev. Nhưng khi muốn chạy trên VPS thì sao? Bạn phải cài Node.js, setup PostgreSQL, quản lý environment variables, rồi cầu nguyện mọi thứ chạy giống như ở local. Docker giải quyết tất cả.

Series: Build a Personal Blog — Complete Roadmap
Trước: Phase 4 — Tags, Search & Pagination
Tiếp: Phase 6 — Deploy lên Ubuntu VPS trên Hostinger
Source Code: GitHub — personal-blog-phase-5


Bạn Sẽ Build Được Gì

Sau phase này:

✅ Multi-stage Dockerfile tạo ra image Next.js production siêu nhẹ
✅ docker-compose.yml cho local development với hot reload
✅ docker-compose.prod.yml override cho production settings
✅ Tự động chạy database migrations khi container khởi động
✅ Health checks cho cả PostgreSQL và Next.js app
✅ .dockerignore giữ image nhỏ gọn và build nhanh
✅ Quản lý environment variables xuyên suốt dev, staging, và production

Time commitment: 2–3 giờ
Prerequisites: Phase 4 — Tags, Search & Pagination


Tại Sao Dùng Docker Cho Blog?

Bạn có thể nghĩ: "Chỉ là blog thôi mà — cần gì Docker?" Câu hỏi hợp lý. Đây là lý do:

Vấn đềKhông có DockerCó Docker
"Máy tao chạy được mà"Version Node.js khác nhau, OS khác nhauEnvironment giống hệt mọi nơi
Setup PostgreSQLCài local, tạo user, cấu hình portdocker compose up — xong
Onboard máy mớiREADME 15 bướcgit clone + docker compose up
Deploy productionSSH vào, cài đủ thứ, cầu nguyệnCũng docker compose up luôn
Rollback"Trước chạy version nào nhỉ?"docker compose up với image tag cũ

Insight quan trọng: Docker không chỉ giúp deployment — nó làm cho local development có thể tái tạo được. Một lệnh duy nhất khởi chạy toàn bộ stack.


1. Bật Standalone Output Trong Next.js

Next.js có thể tạo standalone build — một folder tự chứa chỉ gồm những file cần thiết để chạy app, không cần node_modules. Đây là yếu tố then chốt cho Docker image nhỏ gọn.

Mở next.config.mjs (hoặc next.config.ts) và thêm option output:

// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: "standalone",
};
 
export default nextConfig;

output: "standalone" làm gì:

  • Trace tất cả imports và copy chỉ những file node_modules cần thiết vào .next/standalone/
  • Tạo entry point server.js — chạy bằng node .next/standalone/server.js
  • Giảm image cuối cùng từ ~1 GB (full node_modules) xuống ~100-150 MB

Lưu ý: Static assets (folder public/ và .next/static/) KHÔNG nằm trong standalone output. Bạn sẽ copy chúng thủ công trong Dockerfile.


2. Tạo Dockerfile

Đây là trái tim của containerization. Chúng ta dùng multi-stage build với ba stage:

  1. deps — cài tất cả node_modules
  2. builder — build Next.js app
  3. runner — copy chỉ những gì cần để chạy

Tạo Dockerfile ở root project:

# Dockerfile
 
# ============================================
# Stage 1: Cài dependencies
# ============================================
FROM node:20-alpine AS deps
WORKDIR /app
 
# Copy package files
COPY package.json package-lock.json ./
 
# Cài tất cả dependencies (bao gồm devDependencies để build)
RUN npm ci
 
# ============================================
# Stage 2: Build ứng dụng
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
 
# Copy dependencies từ deps stage
COPY --from=deps /app/node_modules ./node_modules
 
# Copy source code
COPY . .
 
# Build arguments cho environment variables cần lúc build
ARG DATABASE_URL
ENV DATABASE_URL=${DATABASE_URL}
 
# Chạy database migrations trước khi build
RUN npx drizzle-kit push
 
# Build Next.js (standalone output)
RUN npm run build
 
# ============================================
# Stage 3: Production runner
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
 
# Set production environment
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
# Tạo non-root user cho security
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs
 
# Copy standalone server
COPY --from=builder /app/.next/standalone ./
 
# Copy static assets (không nằm trong standalone)
COPY --from=builder /app/.next/static ./.next/static
 
# Copy public folder (images, fonts, v.v.)
COPY --from=builder /app/public ./public
 
# Set đúng permissions
RUN chown -R nextjs:nodejs /app
 
# Chuyển sang non-root user
USER nextjs
 
# Expose port
EXPOSE 3000
 
# Set hostname cho Next.js
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
 
# Khởi chạy server
CMD ["node", "server.js"]

Tại Sao Multi-Stage?

Mỗi stage bắt đầu từ base image sạch. Chỉ những dòng COPY --from=builder cuối cùng quyết định cái gì nằm trong production image. Nghĩa là:

  • Build tools, TypeScript compiler, dev dependencies → KHÔNG có trong image cuối
  • Source code (file .ts, .tsx) → KHÔNG có trong image cuối
  • Chỉ compiled output + runtime dependencies → có trong image cuối

Tại Sao Non-Root User?

Chạy dưới root trong container là rủi ro security. Nếu attacker khai thác được app, họ có quyền root trong container (và có thể thoát ra ngoài). User nextjs chỉ truy cập được /app — không gì khác.


3. Tạo .dockerignore

Không có .dockerignore, Docker copy mọi thứ trong project vào build context — bao gồm node_modules, .git, và .next. Build sẽ chậm và image phình to.

Tạo .dockerignore ở root project:

# .dockerignore
 
# Dependencies (cài mới trong Docker)
node_modules
 
# Build output (build lại trong Docker)
.next
 
# Version control
.git
.gitignore
 
# IDE và editor files
.vscode
.idea
*.swp
*.swo
 
# Environment files (mount lúc runtime, không bake vào image)
.env
.env.local
.env.production
 
# Docker files (không cần copy chính nó)
Dockerfile
docker-compose*.yml
.dockerignore
 
# Documentation
README.md
PLANNING.md
CLAUDE.md
LICENSE
 
# OS files
.DS_Store
Thumbs.db

Tại sao exclude file .env? Environment variables nên được inject lúc runtime (qua docker-compose.yml hoặc --env-file), không nên bake vào image. Bake secrets vào image là rủi ro security — bất kỳ ai có access image đều có thể extract chúng ra.


4. Setup Docker Compose Cho Development

Docker Compose cho phép bạn define và chạy multi-container applications. Cho local development, bạn cần hai services: Next.js app và PostgreSQL.

Tạo docker-compose.yml ở root project:

# docker-compose.yml — Cấu hình local development
 
services:
  # PostgreSQL database
  db:
    image: postgres:17-alpine
    container_name: blog-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-bloguser}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-blogpass}
      POSTGRES_DB: ${POSTGRES_DB:-blogdb}
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bloguser} -d ${POSTGRES_DB:-blogdb}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
 
  # Next.js application
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        DATABASE_URL: postgresql://${POSTGRES_USER:-bloguser}:${POSTGRES_PASSWORD:-blogpass}@db:5432/${POSTGRES_DB:-blogdb}
    container_name: blog-app
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER:-bloguser}:${POSTGRES_PASSWORD:-blogpass}@db:5432/${POSTGRES_DB:-blogdb}
      NODE_ENV: production
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
 
volumes:
  postgres_data:
    driver: local

Các Quyết Định Thiết Kế Quan Trọng

depends_on với condition: service_healthy: App container sẽ không start cho đến khi PostgreSQL báo healthy. Không có cái này, app sẽ crash khi khởi động vì database chưa sẵn sàng.

Named volume postgres_data: Data của bạn persist xuyên suốt container restarts và rebuilds. Không có nó, bạn sẽ mất hết data mỗi lần chạy docker compose down.

Health checks: Docker biết khi nào services thực sự sẵn sàng, không chỉ "đã start". Lệnh pg_isready kiểm tra PostgreSQL có thể nhận connections. Lệnh wget kiểm tra Next.js trả lời HTTP requests.

Variable defaults (${POSTGRES_USER:-bloguser}): Chạy được ngay mà không cần file .env, nhưng bạn có thể override bằng cách tạo một file.


5. Tạo File .env Cho Local Development

Tạo .env ở root project cho local development:

# .env — Environment variables cho local development
 
# PostgreSQL
POSTGRES_USER=bloguser
POSTGRES_PASSWORD=blogpass
POSTGRES_DB=blogdb
 
# Database URL (dùng cho Drizzle ORM và app)
DATABASE_URL=postgresql://bloguser:blogpass@db:5432/blogdb

Quan trọng: Chú ý hostname là db, không phải localhost. Bên trong Docker network, các services tham chiếu nhau bằng service name được define trong docker-compose.yml.

Thêm .env vào .gitignore nếu chưa có:

# .gitignore
.env
.env.local
.env.production

Và tạo file .env.example để document:

# .env.example — Copy thành .env và điền giá trị của bạn
 
# PostgreSQL
POSTGRES_USER=bloguser
POSTGRES_PASSWORD=changeme
POSTGRES_DB=blogdb
 
# Database URL
DATABASE_URL=postgresql://bloguser:changeme@db:5432/blogdb

6. Production Overrides Với docker-compose.prod.yml

Docker Compose hỗ trợ override files. File base docker-compose.yml define các services, còn docker-compose.prod.yml thêm production-specific settings.

Tạo docker-compose.prod.yml:

# docker-compose.prod.yml — Production overrides
 
services:
  db:
    restart: always
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    # Không expose port 5432 ra host ở production
    ports: !override []
 
  app:
    restart: always
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

Thay Đổi Gì Cho Production?

SettingDevProduction
Restart policyunless-stoppedalways (survive server reboot)
DB port exposed5432:5432 (cho local tools)Không expose (chỉ app truy cập được)
Log rotationUnlimited10 MB × 3 files (tối đa 30 MB)

Log rotation cực kỳ quan trọng cho production. Không có nó, container logs cứ tăng mãi và cuối cùng đầy ổ đĩa. Driver json-file với max-size và max-file tự động xoay vòng logs.

Bỏ DB port ở production nghĩa là PostgreSQL chỉ truy cập được từ bên trong Docker network. Không ai connect được từ internet, kể cả khi firewall cho phép port 5432.

Chạy Với Production Overrides

# Development (mặc định)
docker compose up -d --build
 
# Production (merge cả hai files)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build

Khi bạn truyền nhiều flag -f, Docker Compose deep-merge các files. Giá trị trong file sau override file trước.


7. Chạy Migrations Trong Docker

Ở Phase 3, bạn dùng npx drizzle-kit push để apply database schema changes. Trong Docker, migrations chạy trong build stage — trước khi production image được tạo.

Nhìn lại Dockerfile:

# Trong builder stage
ARG DATABASE_URL
ENV DATABASE_URL=${DATABASE_URL}
 
# Chạy database migrations trước khi build
RUN npx drizzle-kit push

Nghĩa là migrations được apply mỗi lần bạn build image. Với personal blog, cách này đơn giản và hiệu quả.

Alternative: Runtime Migrations

Nếu bạn muốn chạy migrations khi container start (không phải lúc build image), dùng entrypoint script:

#!/bin/sh
# scripts/docker-entrypoint.sh
 
set -e
 
echo "Đang chạy database migrations..."
npx drizzle-kit push
 
echo "Khởi chạy Next.js server..."
exec node server.js

Rồi update runner stage trong Dockerfile:

# Copy migration files cần lúc runtime
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/lib/db ./lib/db
COPY --from=deps /app/node_modules ./node_modules
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
 
RUN chmod +x docker-entrypoint.sh
 
CMD ["./docker-entrypoint.sh"]

Trade-off: Build-time migrations đơn giản hơn nhưng cần database access lúc build. Runtime migrations không cần DB access lúc build nhưng tăng startup latency và cần nhiều files hơn trong production image.

Trong series này, chúng ta sẽ dùng build-time migrations vì database luôn available qua Docker Compose.


8. Quản Lý Environment Variables

Quản lý environment variables xuyên suốt các environments là một trong những phần hóc búa nhất của Docker. Đây là strategy:

Ba File

FileTrong Git?Mục đích
.env.example✅ CóDocument các variables cần thiết với placeholder values
.env❌ KhôngGiá trị local development (Docker Compose tự load)
.env.production❌ KhôngProduction secrets (chỉ tồn tại trên VPS)

Dùng Production .env File

Trên VPS, bạn sẽ tạo .env.production với credentials thật:

# .env.production (chỉ trên VPS — KHÔNG BAO GIỜ commit)
 
POSTGRES_USER=bloguser
POSTGRES_PASSWORD=s3cur3_pr0duct10n_p4ss!
POSTGRES_DB=myblog
 
DATABASE_URL=postgresql://bloguser:s3cur3_pr0duct10n_p4ss!@db:5432/myblog

Rồi reference nó khi start:

docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production up -d --build

9. Cấu Trúc Project Sau Phase 5

Project giờ có thêm các file mới:

my-blog/
├── Dockerfile                      ← MỚI (multi-stage build)
├── .dockerignore                   ← MỚI (giữ build nhanh)
├── docker-compose.yml              ← MỚI (cấu hình dev)
├── docker-compose.prod.yml         ← MỚI (production overrides)
├── .env                            ← MỚI (local dev variables, không trong Git)
├── .env.example                    ← MỚI (template đã commit)
├── next.config.ts                  ← ĐÃ SỬA (output: "standalone")
├── .gitignore                      ← ĐÃ SỬA (thêm .env files)
├── app/
│   ├── api/
│   ├── blog/
│   └── ...
├── components/
├── content/posts/
├── lib/
│   ├── db/
│   │   ├── index.ts
│   │   ├── schema.ts
│   │   └── queries.ts
│   └── posts.ts
├── public/
│   └── images/
└── ...

10. Build Và Test Locally

Giờ là lúc verify mọi thứ hoạt động. Chạy từng bước:

Khởi Chạy Stack

# Build và start tất cả services
docker compose up -d --build

Output mong đợi:

[+] Building 45.2s (17/17) FINISHED
 => [deps] npm ci                                           12.3s
 => [builder] npm run build                                 28.4s
 => [runner] COPY --from=builder                             0.3s
[+] Running 3/3
 ✔ Network my-blog_default     Created
 ✔ Container blog-db           Healthy
 ✔ Container blog-app          Started

Kiểm Tra Trạng Thái Services

docker compose ps

Bạn sẽ thấy cả hai containers đang chạy và healthy:

NAME         IMAGE          STATUS                    PORTS
blog-db      postgres:17    Up 2 minutes (healthy)    0.0.0.0:5432->5432/tcp
blog-app     my-blog-app    Up 1 minute (healthy)     0.0.0.0:3000->3000/tcp

Test Ứng Dụng

  1. Mở http://localhost:3000 — blog load bình thường
  2. Click quanh — posts, tags, search đều hoạt động
  3. Kiểm tra view counter — nó tăng lên (database đang hoạt động)

Xem Logs

# Tất cả services
docker compose logs -f
 
# Chỉ app
docker compose logs -f app
 
# Chỉ database
docker compose logs -f db

Dừng Mọi Thứ

# Dừng containers (data persist trong volume)
docker compose down
 
# Dừng và xoá data volume (bắt đầu lại từ đầu)
docker compose down -v

11. Các Lệnh Docker Hữu Ích

Quick reference cho những lệnh bạn dùng hàng ngày:

# Rebuild sau khi thay đổi code
docker compose up -d --build
 
# Restart một service
docker compose restart app
 
# Mở shell bên trong app container
docker compose exec app sh
 
# Mở psql session trong database
docker compose exec db psql -U bloguser -d blogdb
 
# Kiểm tra image sizes
docker images | grep blog
 
# Xoá dangling images (giải phóng disk space)
docker image prune -f
 
# Xem resource usage
docker stats

Kiểm Tra Image Size

Sau khi build, kiểm tra size image:

docker images | grep blog

Mong đợi:

my-blog-app    latest    abc123    2 minutes ago    ~150 MB

So sánh với Dockerfile đơn giản copy toàn bộ node_modules:

my-blog-app    latest    def456    5 minutes ago    ~1.2 GB

Multi-stage build với standalone output tiết kiệm khoảng 1 GB mỗi image.


Các Vấn Đề Thường Gặp

Container không kết nối được database

Nguyên nhân phổ biến nhất là dùng localhost thay vì db trong DATABASE_URL:

# ❌ Sai — localhost chỉ đến chính app container
DATABASE_URL=postgresql://bloguser:blogpass@localhost:5432/blogdb
 
# ✅ Đúng — 'db' là service name trong docker-compose.yml
DATABASE_URL=postgresql://bloguser:blogpass@db:5432/blogdb

Build fail với "Cannot find module"

Thường là do lệnh COPY trong Dockerfile thiếu file. Kiểm tra .dockerignore có đang exclude thứ bạn cần không. Nghi phạm phổ biến:

# Những file này KHÔNG NÊN nằm trong .dockerignore
# drizzle.config.ts     ← cần cho migrations
# lib/                  ← cần cho build
# content/              ← cần cho MDX posts

Mất data database sau docker compose down

Mặc định, docker compose down dừng và xoá containers nhưng giữ volumes. Nếu bạn dùng docker compose down -v, flag -v xoá luôn volumes — nơi PostgreSQL lưu data.

# An toàn — data vẫn còn
docker compose down
 
# Nguy hiểm — data bị xoá
docker compose down -v

Build chậm

Docker cache mỗi layer. Nếu bạn thay đổi package.json, layer npm ci bị invalidate và tất cả layers sau đó rebuild. Dockerfile phía trên tối ưu điều này bằng cách copy package.json trước, rồi mới copy source code — nên dependency installation được cache khi chỉ thay đổi source code.

Nếu build vẫn chậm, kiểm tra .dockerignore đã exclude node_modules và .next chưa. Không có nó, Docker gửi hàng GB context đến build daemon.

Port 5432 đã bị chiếm

Nếu bạn có PostgreSQL cài locally, nó có thể xung đột với Docker container:

# Kiểm tra cái gì đang dùng port 5432
lsof -i :5432
 
# Cách 1: Dừng PostgreSQL local
brew services stop postgresql
 
# Cách 2: Đổi Docker port mapping
# Trong docker-compose.yml, đổi "5432:5432" thành "5433:5432"
# Rồi connect bằng port 5433 từ host

Tổng Kết

Trong phase này bạn đã:

✅ Bật standalone output trong Next.js cho Docker images tối giản
✅ Tạo multi-stage Dockerfile (deps → builder → runner) cho ra image ~150 MB
✅ Setup Docker Compose với PostgreSQL health checks và named volumes
✅ Thêm production overrides với log rotation và restricted port access
✅ Cấu hình database migrations chạy tự động lúc build
✅ Thiết lập quản lý environment variables xuyên suốt dev và production

Blog của bạn giờ chạy trong containers — đúng những containers sẽ chạy trên production VPS. Không còn chuyện "máy tao chạy được mà" nữa.


Tiếp Theo Là Gì?

Trong Phase 6, bạn sẽ mang những Docker containers này deploy lên Ubuntu VPS trên Hostinger. Bạn sẽ setup SSH access, cài Docker trên server, cấu hình Nginx làm reverse proxy, và lấy SSL certificates miễn phí từ Let's Encrypt. Một lệnh docker compose up trên VPS và blog của bạn sẽ live.

Bài tiếp: Phase 6 — Deploy lên Ubuntu VPS trên Hostinger


Mục Lục Series

BàiTiêu đềTrạng thái
BLOG-1Xây Dựng Blog Cá Nhân — Lộ Trình✅ Hoàn thành
BLOG-2Phase 1: Khởi Tạo Project — Next.js 16 + ShadCN/UI✅ Hoàn thành
BLOG-3Phase 2: Render MDX Theo Yêu Cầu✅ Hoàn thành
BLOG-4Phase 3: PostgreSQL + Drizzle ORM✅ Hoàn thành
BLOG-5Phase 4: Tags, Search & Pagination✅ Hoàn thành
BLOG-6Phase 5: Docker Compose✅ Bạn đang ở đây
BLOG-7Phase 6: Deploy lên Ubuntu VPS trên Hostinger✅ Hoàn thành
BLOG-8Phase 7: Thiết Lập Domain trên Hostinger✅ Hoàn thành

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.