Back to blog

Build a Personal Blog — Phase 6: Deploy lên Ubuntu VPS

vpsdockernginxdevopsubuntu
Build a Personal Blog — Phase 6: Deploy lên Ubuntu VPS

Đây là Phase 6 của series Build a Personal Blog. Ở Phase 5, bạn đã containerize blog Next.js và PostgreSQL database với Docker Compose. Mọi thứ chạy ngon lành trong containers trên laptop. Giờ là lúc đưa những containers đó lên server thật và cho cả thế giới truy cập blog của bạn.

Series: Build a Personal Blog — Complete Roadmap
Trước: Phase 5 — Docker Compose
Tiếp: Phase 7 — Setup Custom Domain trên Hostinger
Source Code: GitHub — personal-blog-phase-6


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

Sau phase này:

✅ Ubuntu VPS trên Hostinger với SSH access an toàn
✅ Docker và Docker Compose đã cài trên server
✅ Blog chạy trong production containers trên VPS
✅ Nginx làm reverse proxy chuyển tiếp traffic đến Next.js
✅ SSL certificates miễn phí từ Let's Encrypt qua Certbot
✅ Quy trình re-deploy không downtime chạy trong 30 giây
✅ Firewall và fail2ban bảo vệ server khỏi tấn công brute-force

Time commitment: 3–4 giờ
Prerequisites: Phase 5 — Docker Compose


Tổng Quan Kiến Trúc

Đây là thứ chúng ta sẽ build — traffic đi từ internet qua Nginx vào Docker containers:

Nginx xử lý SSL termination và forward HTTP traffic đến Next.js container trên port 3000. PostgreSQL chạy trong container riêng, chỉ truy cập được từ Next.js container — không bao giờ expose ra internet.


1. Chọn Gói VPS Trên Hostinger

Vào Hostinger VPS và chọn gói. Cho blog cá nhân, gói entry-level là quá đủ:

GóiRAMCPUStorageGiá
KVM 14 GB1 vCPU50 GB NVMe~$5/tháng
KVM 28 GB2 vCPU100 GB NVMe~$7/tháng

KVM 1 thừa sức cho blog cá nhân. Next.js standalone dùng ~100-150 MB memory, PostgreSQL tốn ~50-100 MB, và Nginx gần như không đáng kể. Bạn còn dư dả headroom.

Khi setup VPS:

  1. Chọn Ubuntu 24.04 làm OS
  2. Chọn data center gần audience của bạn (US, EU, hoặc Asia)
  3. Đặt root password (sẽ tắt password auth sau)
  4. Ghi lại VPS IP address — bạn sẽ cần nó ở khắp nơi

2. Setup Server Ban Đầu

2.1 Kết Nối Qua SSH

Từ máy local, SSH vào VPS bằng root:

ssh root@YOUR_VPS_IP

Bạn sẽ được hỏi root password đã đặt lúc tạo VPS.

2.2 Tạo User Deploy

Chạy mọi thứ bằng root rất nguy hiểm. Một lệnh rm -rf sai chỗ là server bay. Tạo user riêng:

# Tạo user với home directory
adduser deploy
 
# Thêm vào sudo group
usermod -aG sudo deploy

Đặt password mạnh khi được hỏi.

2.3 Setup SSH Key Authentication

SSH keys an toàn hơn password và tiện hơn nhiều. Trên máy local (không phải VPS):

# Tạo cặp SSH key (nếu chưa có)
ssh-keygen -t ed25519 -C "[email protected]"
 
# Copy public key lên VPS
ssh-copy-id deploy@YOUR_VPS_IP

Test thử:

ssh deploy@YOUR_VPS_IP
# Phải login được mà không hỏi password

2.4 Tắt Password Authentication

Giờ SSH keys đã hoạt động, tắt password login để chặn brute-force:

sudo nano /etc/ssh/sshd_config

Tìm và đổi các dòng sau:

PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes

Restart SSH:

sudo systemctl restart ssh

Cảnh báo: Đảm bảo SSH key login hoạt động TRƯỚC KHI tắt password auth. Nếu bị lock out, bạn phải dùng VPS console trên Hostinger để khôi phục.

2.5 Cấu Hình Firewall

Ubuntu đi kèm ufw (Uncomplicated Firewall). Chỉ mở các port cần thiết:

# Cho phép SSH (để không bị lock out!)
sudo ufw allow OpenSSH
 
# Cho phép HTTP và HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
 
# Bật firewall
sudo ufw enable
 
# Kiểm tra
sudo ufw status

Output mong đợi:

Status: active
 
To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere
443/tcp                    ALLOW       Anywhere
OpenSSH (v6)               ALLOW       Anywhere (v6)
80/tcp (v6)                ALLOW       Anywhere (v6)
443/tcp (v6)               ALLOW       Anywhere (v6)

2.6 Cài fail2ban

fail2ban theo dõi log files và ban IP có dấu hiệu tấn công (như đăng nhập sai nhiều lần):

sudo apt update
sudo apt install -y fail2ban

Tạo cấu hình local:

sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local

Tìm section [sshd] và đảm bảo đã bật:

[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 5
bantime = 3600

Ban IP nào sai SSH login 5 lần trong 1 giờ. Khởi động service:

sudo systemctl enable fail2ban
sudo systemctl start fail2ban
 
# Kiểm tra status
sudo fail2ban-client status sshd

3. Cài Docker Trên VPS

Docker không cài sẵn trên hầu hết Ubuntu images. Đây là cách cài chính thức:

# Xóa Docker packages cũ (nếu có)
sudo apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null
 
# Cài prerequisites
sudo apt update
sudo apt install -y ca-certificates curl gnupg
 
# Thêm Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
 
# Thêm Docker repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
 
# Cài Docker Engine + Compose plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Thêm deploy user vào docker group để không cần sudo cho mỗi lệnh Docker:

sudo usermod -aG docker deploy

Logout rồi login lại để group change có hiệu lực:

exit
ssh deploy@YOUR_VPS_IP

Kiểm tra Docker hoạt động:

docker --version
# Docker version 27.x.x
 
docker compose version
# Docker Compose version v2.x.x
 
# Test với hello-world
docker run hello-world

4. Clone Và Cấu Hình Project

4.1 Clone Repository

cd ~
git clone https://github.com/YOUR_USERNAME/my-blog.git
cd my-blog

Tip: Nếu repo là private, bạn có thể:

  • Dùng SSH keys: Thêm public key của VPS làm deploy key trong GitHub settings
  • Dùng HTTPS với personal access token: git clone https://[email protected]/user/repo.git
  • Dùng GitHub CLI: gh auth login rồi gh repo clone user/repo

4.2 Tạo File Environment Production

Tạo .env.production với cấu hình production:

nano .env.production
# Database
DATABASE_URL=postgresql://bloguser:YOUR_STRONG_PASSWORD@db:5432/myblog
POSTGRES_USER=bloguser
POSTGRES_PASSWORD=YOUR_STRONG_PASSWORD
POSTGRES_DB=myblog
 
# Application
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
NODE_ENV=production

Bảo mật: Dùng password mạnh, ngẫu nhiên cho PostgreSQL. Tạo bằng:

openssl rand -base64 32

4.3 Build Và Khởi Động Containers

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

Giải thích từng flag:

FlagMục đích
-f docker-compose.ymlCấu hình base
-f docker-compose.prod.ymlProduction overrides (restart policies, logging)
--env-file .env.productionLoad production environment variables
-dDetached mode (chạy nền)
--buildRebuild images từ source

Theo dõi tiến trình build:

docker compose logs -f

Khi thấy Ready on http://0.0.0.0:3000, blog đã chạy. Nhấn Ctrl+C để thoát logs.

Kiểm tra containers healthy:

docker compose ps

Output mong đợi:

NAME        SERVICE   STATUS                  PORTS
my-blog-app-1   app       Up (healthy)       0.0.0.0:3000->3000/tcp
my-blog-db-1    db        Up (healthy)       5432/tcp

Lúc này blog truy cập được tại http://YOUR_VPS_IP:3000 — nhưng chỉ nếu bạn mở port 3000 trong firewall (đừng làm vậy ở production). Thay vào đó, ta sẽ đặt Nginx phía trước.


5. Setup Nginx Làm Reverse Proxy

Nginx nằm giữa internet và Next.js container. Nó xử lý:

  • SSL termination — giải mã HTTPS, forward HTTP thuần đến Next.js
  • Static file caching — serve headers cho browser caching
  • HTTP/2 — performance tốt hơn cho browser hiện đại
  • Security headers — HSTS, X-Frame-Options, v.v.

5.1 Cài Nginx

sudo apt install -y nginx

Kiểm tra đang chạy:

sudo systemctl status nginx
# Phải hiện "active (running)"

Mở http://YOUR_VPS_IP trên browser — bạn sẽ thấy trang welcome mặc định của Nginx.

5.2 Tạo Cấu Hình Nginx

Xóa site mặc định và tạo config cho blog:

# Xóa default
sudo rm /etc/nginx/sites-enabled/default
 
# Tạo blog config
sudo nano /etc/nginx/sites-available/blog

Dán cấu hình này:

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;
 
    # Proxy tất cả requests đến Next.js
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
 
        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
 
    # Cache static assets
    location /_next/static/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_cache_valid 200 365d;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
 
    # Cache images
    location /images/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_cache_valid 200 30d;
        add_header Cache-Control "public, max-age=2592000";
    }
}

Thay yourdomain.com bằng domain thật (hoặc VPS IP nếu chưa setup domain).

5.3 Bật Site

# Tạo symlink để enable site
sudo ln -s /etc/nginx/sites-available/blog /etc/nginx/sites-enabled/blog
 
# Test cấu hình xem có lỗi syntax không
sudo nginx -t
 
# Reload Nginx
sudo systemctl reload nginx

Output mong đợi từ nginx -t:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Giờ mở http://YOUR_VPS_IP — bạn sẽ thấy blog thay vì trang welcome của Nginx!

Tại Sao Cần Reverse Proxy?

Bạn có thể thắc mắc: "Sao không expose port 3000 thẳng ra luôn?" Lý do:


6. SSL Với Let's Encrypt

HTTP không an toàn. Bất kỳ ai nằm giữa user và server đều có thể đọc traffic. Thêm HTTPS với SSL certificates miễn phí từ Let's Encrypt.

6.1 Cài Certbot

sudo apt install -y certbot python3-certbot-nginx

6.2 Lấy SSL Certificates

Điều kiện: Domain phải trỏ đến VPS IP trước khi chạy lệnh này. Nếu chưa setup DNS, bỏ qua section này và quay lại sau Phase 7 (Custom Domain Setup). Tạm thời dùng http://YOUR_VPS_IP.

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot sẽ:

  1. Hỏi email (cho thông báo gia hạn)
  2. Yêu cầu đồng ý terms of service
  3. Tự động xác minh domain qua HTTP challenge
  4. Cài certificate và cập nhật Nginx config
  5. Setup tự động redirect HTTP → HTTPS

Sau khi Certbot xong, Nginx config sẽ được cập nhật với SSL settings. Kiểm tra:

cat /etc/nginx/sites-available/blog

Bạn sẽ thấy các directive listen 443 ssl mới và đường dẫn certificate. Certbot lo hết.

6.3 Kiểm Tra Auto-Renewal

Let's Encrypt certificates hết hạn sau 90 ngày. Certbot cài systemd timer để tự động gia hạn:

# Kiểm tra timer
sudo systemctl status certbot.timer
 
# Test renewal (dry run — không gia hạn thật)
sudo certbot renew --dry-run

Nếu dry run thành công, bạn đã xong. Certbot sẽ tự gia hạn certificates trước khi hết hạn.

6.4 Test SSL

Mở https://yourdomain.com và kiểm tra:

  • Icon ổ khóa xuất hiện trên browser
  • HTTP tự động redirect sang HTTPS
  • Certificate do Let's Encrypt cấp

Bạn cũng có thể test bằng SSL Labs scanner: vào SSL Labs và nhập domain. Mục tiêu là rating A hoặc A+.


7. Quy Trình Deploy

Blog đã live! Nhưng bạn sẽ tiếp tục viết bài và thay đổi code. Đây là cách deploy updates.

7.1 Script Deploy Nhanh

Tạo deploy script trên VPS:

nano ~/my-blog/deploy.sh
#!/bin/bash
set -e
 
echo "🔄 Pulling latest changes..."
git pull origin main
 
echo "🏗️  Building and restarting containers..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production \
  up -d --build
 
echo "🧹 Cleaning up old images..."
docker image prune -f
 
echo "✅ Deploy complete!"
echo ""
docker compose ps

Cho phép chạy:

chmod +x ~/my-blog/deploy.sh

7.2 Deploy Từ Máy Local

Workflow thường ngày:

# Trên máy local
git add .
git commit -m "Add new blog post"
git push origin main
 
# SSH vào VPS và deploy
ssh deploy@YOUR_VPS_IP "cd ~/my-blog && ./deploy.sh"

Hoặc gộp lại thành một lệnh duy nhất alias trong shell:

# Thêm vào ~/.zshrc hoặc ~/.bashrc
alias deploy-blog='ssh deploy@YOUR_VPS_IP "cd ~/my-blog && ./deploy.sh"'
 
# Giờ chỉ cần chạy:
deploy-blog

7.3 Chiến Lược Zero-Downtime

docker compose up -d --build của Docker Compose restart containers lần lượt. Cho blog cá nhân, khoảng restart ngắn (~5-10 giây) là chấp nhận được. Nếu cần zero-downtime thật sự:

  1. Build image trước (không downtime trong lúc build):
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production \
  build
 
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production \
  up -d
  1. Dùng health checks — Docker đợi container mới healthy trước khi route traffic đến nó. docker-compose.yml từ Phase 5 đã có health checks rồi.

8. Monitoring Và Logs

8.1 Xem Container Logs

# Tất cả containers
docker compose logs -f
 
# Chỉ Next.js app
docker compose logs -f app
 
# Chỉ PostgreSQL
docker compose logs -f db
 
# 100 dòng cuối
docker compose logs --tail 100 app

8.2 Kiểm Tra Resource Usage

# CPU và memory của containers
docker stats
 
# Disk usage
df -h
 
# Docker disk usage
docker system df

8.3 Nginx Logs

# Access logs
sudo tail -f /var/log/nginx/access.log
 
# Error logs
sudo tail -f /var/log/nginx/error.log

8.4 Restart Khi Có Vấn Đề

# Restart tất cả containers
docker compose restart
 
# Restart chỉ app
docker compose restart app
 
# Cách cuối: dừng mọi thứ và rebuild
docker compose down
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production \
  up -d --build

9. Security Hardening Checklist

Server đã an toàn hơn đa số (SSH keys, firewall, fail2ban), nhưng đây là thêm vài bước:

9.1 Tự Động Cập Nhật Security

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Chọn "Yes" khi được hỏi. Ubuntu sẽ tự động cài security patches.

9.2 Không Expose PostgreSQL

Trong docker-compose.prod.yml, đảm bảo PostgreSQL KHÔNG map ra host port:

# ✅ Đúng — chỉ truy cập được từ containers khác
db:
  ports: []
 
# ❌ Sai — truy cập được từ internet
db:
  ports:
    - "5432:5432"

Docker Compose config từ Phase 5 đã xử lý đúng với production override.

9.3 Cập Nhật Docker

# Update Docker cùng system packages
sudo apt update && sudo apt upgrade -y

9.4 Tổng Kết Security

LayerBảo vệTrạng thái
SSHKey-only auth, không root login✅
FirewallChỉ mở port 22, 80, 443✅
Brute-forcefail2ban giám sát SSH✅
DatabaseKhông expose ra internet✅
SSL/TLSLet's Encrypt với auto-renewal✅
OSTự động cập nhật security✅
ContainersNon-root user trong Dockerfile✅

Troubleshooting

Container không start được

# Kiểm tra logs xem lỗi gì
docker compose logs app
 
# Các vấn đề thường gặp:
# 1. DATABASE_URL sai — kiểm tra .env.production
# 2. Port 3000 bị chiếm — kiểm tra: lsof -i :3000
# 3. Build fail — kiểm tra TypeScript errors trong logs

Nginx hiện 502 Bad Gateway

Nghĩa là Nginx không kết nối được đến Next.js container:

# App container có đang chạy không?
docker compose ps
 
# Có đang listen port 3000 không?
curl http://127.0.0.1:3000
 
# Kiểm tra Nginx error log
sudo tail -20 /var/log/nginx/error.log

Nguyên nhân thường gặp:

  • Container đang khởi động (đợi vài giây)
  • Container bị crash — kiểm tra docker compose logs app
  • Sai port trong Nginx config — xác nhận proxy_pass http://127.0.0.1:3000

SSL certificate không issue được

Certbot cần truy cập server trên port 80 để xác minh domain:

# Kiểm tra DNS trỏ đến VPS
dig yourdomain.com +short
# Phải trả về VPS IP
 
# Kiểm tra port 80 đã mở
sudo ufw status
# Phải thấy 80/tcp ALLOW
 
# Kiểm tra Nginx đang chạy
sudo systemctl status nginx

Hết disk space

Docker images và build cache có thể ăn hết disk:

# Xem cái gì đang chiếm chỗ
docker system df
 
# Xóa images, containers, và build cache không dùng
docker system prune -a
 
# Xóa logs cũ
docker compose logs --tail 0

Tổng Kết

Trong phase này bạn đã:

✅ Setup Ubuntu VPS trên Hostinger với SSH access an toàn và non-root deploy user
✅ Gia cố server với firewall rules và fail2ban chống brute-force
✅ Cài Docker và Docker Compose trên VPS
✅ Deploy blog với production Docker Compose configuration
✅ Cấu hình Nginx làm reverse proxy với static asset caching
✅ Thêm SSL certificates miễn phí từ Let's Encrypt với tự động gia hạn
✅ Tạo deploy script cho quy trình deploy nhanh, lặp lại được

Blog của bạn giờ đã live trên internet, bảo mật bằng HTTPS, và được bảo vệ bởi nhiều lớp security. Bất kỳ ai cũng có thể truy cập https://yourdomain.com và đọc bài viết của bạn.


Tiếp Theo Là Gì?

Trong Phase 7, bạn sẽ đăng ký custom domain (như chanh.blog) trên Hostinger, cấu hình DNS records trỏ về VPS, setup redirect www, và verify mọi thứ hoạt động end-to-end. Bạn cũng sẽ submit site lên Google Search Console và test social sharing previews.

Bài tiếp: Phase 7 — Custom Domain Setup 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✅ Hoàn thành
BLOG-7Phase 6: Deploy lên Ubuntu VPS trên Hostinger✅ Bạn đang ở đây
BLOG-8Phase 7: Custom Domain Setup 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.