truong.blog
← Quay lại Công nghệ

Blog — Hướng dẫn cài đặt đầy đủ (Phân tích chuyên sâu)

·18 phút đọc
#devops #astro #go #docker #ci-cd

Tất cả những gì chúng ta đã làm, từ những repo trống cho đến một trang web HTTPS đang hoạt động tại https://hoongan.art. Được viết để bạn hiểu vì sao mỗi bước tồn tại, có thể tái hiện lại, và gỡ lỗi sau này.

Kết quả cuối cùng:

  • 🌐 Blog (trang tĩnh Astro): https://hoongan.art
  • 📡 API (Go): https://hoongan.art/api/ + /healthz
  • 🔁 CI/CD: push lên main → GitHub Actions → tự động deploy
  • 🔒 HTTPS: chứng chỉ Let’s Encrypt tự động (tự gia hạn)
  • 🖥️ Máy chủ: 109.123.229.225 (máy dùng chung), blog được cô lập trong stack Docker riêng

Mục lục

  1. Bức tranh tổng thể (kiến trúc)
  2. Giai đoạn 1 — Dựng khung mã nguồn
  3. Giai đoạn 2 — Thiết kế lại giao diện
  4. Giai đoạn 3 — Kết nối tới máy chủ (SSH)
  5. Giai đoạn 4 — Triển khai bằng Docker
  6. Giai đoạn 5 — CI/CD với GitHub Actions
  7. Giai đoạn 6 — Tên miền + DNS + HTTPS
  8. Những lỗi đã gặp & cách khắc phục
  9. Bảng tra cứu nhanh — các lệnh hằng ngày
  10. Thuật ngữ

1. Bức tranh tổng thể (kiến trúc)

   Bạn ── git push main ──▶  GitHub (truongngoblog/frontend, /backend)

                                   │  GitHub Actions chạy

                            ┌──────────────┐
   repo frontend ──────────▶│  build dist/ │── rsync qua SSH ──┐
                            └──────────────┘                   │
                            ┌──────────────┐                   ▼
   repo backend  ──────────▶│ build image  │── scp + docker ──▶ Máy chủ 109.123.229.225
                            └──────────────┘   load qua SSH    │

            Internet ──https://hoongan.art──▶ chamee-caddy (:443, dùng chung)
                                                  │   reverse proxy
                                  ┌───────────────┼─────────────────┐
                                  ▼                                 ▼
                          blog-web (nginx :8080)            blog-api (Go :8090)
                          phục vụ dist/ tĩnh                SQLite tại /opt/blog/data

Hai repo, hai trách nhiệm:

  • frontend = Astro. Biến các bài viết Markdown/MDX thành một trang tĩnh nhanh (HTML/CSS thuần). Không cần máy chủ để render — chỉ là các tệp.
  • backend = Go. Xử lý những thứ động mà tệp tĩnh không làm được: bộ đếm lượt xem, biểu mẫu liên hệ, đăng ký nhận tin. Dữ liệu lưu trong SQLite.

Vì sao dùng reverse proxy chung (chamee-caddy)? Máy chủ đã chạy ứng dụng của những người khác (chamee/elearning/laws), và một container tên chamee-caddy đã chiếm cổng 80/443. Mỗi cổng chỉ một chương trình được sở hữu. Vậy nên thay vì tranh giành, blog nằm phía sau Caddy hiện có; nó định tuyến hoongan.art về cho chúng ta và mọi thứ còn lại về cho họ.


2. Giai đoạn 1 — Dựng khung mã nguồn

Cả hai repo GitHub đều bắt đầu trống. Kế hoạch (KE-HOACH.md) đã chọn ngăn xếp công nghệ: frontend Astro + backend Go.

2.1 Clone repo

git clone https://github.com/truongngoblog/backend.git
git clone https://github.com/truongngoblog/frontend.git

2.2 Backend (API Go)

Bố cục chúng ta tạo ra:

backend/
├── cmd/api/main.go          # điểm vào: đọc biến môi trường, khởi động máy chủ HTTP
├── internal/server/         # route + handler HTTP (+ test)
├── internal/store/          # lưu trữ SQLite (+ test)
├── go.mod                   # module + danh sách phụ thuộc
├── Makefile                 # lối tắt: make run / test / build
└── README.md

Những lựa chọn chính:

  • Router: chi — một HTTP router nhỏ gọn, đậm chất Go.
  • Cơ sở dữ liệu: SQLite qua modernc.org/sqlite — một driver thuần Go, nghĩa là không cần trình biên dịch C (CGO_ENABLED=0). Đây là lý do image Docker của chúng ta có thể nhỏ và tĩnh.
  • Endpoint: GET /healthz, GET/POST /api/posts/{slug}/views, POST /api/contact, POST /api/newsletter.

Cách giải quyết phụ thuộc và chạy test:

cd backend
go mod tidy        # tải phụ thuộc, ghi go.sum
go test ./...      # chạy toàn bộ unit test

go test ./... cho kết quả ok cho cả internal/serverinternal/store → xanh hết.

Vì sao test với DB trong bộ nhớ? Các test của store mở SQLite bằng ":memory:" để mỗi test có một cơ sở dữ liệu mới dùng-một-lần — nhanh, không để lại tệp thừa.

2.3 Frontend (blog Astro)

Bố cục:

frontend/
├── src/content/             # các bài viết thực sự
│   ├── config.ts            # schema: mỗi bài có title, description, pubDate, tags, draft
│   ├── tech/*.mdx           # bài viết kỹ thuật
│   └── life/*.mdx           # bài viết đời sống
├── src/lib/posts.ts         # các hàm hỗ trợ không phụ thuộc framework (sắp xếp, lọc nháp, thời gian đọc)
├── src/lib/posts.test.ts    # unit test (vitest)
├── src/layouts/             # khung trang
├── src/pages/               # các route (index, /tech, /life, trang bài viết, rss.xml)
├── astro.config.mjs
└── package.json

Ý tưởng cốt lõi: Content Collections. Bài viết là các tệp .mdx; config.ts định nghĩa một schema để Astro kiểm tra phần frontmatter (khối --- ở đầu mỗi bài) tại thời điểm build.

Chạy thử:

cd frontend
npm install
npm test            # vitest — kiểm thử các hàm thuần
npm run dev         # xem trước cục bộ tại http://localhost:4321
npm run build       # xuất HTML tĩnh vào dist/

Vì sao chỉ test src/lib? Các hàm hỗ trợ (sắp xếp bài, ẩn bản nháp, thời gian đọc) là các hàm thuần không phụ thuộc Astro, nên rất dễ và nhanh để unit-test. Còn bản thân các trang được kiểm chứng bằng việc npm run build chạy thành công.

2.4 Lần push đầu tiên

Cả hai repo đều trống (chưa có nhánh nào), nên chúng ta tạo main và push:

git checkout -b main
git add -A
git commit -m "Scaffold ..."
git push -u origin main

3. Giai đoạn 2 — Thiết kế lại giao diện

Bạn thích phong cách của lehoangdung.blog. Chúng ta đã nghiên cứu nó (đó là trang Next.js + Tailwind) và mượn ngôn ngữ thị giác mà không đổi ngăn xếp của bạn:

  • Thêm Tailwind CSS v4 (qua @tailwindcss/vite) + font Inter.
  • Chế độ sáng/tối với nút chuyển không bị nháy (một <script> nội tuyến nhỏ thiết lập theme trước khi trang vẽ, nên không bị lóe trắng ở chế độ tối).
  • Hero gradient, header dính có làm mờ, thẻ bài viết có hiệu ứng, viên tag bo tròn.
  • Một PostLayout với typography prosetô màu code song theme (Shiki render cả sáng lẫn tối, CSS hoán đổi).
  • Thêm: thời gian đọc, trang 404 tùy biến, favicon gradient.

Mọi thứ vẫn nằm trong Astro/MDX — chỉ thay đổi phần style/markup. Đã kiểm chứng bằng npm test (6 test đậu) và npm run build (sạch).


4. Giai đoạn 3 — Kết nối tới máy chủ (SSH)

Bạn đã cấp quyền SSH root: ssh root@109.123.229.225.

4.1 Vì sao không dùng mật khẩu root ở khắp nơi

Dùng mật khẩu trong tự động hóa vừa mong manh vừa kém an toàn (không thể lưu an toàn trong GitHub, và nó lọt vào log). Cách làm chuyên nghiệp là xác thực bằng khóa SSH:

  • Một cặp khóa = khóa riêng (bí mật, nằm trên máy bạn / trong GitHub Secrets) + khóa công khai (chia sẻ thoải mái, đặt trên máy chủ).
  • Máy chủ tin tưởng ai nắm giữ khóa riêng tương ứng — không cần gõ mật khẩu.

4.2 Tạo khóa deploy

ssh-keygen -t ed25519 -N "" -C "github-actions-blog-deploy" -f .deploy/deploy_key
  • ed25519 = một loại khóa hiện đại, nhỏ, nhanh.
  • -N "" = không có passphrase (cần thiết để CI dùng được không cần người trông).
  • Lệnh này tạo .deploy/deploy_key (riêng) và .deploy/deploy_key.pub (công khai).
  • .deploy/ nằm ngoài cả hai repo git, nên khóa riêng không bao giờ bị vô tình commit.

4.3 Cài khóa công khai lên máy chủ

Chúng ta dùng sshpass (công cụ đưa mật khẩu cho ssh một cách không tương tác) một lần để khởi tạo — thêm khóa công khai vào máy chủ, rồi không bao giờ dựa vào mật khẩu nữa:

brew install hudochenkov/sshpass/sshpass
# thêm khóa công khai của chúng ta để đăng nhập bằng khóa hoạt động:
sshpass -p '***' ssh root@109.123.229.225 \
  "echo 'ssh-ed25519 AAAA... github-actions-blog-deploy' >> ~/.ssh/authorized_keys"

Sau đó, mọi kết nối đều dùng khóa:

ssh -i .deploy/deploy_key -o IdentitiesOnly=yes deploy@109.123.229.225

(IdentitiesOnly=yes = “chỉ thử khóa này, không thử mọi khóa trong agent của tôi”.)

4.4 Kiểm kê máy chủ — và bất ngờ

Chúng ta kiểm tra máy chủ trước khi đụng vào bất cứ thứ gì:

ssh ... 'cat /etc/os-release; uname -m; free -h; docker ps'

Phát hiện:

  • Ubuntu 24.04, x86_64, ~8 GB RAM, Docker đã được cài sẵn.
  • Máy chủ KHÔNG hề trống. docker ps cho thấy ~17 container: chamee-* (postgres/redis/minio/caddy), elearning-*, laws-*, telebot, botbot.
  • chamee-caddy đã chiếm cổng 80 và 443.

➡️ Điều này làm thay đổi kế hoạch. Cài máy chủ web riêng của chúng ta lên cổng :80 sẽ xung đột và có thể làm sập những ứng dụng production đó. Vì vậy chúng ta chọn cô lập.

4.5 Tạo người dùng deploy giới hạn quyền

Thay vì deploy bằng root, chúng ta tạo một người dùng deploy hạn chế:

useradd -m -s /bin/bash deploy
usermod -aG docker deploy          # có thể chạy docker (cần để deploy)
mkdir -p /home/deploy/.ssh
echo '<khóa công khai>' >> /home/deploy/.ssh/authorized_keys
mkdir -p /opt/blog/web /opt/blog/data
chown -R deploy:deploy /opt/blog   # chỉ sở hữu thư mục của riêng nó

Bây giờ CI đăng nhập với tư cách deploy, không phải root.


5. Giai đoạn 4 — Triển khai bằng Docker

Chiến lược cô lập: blog chạy như một stack Docker riêng trên các cổng cao (8080 web, 8090 api) để không bao giờ đụng tới :80/:443 dùng chung hay các ứng dụng khác.

5.1 Dockerfile backend (nhiều tầng)

# tầng build: bộ công cụ Go đầy đủ
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /api ./cmd/api

# tầng runtime: image gần như trống, chỉ có binary
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /api /api
ENTRYPOINT ["/api"]

Vì sao hai tầng? Tầng đầu chứa cả trình biên dịch Go (~300 MB). Tầng sau chỉ chép mỗi binary đã biên dịch vào một image distroless (không shell, không trình quản lý gói — nhỏ và an toàn). CGO_ENABLED=0 khiến binary hoàn toàn tĩnh nên chạy được trong image tối giản đó.

5.2 docker-compose.yml (/opt/blog/docker-compose.yml)

Định nghĩa hai dịch vụ:

  • web: nginx:alpine, phục vụ các tệp dist/ tĩnh (mount từ ./web), publish trên 8080:80.
  • api: image blog-api:latest của chúng ta, publish trên 8090:8080, với các biến môi trường (DB_PATH, CORS_ORIGINS) và một volume cho tệp SQLite.

5.3 nginx.conf

Một cấu hình nhỏ để URL sạch hoạt động (/tech/hello-world/index.html của nó) và trang không tồn tại trả về 404 thật với trang tùy biến của chúng ta:

location / { try_files $uri $uri/ $uri.html =404; }
error_page 404 /404.html;

5.4 Lần deploy thủ công đầu tiên

Vì máy chủ là x86_64 còn máy Mac của bạn là arm64, chúng ta build image ngay trên máy chủ (đúng kiến trúc):

# gửi cấu hình + trang tĩnh đã build + mã nguồn backend
scp deploy/docker-compose.yml deploy/nginx.conf deploy@server:/opt/blog/
rsync -az dist/ deploy@server:/opt/blog/web/
rsync -az --exclude '.git' backend/ deploy@server:/opt/blog/_build/

# build + khởi động trên máy chủ
ssh deploy@server 'cd /opt/blog && docker build -t blog-api:latest _build && docker compose up -d'

Kết quả: cả blog-webblog-api đều Up. Đã kiểm chứng từ internet công cộng bằng curl.


6. Giai đoạn 5 — CI/CD với GitHub Actions

Mục tiêu: mỗi lần git push lên main đều tự động test, build, và deploy.

Một workflow GitHub Actions là một tệp YAML trong .github/workflows/. GitHub chạy nó trên một máy ảo đám mây mới mỗi khi điều kiện kích hoạt xảy ra.

6.1 Workflow backend (backend/.github/workflows/deploy.yml)

Hai job:

  1. testgo vet ./... + go test ./....
  2. deploy (chỉ khi test đậu):
    • docker build image,
    • docker save | gzip thành một tarball,
    • ghi khóa SSH từ một secret, scp tarball lên máy chủ,
    • ssh vào và docker load + docker compose up -d api.

Vì sao gửi tarball thay vì dùng registry? Nó tránh được việc cần một container registry + đăng nhập. Image nhỏ, nên save → scp → load đơn giản và khép kín.

6.2 Workflow frontend (frontend/.github/workflows/deploy.yml)

Một job: npm cinpm testnpm run buildrsync dist/ lên /opt/blog/web trên máy chủ. Tệp tĩnh, không cần build lại container.

6.3 GitHub Secrets (phần bạn làm trên trình duyệt)

Secret là các giá trị được mã hóa do GitHub lưu, không bao giờ commit vào repo. Workflow đọc chúng qua ${{ secrets.NAME }}.

Trong mỗi repo → Settings → Secrets and variables → Actions → New repository secret:

TênGiá trị
SSH_HOST109.123.229.225
SSH_USERdeploy
SSH_KEYnội dung của .deploy/deploy_key (khóa riêng)

Bước deploy sẽ thất bại cho đến khi các secret này tồn tại (không thể xác thực). Sau khi thêm xong, chạy lại workflow hoặc push lại.


7. Giai đoạn 6 — Tên miền + DNS + HTTPS

Bạn sở hữu hoongan.art (đăng ký tại Namecheap). Mục tiêu: phục vụ blog tại https://hoongan.art không kèm số cổng và có chứng chỉ hợp lệ.

7.1 DNS — trỏ tên về máy chủ

DNS ánh xạ tên miền → địa chỉ IP. Chúng ta thêm một bản ghi tại Namecheap (Advanced DNS → Host Records):

TypeHostValue
A@109.123.229.225

(@ = tên miền trần hoongan.art. Chúng ta cũng phải xóa bản ghi chuyển hướng URL/đỗ tên mặc định của Namecheap trên @, nếu không nó sẽ ghi đè bản ghi A.)

Kiểm tra việc lan truyền từ bất cứ đâu:

dig +short hoongan.art            # phải in ra 109.123.229.225
dig +short @8.8.8.8 hoongan.art   # kiểm tra qua resolver của Google

7.2 Định tuyến qua Caddy hiện có (không phải Caddy thứ hai)

chamee-caddy đang chiếm :443, chúng ta làm cho blog có thể tiếp cận được bởi nó:

  1. Đưa các container blog vào mạng Docker của Caddy để Caddy tìm thấy chúng theo tên. Trong docker-compose.yml:
    networks: [default, chamee]
    networks:
      chamee:
        external: true
        name: chamee-devops_chamee-net
    
  2. Thêm một khối vhost vào Caddyfile dùng chung (/home/chamee/.../shared/caddy/Caddyfile) — cẩn thận: sao lưu, thêm vào, kiểm tra, nạp lại:
    hoongan.art {
        encode zstd gzip
        @api path /api/* /healthz
        reverse_proxy @api blog-api:8080     # lưu lượng API → Go
        reverse_proxy blog-web:80            # mọi thứ còn lại → trang tĩnh
    }
    
    cp Caddyfile Caddyfile.bak.$(date +%s)               # sao lưu trước!
    docker exec chamee-caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
    docker exec chamee-caddy caddy reload  --config /etc/caddy/Caddyfile
    

7.3 HTTPS — tự động, qua Let’s Encrypt

Caddy làm việc này tự động: ở yêu cầu đầu tiên cho hoongan.art, nó liên hệ Let’s Encrypt, chứng minh nó kiểm soát tên miền (một thử thách ACME), và cài một chứng chỉ miễn phí tự gia hạn. Không cần thao tác chứng chỉ thủ công.

Kiểm chứng:

echo | openssl s_client -servername hoongan.art -connect hoongan.art:443 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
# subject=CN = hoongan.art   issuer=Let's Encrypt   (hợp lệ 90 ngày)

8. Những lỗi đã gặp & cách khắc phục

Triển khai thực tế luôn vướng trục trặc. Đây là từng lỗi và bài học.

Lỗi 1 — Build Astro sập vì @astrojs/sitemap

Một sự không tương thích phiên bản khiến tích hợp sitemap ném lỗi lúc build. Cách sửa: gỡ sitemap (nó là phần tô điểm tùy chọn của Giai đoạn 6); build xanh trở lại. Thêm lại sau khi tên miền đã chốt.

Lỗi 2 — API lặp đi lặp lại lỗi crash: out of memory (14)

Container Go chạy với người dùng nonroot của distroless (uid 65532), nhưng thư mục data/ được mount lại thuộc sở hữu của deploy, nên SQLite không thể tạo tệp cơ sở dữ liệu. SQLite báo lỗi mở-thất-bại này bằng dòng chữ gây hiểu lầm “out of memory (14)”. Cách sửa: chown 65532:65532 /opt/blog/data để người dùng container có thể ghi. Bài học: quyền tệp trong container là về người dùng của container, không phải người dùng host — và thông báo lỗi có thể nói dối.

Lỗi 3 — https://109.123.229.225 báo lỗi SSL

Không có chứng chỉ nào cho một IP trần, và :443 thuộc về Caddy của các ứng dụng khác. Cách sửa: URL đúng trước khi có tên miền là http://109.123.229.225:8080 (chú ý http + cổng). HTTPS chỉ hoạt động qua tên miền.

Lỗi 4 — ERR_CERT_COMMON_NAME_INVALID ngay cả khi chứng chỉ đã được cấp

Hai lớp:

  • Lần đầu: DNS chưa lan truyền hết, nên việc xác thực của Let’s Encrypt thoáng chốc chạm tới IP đỗ tên Shopify cũ (23.227.38.65) và bị từ chối (HTTP 409). Caddy sau đó lùi lại chờ. Cách sửa: khi cả dig @8.8.8.8@1.1.1.1 đều trả về đúng IP, chúng ta khởi động lại Caddy để buộc thử lại — chứng chỉ được cấp trong ~5 giây.
  • Rồi trên máy của bạn: máy Mac/ISP vẫn còn cache DNS cũ + một khóa HSTS từ host cũ, nên Chrome cứ chạm tới máy chủ sai. Cách sửa (phía máy khách): xóa cache DNS + xóa mục HSTS của Chrome:
    sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
    
    Chrome: chrome://net-internals/#hsts → xóa hoongan.art; chrome://net-internals/#dns → xóa cache host. Bằng chứng đó là lỗi cục bộ: trang tải tốt trên điện thoại dùng dữ liệu di động (resolver khác). Bài học: “chứng chỉ sai” thường thực ra là “mình đang chạm tới máy chủ sai vì DNS/HSTS bị cache”. Luôn kiểm chứng từ một mạng độc lập (curl từ nơi khác, hoặc điện thoại của bạn).

9. Bảng tra cứu nhanh — các lệnh hằng ngày

Viết một bài mới

cd frontend
# tạo src/content/tech/my-post.mdx  (hoặc life/)
npm run dev          # xem trước tại http://localhost:4321
git add -A && git commit -m "post: my-post" && git push   # tự động deploy

Chạy mọi thứ cục bộ

# frontend
cd frontend && npm test && npm run dev

# backend
cd backend && make test && make run     # http://localhost:8080

SSH vào máy chủ

ssh -i .deploy/deploy_key -o IdentitiesOnly=yes deploy@109.123.229.225

Kiểm tra stack đang chạy

ssh ... 'cd /opt/blog && docker compose ps'        # trạng thái container
ssh ... 'docker logs blog-api --tail 30'           # log API
curl https://hoongan.art/healthz                   # sức khỏe API

Kiểm tra chứng chỉ / DNS

dig +short hoongan.art
echo | openssl s_client -servername hoongan.art -connect hoongan.art:443 2>/dev/null \
  | openssl x509 -noout -subject -dates

Khởi động lại blog (nếu cần)

ssh ... 'cd /opt/blog && docker compose restart'

10. Thuật ngữ

Thuật ngữNghĩa dễ hiểu
Trang tĩnh (Static site)Các tệp HTML/CSS dựng sẵn; không có logic máy chủ để render trang. Nhanh & rẻ.
MDXMarkdown có thể chứa thêm component. Chính là các bài viết của bạn.
SQLiteMột cơ sở dữ liệu chỉ là một tệp duy nhất. Không cần máy chủ DB riêng.
CGOGo gọi mã C. Chúng ta tắt nó (CGO_ENABLED=0) để có binary tĩnh, di động.
Docker image / containerImage = ứng dụng đã đóng gói + môi trường của nó. Container = một thực thể đang chạy của image.
docker composeĐịnh nghĩa/chạy nhiều container cùng nhau từ một tệp YAML.
Reverse proxyMột máy chủ cửa-trước nhận mọi yêu cầu và chuyển tiếp từng cái tới backend đúng (ở đây: Caddy).
CaddyMột máy chủ web/reverse proxy tự động lấy chứng chỉ HTTPS.
CI/CDContinuous Integration / Deployment — tự động test + deploy ở mỗi lần push.
GitHub ActionsHệ thống CI/CD tích hợp của GitHub, chạy workflow YAML của bạn trên máy ảo đám mây.
SecretMột giá trị mã hóa lưu trong GitHub, được tiêm vào workflow lúc chạy, không bao giờ nằm trong mã.
Cặp khóa SSHKhóa riêng (bí mật) + khóa công khai (trên máy chủ) để đăng nhập không mật khẩu.
DNS / bản ghi ACuốn danh bạ của internet; bản ghi A ánh xạ tên miền tới địa chỉ IPv4.
TTLCâu trả lời DNS được phép cache bao lâu trước khi kiểm tra lại.
Lan truyền (Propagation)Thời gian để một thay đổi DNS lan ra các resolver khắp thế giới.
Let’s Encrypt / ACMETổ chức cấp chứng chỉ miễn phí; ACME là giao thức Caddy dùng để chứng minh quyền sở hữu tên miền và lấy chứng chỉ.
HSTSMột header báo trình duyệt “luôn dùng HTTPS cho tên miền này” — có thể gây lỗi dai dẳng nếu từng thấy chứng chỉ sai.
distrolessMột image nền container tối giản, không shell/trình quản lý gói — nhỏ và an toàn.

Được tạo như một bản ghi của toàn bộ phiên xây dựng & triển khai.