Danh mục: Blog

  • [Debug Thực Chiến – Kỳ 2] Lệnh Truy Nã “Go-http-client”: Khi ZaloPay Bị Firewall Tống Cổ Ngay Cửa

    [Debug Thực Chiến – Kỳ 2] Lệnh Truy Nã “Go-http-client”: Khi ZaloPay Bị Firewall Tống Cổ Ngay Cửa

    Chào anh em, lại là tôi — Cậu bé chăn bò đây.

    Ở Kỳ 1, chúng ta đã dùng tcpdump lôi cổ được thằng bug SNI ra ánh sáng. Sau khi cấu hình lại default_server cho Nginx, Access Log đã bắt đầu ghi nhận request từ ZaloPay chui vào hệ thống. Tưởng xong — nhưng không.

    Đập vào mắt tôi trong file log không phải mã 200 OK thần thánh, mà là một dòng đỏ rực:

    POST /wp-json/fuver/v1/zalopay-webhook HTTP/1.1" 403 Forbidden

    Gói tin đã lọt qua Nginx, nhưng bị một kẻ sát nhân vô hình chém đứt đầu ngay tắp lự. Mời anh em bước vào Kỳ 2: hành trình đục lỗ bọc thép qua hai tầng bảo vệ mà chính tay mình dựng lên.


    TL;DR cho anh em bận rộn:

    • Triệu chứng: Nginx nhận Webhook ZaloPay nhưng trả về 403 Forbidden. Postman test vẫn pass 100%.
    • Nguyên nhân tầng 1 (Nginx): 8G Firewall chặn User-Agent Go-http-client/1.1 — cái tên mặc định của HTTP Client Golang.
    • Nguyên nhân tầng 2 (WordPress): Một hàm chống DDoS nằm trong functions.php cũng blacklist đúng cái User-Agent đó.
    • Giải pháp: Whitelist đường dẫn Webhook tại Nginx + IP Whitelisting trong functions.php.

    1. Cô Lập Lỗi: Postman Vs ZaloPay

    Trước khi mổ xẻ, cần biết 403 đến từ tầng nào — Nginx hay WordPress? Tôi không đoán mò. Tôi dùng đòn Isolate Debugging: tạo một file PHP thuần, không có logic gì cả, chỉ làm một việc duy nhất là hứng data và ghi log.

    php

    <?php
    $raw_data = file_get_contents('php://input');
    file_put_contents(__DIR__ . '/zalo_raw_debug.log', $raw_data);
    echo json_encode(["return_code" => 1, "return_message" => "success"]);

    File này được đặt thẳng ở thư mục gốc, không qua WordPress routing. Tôi trỏ Callback URL trên ZaloPay Merchant Portal sang file này và quẹt thử một đơn.

    Kết quả: File log trắng trơn. Request bị Nginx từ chối trước khi kịp chạm vào PHP.

    Tôi mở Postman, bắn thẳng vào cùng cái link đó. Kết quả: Log ghi nhận đầy đủ, trả về success.

    Cùng một file. Cùng một server. Điểm khác biệt duy nhất: HTTP Headers mà hai bên mang theo.


    2. Thủ Phạm Lộ Diện: User-Agent “Go-http-client/1.1”

    Tôi dở header mà ZaloPay gửi đi lên soi. Và ngay dòng User-Agent:

    User-Agent: Go-http-client/1.1

    Đây là cái tên mặc định mà thư viện HTTP Client của Golang tự động đính kèm khi dev không khai báo User-Agent tường minh.

    Vấn đề không phải ở Golang — đây là ngôn ngữ tuyệt vời, được dùng cho hệ thống Microservices chịu tải cao như ZaloPay vì khả năng xử lý đồng thời (Goroutines) cực mạnh. Vấn đề nằm ở chỗ Golang cũng là ngôn ngữ hacker yêu thích để viết tool scan, Brute-force, Botnet — vì build ra file thực thi đơn giản và chạy siêu nhanh.

    Hệ quả: các hệ thống WAF toàn cầu (Cloudflare, ModSecurity, 8G Firewall…) đều đã học thuộc lòng cái User-Agent này và áp một luật bất thành văn: thấy Go-http-client là chặn, không cần trình bày.

    Web của tôi đang dùng 8G Firewall tích hợp tại tầng Nginx. ZaloPay lấp ló ngoài cửa, 8G check thấy Go-http-client, chém không thương tiếc. Đây là nỗi oan của ZaloPay — bị vạ lây vì cái User-Agent mặc định quá “hacker-friendly”.


    3. Fix Tầng Nginx: Mở Đường Máu Cho Webhook

    Bắt đúng bệnh rồi thì bốc thuốc. Tôi mở file config Nginx, thêm một rule Regex để 8G Firewall bỏ qua kiểm tra với các đường dẫn Webhook:

    nginx

    # Whitelist Webhook thanh toán khỏi 8G Firewall
    if ($request_uri ~* "^/wp-json/fuver/v1/(acb-webhook|vtp-webhook|zalopay-webhook)") {
        set $bypass_8g 1;
    }

    Reload Nginx. Gói tin đã xuyên qua tầng server, chính thức chạm được vào mã nguồn WordPress.

    Nhưng nếu anh em nghĩ đến đây là xong thì hơi vội.


    4. Trùm Cuối Trong Bóng Tối: wp_die Từ functions.php

    Test lại đơn thứ tư. Data vào được rồi, nhưng WordPress trả về:

    json

    {
        "code": "wp_die",
        "message": "Access denied.",
        "data": { "status": 403 }
    }

    wp_die là thứ đặc sản không lẫn vào đâu được của WordPress. Nginx không bao giờ đẻ ra cái lỗi này. Nghĩa là Nginx đã cho qua, nhưng một thứ gì đó bên trong WordPress đang làm thủ tục tiễn khách.

    Web không cài Wordfence, không cài iThemes Security. Tôi mò thẳng vào functions.php. Và đây rồi — di sản của một ông dev đời trước:

    php

    // Block suspicious user agents
    function block_suspicious_user_agents() {
        $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
        $blocked_agents = array(
            'libwww-perl', 'sqlmap', 'nikto', 'Go-http-client' // <-- kẻ thù ở đây
        );
        
        foreach ($blocked_agents as $agent) {
            if (stripos($user_agent, $agent) !== false) {
                wp_die('Access denied.', 'Security Check', array('response' => 403));
            }
        }
    }
    add_action('init', 'block_suspicious_user_agents');

    Hook init là một trong những hook chạy sớm nhất của WordPress. Khi ZaloPay chọc vào REST API endpoint, nó chưa kịp đến được Class giải mã Webhook thì đã bị tóm cổ, check thấy Go-http-client, và bị chém không trượt phát nào.


    5. Giải Pháp Đúng Nghĩa: IP Whitelisting

    Lúc này nhiều anh em sẽ bảo: xóa Go-http-client ra khỏi cái mảng blacklist là xong. Đừng làm vậy.

    Hàm đó đang ngăn hàng nghìn request rác mỗi ngày. Nếu xóa điều kiện mà không có gác cổng thay thế, hacker dùng Botnet Golang nã vào đúng cái endpoint Webhook đang mở — VPS của anh em sẽ không trụ được lâu.

    Giải pháp đúng: không xét thẻ căn cước (User-Agent) nữa, xét thẳng biển số xe (IP Address). IP thuộc dải ZaloPay thì cho qua, IP khác thì chặn — kể cả khi nó cũng mang Go-http-client.

    php

    function block_suspicious_user_agents() {
        $request_uri = $_SERVER['REQUEST_URI'] ?? '';
        $client_ip   = $_SERVER['REMOTE_ADDR'] ?? '';
    
        // Bọc thép riêng cho Webhook ZaloPay
        if (strpos($request_uri, '/wp-json/fuver/v1/zalopay-webhook') !== false) {
            $zalopay_ips = array(
                '118.102.5.66', // Sandbox
                '113.163.x.x',  // Production
            );
    
            if (in_array($client_ip, $zalopay_ips)) {
                return; // IP chuẩn ZaloPay → cho đi
            }
    
            wp_die('Invalid Webhook Source.', 'Security', array('response' => 403));
        }
    
        // Giữ nguyên logic check User-Agent cho các route khác
        $user_agent     = $_SERVER['HTTP_USER_AGENT'] ?? '';
        $blocked_agents = array('libwww-perl', 'sqlmap', 'nikto', 'Go-http-client');
    
        foreach ($blocked_agents as $agent) {
            if (stripos($user_agent, $agent) !== false) {
                wp_die('Access denied.', 'Security Check', array('response' => 403));
            }
        }
    }
    add_action('init', 'block_suspicious_user_agents');

    Góc Thực Hành: Tự Tay Test Tường Lửa

    Lý thuyết xuông thì hơi khô khan. Tôi có code nhanh một cái sa bàn mô phỏng lại y hệt logic của hệ thống tường lửa mà chúng ta vừa setup ở trên.

    Anh em thử đóng vai hacker, hoặc đóng vai Postman, thử bật/tắt cái tính năng “IP Whitelist” bên trên xem gói tin nó bị chém đứt đầu ở tầng nào nhé. Đảm bảo test xong là hiểu tận gốc rễ vấn đề luôn:

    waf-simulation.sh — ZaloPay Webhook Debug / Kỳ 2
    Bật IP Whitelisting cho Webhook
    📡
    Client
    🔥
    Nginx + 8G
    ⚙️
    WP init
    🛒
    WooCommerce
    Chọn nguồn gửi và nhấn ▶ để mô phỏng…

    Kết: Cái Ting Ting Trọn Vẹn

    Lưu file. Tôi cầm điện thoại lên, mở ZaloPay, quẹt đơn hàng thứ năm.

    Không cần nhìn log nữa. Nhìn thẳng vào dashboard WooCommerce. Nhấn F5. Trạng thái đơn hàng chuyển từ Pending Payment sang Processing — mượt mà, không cần giải thích thêm.

    Cái âm thanh Ting Ting lúc này nghe mới thực sự trọn vẹn.


    Hành trình này tốn kha khá bát mì tôm và nơ-ron thần kinh, nhưng nó là một case study hội tụ đủ ba tầng kiến thức:

    Tầng Network (SNI)Tầng Server WAF (User-Agent)Tầng Application (WordPress Hooks)

    Dù là MoMo, VNPay hay bất kỳ cổng thanh toán nào dở chứng — cứ nắm chắc tư duy “Cô lập → Đào sâu từng tầng”, anh em sẽ tóm được hết.


    Hết rồi, trên đây là những chia sẻ thực tế về trải nghiệm của tôi khi code và gặp bug khá đau đầu, nên muốn lưu lại làm bài học cũng như là chia sẻ với ae coder, cảm ơn ae đã đón đọc bài viết của tôi, thấy hay thì để lại 1 cmt, 1 share nhé!

  • [Debug Thực Chiến – Kỳ 1] ZaloPay Webhook “Bặt Vô Âm Tín”: Truy Tìm 683 Bytes Mất Tích Trong Nginx

    [Debug Thực Chiến – Kỳ 1] ZaloPay Webhook “Bặt Vô Âm Tín”: Truy Tìm 683 Bytes Mất Tích Trong Nginx

    Chào ae, tôi lại ngoi lên đây,

    Nếu anh em làm E-commerce lâu năm, đặc biệt là ôm mấy con hàng WordPress/WooCommerce, chắc chắn không dưới một lần nếm mùi tích hợp cổng thanh toán. Nghe thì có vẻ “cơ bản”: Đọc API docs → Bắn request tạo đơn → Tạo Endpoint hứng Webhook → Đổi trạng thái đơn. Xong! Dễ như ăn kẹo đúng không?

    Nhưng không anh em ạ, hệ thống thực tế không giống tutorial trên mạng. Hôm nay tôi sẽ kể cho anh em nghe một case study debug “đẫm máu” mà tôi vừa trải qua. Một pha bắt bug mà logic PHP chuẩn 100%, test bằng Postman trả về mã 200 xanh mướt, nhưng khi khách hàng thanh toán thật thì… bặt vô âm tín.

    Hành trình lôi cổ con bug này xuyên qua 3 tầng hệ thống — Nginx → WAF → WordPress Hooks — thực sự là một bài học xương máu. Chúng ta bắt đầu với cửa ải đầu tiên: Cú lừa từ tầng Network.


    Bức Tranh Hoàn Hảo Và Vết Gợn Đầu Tiên

    Dự án tôi đang xử lý là một web WooCommerce bán hàng thực chiến, không phải môi trường demo. Mọi thứ đang chạy ngon — cho đến khi khách hàng nhắn tin:

    “Anh ơi, em quẹt ZaloPay tiền trừ ting ting rồi mà web vẫn báo Chờ thanh toán.”

    Cái câu đó, bất kỳ developer nào làm payment integration cũng biết là nó nặng cỡ nào. Không phải lỗi UI. Không phải lỗi logic. Đây là tiền thật của khách hàng thật, và hệ thống đang không xử lý đúng.

    Tôi mở dashboard WooCommerce lên. Đơn hàng vẫn trơ ra ở Pending Payment. Không có log lỗi nào từ plugin. Không có email xác nhận nào được gửi đi. Hệ thống hoạt động hoàn toàn bình thường — như thể Webhook của ZaloPay chưa bao giờ tồn tại.

    Đây không phải bug bình thường. Đây là kiểu bug mà càng nhìn vào càng thấy không có gì sai — cho đến khi bạn tụt xuống tận tầng Network để nhìn thẳng vào gói tin. Bài viết này ghi lại toàn bộ hành trình debug thực chiến đó, không bỏ sót bước nào.


    Tại Sao Webhook ZaloPay Lại Khó Debug Đến Vậy?

    Tích hợp thanh toán có một đặc thù chết người: luồng xử lý bị tách làm đôi.

    Phần đầu (người dùng redirect sang cổng thanh toán) bạn còn có thể test trực tiếp. Nhưng phần sau — Webhook từ server ZaloPay gọi ngược về server của bạn — là một tiến trình hoàn toàn bất đồng bộ, chạy sau hậu trường, không có giao diện, không có người nhìn, và không có bất kỳ thứ gì để bạn can thiệp trực tiếp trong lúc nó xảy ra.

    Khi nó im lặng, bạn có đúng ba nguồn thông tin: Log Nginx, Log PHP, và Log phía ZaloPay — mà Log phía ZaloPay thì Merchant Portal không cho bạn xem. Đó là lý do tại sao đây là loại bug dễ khiến một developer lành mạnh mất hướng sau vài chục phút.

    Phản xạ đầu tiên của tôi không phải là mở code lên sửa. Mà là: xác định xem lỗi nằm ở đâu đã.


    Bước 0: Cô Lập Lỗi Trước Khi Mổ Server

    Trước khi SSH vào VPS và bắt đầu đào bới, cần xác định một điều cơ bản: lỗi nằm ở phía ZaloPay hay phía server của mình?

    Câu trả lời đến từ webhook.site — một công cụ cho phép tạo một URL công khai, hứng mọi HTTP request đến và hiển thị toàn bộ header lẫn body theo thời gian thực.

    Quy trình cô lập:

    1. Vào webhook.site, lấy URL ngẫu nhiên được cấp.
    2. Thay URL đó vào phần Callback URL được gửi sang Zalopay cùng với các dữ liệu đơn hàng, chi tiết anh em có thể sang document chính thức của Zalopay để đọc.
    3. Tạo một đơn hàng test và thực hiện thanh toán.

    Kết quả: Chưa đầy 2 giây, webhook.site nhận được một payload JSON đầy đủ — app_trans_id, mac, amount, toàn bộ.

    Verdict: ZaloPay không có vấn đề gì. Lỗi nằm 100% ở phía server. Lúc này mới có cơ sở để mổ VPS.

    Nguyên tắc: Khi tích hợp API bên thứ ba (ZaloPay, MoMo, VNPay…), luôn cô lập lỗi bằng một endpoint trung gian trước khi đụng vào code hay server. Tiết kiệm ít nhất một giờ debug mù.


    Bước 1: Nginx Access Log “Im Lặng” — Dấu Hiệu Bất Thường Đầu Tiên

    Vấn đề của Webhook thanh toán nằm ở chỗ này: không giống phần redirect mà bạn còn test được trực tiếp, Webhook là tiến trình bất đồng bộ chạy hoàn toàn sau hậu trường — khi nó im lặng, bạn chỉ có đúng hai nguồn để bới: Log Nginx và Log PHP.

    Vậy nên việc đầu tiên, tôi mở Access Log của Nginx ra soi, để xem ZaloPay có thực sự gọi vào server không.

    bash

    tail -f /var/log/nginx/domain.com-access.log

    Kết quả: Không có gì. Không một dòng nào từ dải IP của ZaloPay. Chỉ có log của bot crawl dạo.

    Tình huống lúc này:

    • ❌ Không có log ở tầng ứng dụng (PHP/WordPress).
    • ❌ Không có log ở tầng web server (Nginx).
    • ❌ Không có quyền xem log phía ZaloPay.

    Khi cả ba cửa đều bị bịt, chỉ còn một cách: tụt xuống thẳng tầng Network.


    Bước 2: TCPDUMP — Nhìn Thẳng Vào Gói Tin Mạng

    tcpdump là công cụ bắt gói tin trực tiếp tại card mạng, không qua bất kỳ tầng trung gian nào. Nếu ZaloPay có gửi request đến IP của server, tcpdump sẽ thấy — dù Nginx có log hay không.

    Lệnh giăng lưới, lọc đúng port 443 và dải IP của hệ thống ZaloPay:

    bash

    tcpdump -n -i any port 443 and src net 118.102.0.0/16

    Tạo một đơn test và thanh toán. Màn hình SSH nổ ngay:

    11:53:15.057552 IP 118.102.5.66.60772 > 103.68.xxx.xxx.443: Flags [P.], length 268
    11:53:15.081858 IP 118.102.5.66.60772 > 103.68.xxx.xxx.443: Flags [P.], length 683
    11:53:15.094082 IP 118.102.5.66.60772 > 103.68.xxx.xxx.443: Flags [F.], length 0

    Gói tin length 683 chứa HTTP Header và JSON payload của Webhook.

    Kết luận bắt buộc từ dữ liệu thực tế:

    • ✅ ZaloPay đã gửi request.
    • ✅ Server đã nhận — không mất một byte nào.
    • ❓ Nhưng Nginx không ghi log và không truyền cho PHP.

    Vậy gói tin đó đi về đâu sau khi chạm vào server?


    Bước 3: Nguyên Nhân Gốc Rễ — Lỗi SNI Trong Nginx

    Để hiểu tại sao, cần biết cách Nginx xử lý HTTPS.

    SNI (Server Name Indication) Là Gì?

    Khi bạn hoặc Postman gọi vào https://domain.com, trong quá trình TLS Handshake, client sẽ đính kèm một thông tin quan trọng: tên miền đang muốn kết nối đến. Thông tin này gọi là SNI (Server Name Indication).

    Nginx đọc SNI, so khớp với danh sách server_name trong các file config, rồi đưa request đến đúng virtual host tương ứng để xử lý.

    Tại Sao ZaloPay Dính Lỗi SNI?

    Core hệ thống ZaloPay được viết bằng Golang. HTTP Client mặc định của Golang — trong một số cấu hình hoặc phiên bản — không tự động đính kèm SNI header trong quá trình TLS Handshake khi gọi đến IP trực tiếp.

    Kết quả:

    1. Gói tin ZaloPay chạm vào IP của server.
    2. Nginx nhận được kết nối TCP, nhưng không đọc được SNI — không biết request này muốn vào domain.com hay virtual host nào khác.
    3. Theo cơ chế mặc định, Nginx ném request vào Default Server — trong trường hợp này là một file config 000-catch-all được tự động tạo ra bởi control panel (HestiaCP/cPanel…).
    4. catch-all trả về lỗi (thường là 444 hoặc redirect rỗng), không ghi vào access log của domain.com.

    Đây là lý do tại sao Access Log của domain.com hoàn toàn im lặng dù gói tin đã đến server: request chưa bao giờ vào đúng virtual host.


    Cách Fix: Chỉ Định Default Server Cho Đúng Virtual Host

    Khi không có SNI, Nginx cần biết phải đẩy request vào virtual host nào. Giải pháp: chỉ định default_server cho virtual host chính.

    Bước 1 — Gỡ quyền default_server khỏi catch-all:

    Mở file /etc/nginx/sites-available/000-catch-all (hoặc tương đương), tìm và xóa default_server khỏi các dòng listen:

    nginx

    # Trước
    listen 443 ssl default_server;
    
    # Sau
    listen 443 ssl;

    Bước 2 — Gán default_server cho virtual host chính:

    Mở file config Nginx của domain.com, thêm default_server vào directive listen 443:

    nginx

    server {
        listen 443 ssl default_server;
        listen [::]:443 ssl default_server;
    
        server_name domain.com www.domain.com;
    
        # Phần còn lại giữ nguyên
    }

    Bước 3 — Kiểm tra cú pháp và reload:

    bash

    nginx -t && systemctl reload nginx

    Sau khi reload, mọi request HTTPS không có SNI sẽ mặc định được đưa vào domain.com thay vì rơi vào catch-all.


    Kết Quả Sau Fix — Và Cái Bẫy Tiếp Theo

    Test lại. Lần này Access Log của domain.com đã có phản ứng:

    POST /wp-json/cuatui/v1/zalopay-webhook HTTP/1.1" 403 Forbidden

    ZaloPay đã vào đúng virtual host. Nhưng một kẻ chặn đường mới xuất hiện: HTTP 403.

    Code đúng. Mạng thông. SNI đã fix. Nhưng tại sao request của ZaloPay bị từ chối?

    Thủ phạm của mã lỗi 403 này không phải từ WordPress hay code PHP — mà bắt nguồn từ một lệnh truy nã toàn cầu nhắm vào đúng cái User-Agent mà HTTP Client của Golang mang theo. Chi tiết ở [Kỳ 2]: Lệnh Truy Nã “Go-http-client” Và Cú Vả Điếng Người Từ ModSecurity/8G Firewall.


    Tóm Tắt Kỹ Thuật — Để Không Phải Debug Lại Từ Đầu

    Triệu chứngNguyên nhânGiải pháp
    Webhook ZaloPay không có log trong NginxHTTP Client Golang thiếu SNI khi TLS HandshakeChỉ định default_server cho virtual host chính
    tcpdump thấy gói tin nhưng Nginx không logRequest bị ném vào Default Server / catch-allXem mục cấu hình bên trên
    Test Postman OK nhưng Webhook thật thất bạiPostman gửi SNI, Golang client thì khôngNguyên nhân gốc là khác biệt TLS client behavior

    Checklist debug Webhook thanh toán theo thứ tự:

    1. Dùng webhook.site xác nhận đối tác có bắn request không.
    2. Dùng tcpdump xác nhận gói tin có đến server không.
    3. So sánh IP đến trong tcpdump với Access Log của từng virtual host.
    4. Nếu log im lặng dù tcpdump thấy gói tin → nghi ngờ ngay vấn đề SNI và Default Server.

    Mạng đã thông. SNI đã fix. Tưởng xong — nhưng con 403 kia mới là pha twist thật sự. Hẹn anh em ở Kỳ 2

  • Tôi đã build personal site bằng Headless WordPress + Astro như thế nào — và những gì tôi học được

    Kiến trúc thực tế, quyết định thiết kế, và lý do tôi không dùng Docker


    Trong bài Hello World trước, tôi có nhắc đến stack của site này: WordPress chạy headless, Astro làm frontend, tất cả trên một con DigitalOcean 2GB RAM ở Singapore. Bài này tôi sẽ kể chi tiết hơn — không phải tutorial copy-paste, mà là những quyết định thực sự tôi đã phải đưa ra, những chỗ tôi sai và phải làm lại, và tại sao tôi đi đến kiến trúc hiện tại.


    Tại sao Headless WordPress thay vì WordPress truyền thống

    Câu hỏi đầu tiên ai cũng hỏi: tại sao phức tạp vậy? Cài theme đẹp vào WordPress không xong à?

    Xong. Nhưng không phải đó là mục tiêu.

    Tôi làm WordPress theme development 8 năm. Tôi biết rõ WordPress render page như thế nào — PHP query database, template engine xử lý, HTML trả về browser. Tôi đã làm điều đó đủ nhiều lần để biết nó hoạt động tốt, nhưng tôi cũng biết rõ giới hạn của nó.

    Headless CMS giải quyết một vấn đề cụ thể: tách biệt nơi quản lý nội dung và nơi hiển thị nội dung. WordPress vẫn là nơi tôi đăng bài, quản lý media, kiểm soát taxonomy — những thứ nó làm tốt nhất. Nhưng giao diện người dùng cuối thấy không phải do WordPress render — đó là việc của Astro.

    Kết quả thực tế: người dùng nhận HTML tĩnh từ Nginx, không có PHP execution, không có database query trong request path. Tốc độ load ở mức milliseconds, không phải seconds.

    Và cá nhân hơn: tôi muốn học cách một modern frontend framework hoạt động trong môi trường production thực sự — không phải localhost, không phải Vercel free tier, mà là server tôi tự quản lý từ đầu đến cuối.


    Kiến trúc tổng thể

    Trước khi đi vào chi tiết từng phần, đây là bức tranh toàn cảnh:

    Browser
      └── Nginx (reverse proxy, SSL termination)
            ├── yourdomain.com     → serve /var/www/site/dist/ (Astro static files)
            ├── wp.yourdomain.com  → PHP-FPM → WordPress (headless backend)
            └── mail.yourdomain.com → Roundcube (webmail)
    
    WordPress REST API
      └── /wp-json/wp/v2/posts → Astro (lúc build)
    
    CI/CD Pipeline
      └── git push → GitHub Actions
            ├── npm run build (Astro)
            └── rsync dist/ → server

    Toàn bộ stack chạy trên một VPS DigitalOcean 2 CPU, 2GB RAM, 60GB SSD, đặt tại Singapore. Không có managed service, không có Vercel, không có Cloudflare Pages — mọi thứ tôi tự cài và tự quản lý.


    Những quyết định thiết kế và lý do đằng sau chúng

    1. Tại sao Astro thay vì Next.js

    Next.js là lựa chọn phổ biến hơn nhiều — nhu cầu tuyển dụng tại Việt Nam cho Next.js cao hơn Astro vài chục lần. Tôi biết điều đó.

    Nhưng với personal site, tôi có một constraint cứng: 2GB RAM. Next.js build tốn memory đáng kể, và nếu tôi chạy SSR thì Node.js server chạy liên tục sẽ cạnh tranh RAM với WordPress, MariaDB, và mail server.

    Astro được thiết kế với triết lý “zero JavaScript by default” — nó build ra HTML tĩnh, không cần runtime. Nginx serve file HTML đó trực tiếp, không có Node.js process nào chạy nền. Trên server 2GB, đây là sự khác biệt có thể tính được bằng con số.

    Thêm vào đó, với background WordPress theme development, Astro cảm giác rất tự nhiên — component system đơn giản, gần với HTML/CSS thuần, không có quá nhiều magic ẩn bên dưới.

    Sau này khi tôi mở rộng kỹ năng sang React, Astro là bước đệm tốt — những khái niệm như component, props, file-based routing đều có trong Astro và áp dụng được sang Next.js.

    2. WordPress chạy trên subdomain riêng

    WordPress không chạy ở yourdomain.com mà chạy ở wp.yourdomain.com. Người dùng không bao giờ thấy subdomain này trong quá trình dùng web — nó chỉ là backend API endpoint cho Astro gọi lúc build.

    Quyết định này quan trọng hơn tôi nghĩ ban đầu. Lý do:

    Bảo mật tốt hơn. wp-admin không nằm ở domain chính. Bots scan yourdomain.com/wp-admin sẽ không tìm thấy gì. Tôi có thể giới hạn access vào wp.yourdomain.com bằng IP whitelist hoặc basic auth ở Nginx level mà không ảnh hưởng gì đến frontend.

    CORS rõ ràng hơn. Nginx config trên WordPress server có header Access-Control-Allow-Origin: https://yourdomain.com — chỉ cho phép Astro frontend gọi API, không ai khác.

    Tách biệt hoàn toàn. Nếu sau này tôi muốn đổi frontend sang Next.js hay bất kỳ framework nào khác, WordPress backend không cần thay đổi gì.

    3. Static build thay vì SSR

    Astro có thể chạy ở hai chế độ: static (build ra HTML sẵn) hoặc SSR (render theo từng request). Tôi chọn static.

    Lý do đơn giản: người dùng nhận file HTML tĩnh từ Nginx. Không có computation nào xảy ra khi request đến. Đây là cách serve web nhanh nhất có thể — nhanh hơn bất kỳ SSR framework nào dù được optimize tốt đến đâu.

    Trade-off duy nhất: khi tôi đăng bài mới trên WordPress, website không tự cập nhật — phải có một lần build mới. Tôi giải quyết vấn đề này bằng webhook: WordPress publish post → gọi GitHub API → trigger Actions → Astro rebuild → deploy. Toàn bộ quá trình mất khoảng 2-3 phút, hoàn toàn tự động.

    4. Mail server trên cùng VPS

    Đây là quyết định nhiều người sẽ không đồng ý — thông thường mail server được khuyến nghị chạy riêng vì deliverability phụ thuộc nhiều vào IP reputation.

    Tôi chấp nhận trade-off đó vì đây là personal site, không phải transactional email hàng nghìn người nhận. Nhu cầu của tôi đơn giản: một địa chỉ email @yourdomain.com để dùng cho liên lạc cá nhân và liên lạc nghề nghiệp.

    Stack mail: Postfix xử lý SMTP, Dovecot xử lý IMAP, Roundcube làm webmail interface. Tất cả cài native trên Ubuntu, không container hóa.


    Những chỗ tôi đã sai và phải làm lại

    Không có dự án nào chạy đúng ngay từ đầu. Đây là những chỗ tôi đã phải quay lại sửa.

    Sai 1: Quên swap memory

    Con server 2GB RAM không có swap mặc định. Lần đầu chạy npm run build cho Astro trên server, process bị kill giữa chừng vì OOM (Out of Memory). Build tốn khoảng 300-400MB RAM tạm thời trong quá trình compile.

    Giải pháp: tạo 2GB swap file ngay khi setup server. Bây giờ tôi làm điều này đầu tiên trước bất cứ thứ gì khác.

    Bài học: build process khác với runtime. Runtime của Astro static site gần như không tốn RAM. Nhưng bản thân việc build thì tốn. Hai con số này cần được tính riêng.

    Sai 2: Build Astro trên server thay vì CI

    Ban đầu tôi setup pipeline theo kiểu: push code → SSH vào server → chạy git pull → chạy npm run build trên server. Điều này dẫn đến vấn đề ở trên — server OOM lúc build.

    Giải pháp đúng: build trên GitHub Actions runner (không giới hạn RAM), chỉ rsync thư mục dist/ lên server. Server không cần Node.js, không cần npm, không cần build gì cả — chỉ cần Nginx serve file tĩnh.

    Đây là sự khác biệt giữa “pull-based deployment” (server tự pull code về build) và “push-based deployment” (CI build xong đẩy artifact lên server). Với constraint RAM, push-based là lựa chọn duy nhất hợp lý.

    Sai 3: Không tách WordPress core ra khỏi git

    Lần đầu tôi tạo repo cho WordPress, tôi commit cả thư mục WordPress core vào — hàng nghìn files, repo nặng cả GB. Sai hoàn toàn.

    Cấu trúc đúng: WordPress core được cài thẳng trên server, không qua git. Chỉ có theme và plugin tự viết mới nằm trong git. wp-config.php nằm trong .gitignore vì nó chứa database credentials.

    Nguyên tắc: git quản lý code bạn viết, không quản lý dependency và configuration có credentials.

    Sai 4: Dùng một SSH key cho cả personal và CI/CD

    Ban đầu tôi dùng SSH key cá nhân làm GitHub Actions secret để deploy. Đây là security risk — nếu key bị leak, attacker có full SSH access vào server với quyền của tôi.

    Giải pháp: tạo SSH key riêng chỉ cho CI/CD với user deploy có quyền hạn chế — chỉ có thể write vào thư mục /var/www/, không có sudo, không thể làm gì khác trên server.

    Mỗi actor (con người, CI pipeline) nên có credential riêng với quyền hạn tối thiểu cần thiết — nguyên tắc least privilege.


    Tại sao không dùng Docker

    Đây là câu hỏi tôi nhận được nhiều nhất khi kể về stack này.

    Docker giải quyết một vấn đề cụ thể: isolation và reproducibility — đảm bảo mọi environment chạy giống nhau, tránh xung đột giữa các service, dễ dàng scale và di chuyển.

    Nhưng Docker có chi phí: Docker daemon tốn khoảng 150MB RAM, mỗi container thêm overhead, và quan trọng hơn — Docker thêm một tầng abstraction vào giữa code và hệ điều hành. Tầng đó cần được hiểu, debug, và maintain.

    Với stack của tôi trên server 2GB RAM chạy một dự án duy nhất, Docker không giải quyết vấn đề nào tôi đang có — nó chỉ thêm vấn đề mới. Không có xung đột PHP version vì chỉ có một project. Không cần scale vì đây là personal site. Không cần onboard developer mới vì tôi làm một mình.

    Docker phù hợp nhất khi bạn có một server lớn chạy nhiều project của nhiều team — mỗi project cần PHP version khác nhau, Node version khác nhau, và cần cô lập hoàn toàn. Hoặc khi bạn cần deploy nhanh lên server mới mà không muốn cài từng thứ một — docker compose up và xong.

    Không phải scenario của tôi. Native stack trên Ubuntu 24.04 — Nginx, PHP-FPM, MariaDB, Postfix, Dovecot — cài một lần, chạy ổn định, dễ debug khi có vấn đề vì không có container layer ở giữa.


    Kết quả và những gì tiếp theo

    Site hiện tại load dưới 1 giây ở Việt Nam — phần lớn là vì static HTML served từ Singapore data center, không có server-side computation.

    CI/CD pipeline chạy ổn định: push code theme lên GitHub, 30 giây sau site đã có version mới. Publish bài mới trên WP admin, 3 phút sau Astro đã rebuild và deploy xong.

    Mail server hoạt động, tôi có khanh@yourdomain.com để dùng cho liên lạc nghề nghiệp.

    Những thứ tôi muốn làm tiếp:

    • Search — Astro static site không có server-side search. Đang xem xét Pagefind, một thư viện search chạy hoàn toàn ở client-side, không cần backend.
    • Analytics không tracking — Plausible hoặc Umami self-hosted thay vì Google Analytics.
    • Comment system — có thể dùng Giscus (dựa trên GitHub Discussions) để tránh phải chạy thêm database.

    Những thứ tôi không làm tiếp: thêm feature vì thêm được, nâng cấp server vì muốn có thêm RAM để làm những thứ không cần thiết, hay migrate sang stack mới vì nghe có vẻ hay.

    Stack tốt là stack giải quyết được vấn đề thực sự của bạn với mức độ phức tạp thấp nhất cần thiết. Hiện tại, stack này làm đúng điều đó.


    Bài tiếp theo tôi sẽ viết về cách tôi setup CI/CD pipeline từ đầu — GitHub Actions, rsync, SSH key management, và cái webhook trigger rebuild khi publish bài WordPress. Nếu bạn đang làm điều gì đó tương tự, subscribe hoặc bookmark lại.

    — Khảnh, tháng 4/2026

  • Hello world!

    Xin chào thế giới và cộng đồng lập trình, tôi rất vui khi được viết những bài đầu tiên trên này

    <?php echo "Hello world!"; ?>
    <script>console.log("Hello world!")</script>
    print("Hello, World!")

    Mọi developer đều bắt đầu bằng ba chữ này.

    Không phải ngẫu nhiên. Hello, World! là cái bắt tay đầu tiên giữa bạn và một ngôn ngữ mới — đủ đơn giản để chạy được ngay, đủ có ý nghĩa để bạn nhớ mãi lần đầu nó hiện lên màn hình terminal.

    Bài viết đầu tiên trên blog này cũng vậy.


    Tôi là ai

    Tôi là Khảnh — WordPress developer, làm việc tại Hà Nội.

    4+ năm với WordPress không có nghĩa là 4+ năm kéo thả Elementor. Tôi build theme từ đầu, viết PHP, đọc functions.php như đọc báo sáng, và debug WooCommerce lúc 11 giờ đêm trước ngày khách go-live.

    Gần đây tôi bắt đầu đi xa hơn khỏi WordPress thuần túy — headless CMS, REST API, CI/CD, tự setup server. Cái blog này chính là sản phẩm của hành trình đó.


    Cái site này được build như thế nào

    Tôi có thể dùng WordPress bình thường và cài một cái theme đẹp trong 30 phút. Nhưng tôi không làm vậy.

    Thay vào đó, stack của site này là:

    • WordPress chạy headless — chỉ làm backend, không render giao diện
    • Astro làm frontend — gọi WP REST API lúc build, xuất ra static HTML
    • Nginx trên DigitalOcean Singapore serve mọi thứ
    • GitHub Actions tự động deploy khi tôi push code
    • Postfix + Dovecot + Roundcube cho mail server ngay trên cùng con server

    Lý do tôi build theo cách này không phải vì nó dễ hơn — mà vì tôi muốn hiểu từng layer hoạt động như thế nào. Mỗi lần cái gì đó không chạy là một lần tôi học được thứ gì đó mới.

    Bài viết tiếp theo tôi sẽ kể chi tiết hơn về kiến trúc này — những quyết định thiết kế, những chỗ tôi đã sai và sửa lại, và tại sao tôi không dùng Docker cho stack này.


    Blog này về cái gì

    Không có lịch đăng bài cố định. Không có cam kết “mỗi tuần một bài”.

    Tôi sẽ viết khi tôi có thứ đáng viết — giải pháp cho một vấn đề thực tế, quyết định kiến trúc và lý do đằng sau nó, hoặc đơn giản là thứ tôi vừa học được và muốn ghi lại trước khi quên.

    Nếu bạn cũng là developer đang làm việc với WordPress, headless CMS, hoặc đang tự build infrastructure — có thể bạn sẽ tìm thấy thứ gì đó hữu ích ở đây.

    Còn nếu không — Hello, World! vẫn là một câu chào tốt để bắt đầu.


    — Khảnh, tháng 4/2026