Tự host backend .NET trên VPS Windows: Kestrel, cổng và 5 lỗi deploy đầu tiên
Chuyển backend từ PaaS sang VPS tự quản không khó ở phần code. Nó khó ở phần vận hành: ai giữ tiến trình sống, cổng nào mở ra internet, cấu hình nằm ở đâu, và làm sao biết API chết lúc 2 giờ sáng.
1. Một tối thứ Ba bình thường
22 giờ 40. Khách nhắn: "Anh ơi em đăng nhập không được, nó cứ quay rồi báo lỗi mạng."
Mở web lên, trang chủ vẫn vào bình thường — vì trang chủ là file tĩnh trên host khác. Nhưng mọi thứ cần API đều chết: đăng nhập, đăng ký, danh sách VPS, cả nút gia hạn. Kiểm tra nhanh bằng một lệnh gọi thẳng vào endpoint kiểm tra sức khoẻ của API: không phản hồi.
Trong Remote Desktop, cửa sổ terminal mà tôi dùng để chạy API đã biến mất. Không phải API treo, không phải lỗi code, không phải máy chủ chết. Chỉ đơn giản là cái cửa sổ chạy nó đã đóng, và theo nó là cả tiến trình. Ai đóng? Windows cập nhật, một lần đăng xuất phiên, hay chính tôi lúc dọn màn hình — không quan trọng. Cái đáng nói là suốt gần nửa tiếng, hệ thống giám sát không hề biết có chuyện gì, vì lúc đó tôi chưa có giám sát.
Sự cố đó dạy tôi một điều đơn giản: đưa backend .NET lên VPS Windows thì phần code chỉ chiếm một phần ba công việc. Hai phần còn lại nằm ở vận hành — ai giữ tiến trình sống, cổng nào mở ra ngoài, cấu hình nằm ở đâu, và làm sao để biết nó chết trước khi khách nhắn.
Bài này viết lại đúng những gì tôi đã làm sai, theo thứ tự tôi đã gặp.
2. Bài này dành cho ai
Có ba nhóm người đọc thường gặp với chủ đề này.
Thứ nhất, lập trình viên đang chạy backend trên một nền tảng đám mây trả theo tháng, thấy hoá đơn tăng dần, và muốn chuyển sang một VPS Windows mình toàn quyền kiểm soát.
Thứ hai, người đã có VPS Windows để chạy bot, EA, hoặc một phần mềm desktop, và muốn dùng luôn máy đó làm nơi chạy API cho app của mình thay vì thuê thêm.
Thứ ba, người làm sản phẩm nội bộ: API chỉ phục vụ vài chục người dùng, dữ liệu quan trọng hơn lưu lượng, và điều duy nhất bạn cần là nó không được chết im lặng.
Cả ba nhóm có cùng một danh sách việc phải giải quyết, chỉ khác mức độ. Bài này đi từ kiến trúc tối thiểu, qua năm lỗi hay gặp nhất, rồi tới bốn bước chẩn đoán khi API "không chạy". Cuối bài có checklist để bạn tự chấm trước khi mở cho người dùng thật.
3. Kiến trúc tối thiểu bạn cần hiểu
Một backend .NET tự host trên VPS Windows thường có bốn tầng, và điều quan trọng nhất là tầng nào được phép nhìn thấy internet.
Tầng ngoài cùng là người dùng: trình duyệt, app, hoặc một tiến trình trên máy khác. Tầng này chỉ được phép nói chuyện qua cổng 443.
Tầng tiếp theo là reverse proxy. Trên Windows, Caddy là lựa chọn dễ chịu nhất vì nó tự xin và tự gia hạn chứng chỉ HTTPS mà bạn không phải viết một dòng cấu hình phức tạp nào. Nó nghe ở cổng 443 và chuyển tiếp vào một cổng nội bộ.
Tầng thứ ba là chính ứng dụng .NET của bạn, chạy trên Kestrel. Kestrel là web server có sẵn trong ASP.NET Core, không cần IIS, không cần cài thêm gì. Nó nghe ở một cổng nội bộ, ví dụ 5101, và cổng này không nên mở ra internet.
Tầng cuối là nơi lưu dữ liệu — PostgreSQL, SQL Server hoặc SQLite, tuỳ quy mô. Nếu dữ liệu nằm cùng máy thì nó chỉ nghe ở 127.0.0.1, không nghe ở 0.0.0.0.
Nhiều người bỏ qua tầng proxy vì nghĩ "cứ mở thẳng cổng 5000 ra là xong". Cách đó chạy được, và đó chính là lý do nó nguy hiểm: bạn phải tự lo chứng chỉ, tự lo chuyển hướng, tự nhớ cập nhật firewall, và vô tình biến cổng nội bộ thành cổng công khai mà không ai kiểm soát. Thêm một tầng proxy mất khoảng nửa giờ cấu hình và tiết kiệm cho bạn rất nhiều lần sửa về sau.
4. Chuẩn bị: chọn cách publish trước khi viết dòng nào
Trước khi bàn tới các lỗi, hãy chốt cách bạn sẽ đóng gói ứng dụng. Có hai lựa chọn và chúng ảnh hưởng tới mọi bước sau.
Cách thứ nhất là publish phụ thuộc framework: máy chạy cần cài .NET runtime đúng phiên bản. File phát ra nhỏ, build nhanh, và bạn cần nhớ một điều duy nhất: khi nâng phiên bản .NET, phải cập nhật runtime trên VPS trước khi deploy, nếu không ứng dụng không khởi động được.
Cách thứ hai là publish kèm runtime: toàn bộ runtime nằm trong thư mục phát ra, máy chạy không cần cài gì. Thư mục nặng vài chục tới vài trăm megabyte, nhưng đổi lại nó chạy giống nhau ở mọi máy, và bạn không bao giờ gặp cảnh "máy dev chạy được mà VPS thì không" vì lệch phiên bản runtime.
Với VPS Windows chỉ chạy một ứng dụng, tôi nghiêng về cách thứ hai. Dung lượng ổ đĩa rẻ hơn thời gian bạn bỏ ra để truy một lỗi lệch runtime vào lúc 2 giờ sáng.
Một chi tiết nhỏ nhưng hay bị bỏ qua: hãy publish vào một thư mục cố định, riêng cho từng môi trường. Ví dụ C:\FindMe\Api cho môi trường chạy thật và C:\FindMe\Api-Staging cho bản thử. Nghe có vẻ thừa, nhưng nó là cách rẻ nhất để tránh lỗi số 5 ở phần dưới.
Nếu VPS của bạn đang chạy song song cả bot giao dịch, hãy đọc thêm bài VPS vận hành bot 24/7 để biết cách chia tài nguyên giữa bot và backend trên cùng một máy.
5. Lỗi 1 — Đổi cổng bằng biến môi trường mà không có tác dụng
Đây là lỗi đầu tiên và cũng là lỗi làm tôi mất nhiều thời gian nhất, vì triệu chứng của nó rất khó đoán: bạn đặt biến môi trường ASPNETCORE_URLS=http://0.0.0.0:5101, khởi động lại ứng dụng, và ứng dụng vẫn nghe ở cổng cũ.
Nguyên nhân nằm ở dòng cuối cùng của Program.cs. Rất nhiều mẫu code — kể cả mẫu do chính bạn viết từ đầu dự án — kết thúc bằng:
app.Run($"http://{bindHost}:{port}");Khi bạn gọi app.Run(url) với một địa chỉ cụ thể, bạn đang ghi đè toàn bộ cấu hình địa chỉ. Kestrel không còn đọc ASPNETCORE_URLS nữa, và cũng không đọc applicationUrl trong file cấu hình. Giá trị bạn truyền vào là giá trị duy nhất có hiệu lực. Đây là hành vi đúng của thư viện, không phải lỗi — nhưng nó phá vỡ kỳ vọng của gần như mọi người lần đầu tự host.
Có hai cách sửa, và bạn nên chọn cách thứ nhất.
Cách thứ nhất, đọc cổng từ biến môi trường ngay trong code và dùng chính giá trị đó để gọi app.Run. Bạn giữ nguyên quyền kiểm soát, nhưng cho phép môi trường quyết định:
var port = Environment.GetEnvironmentVariable("PORT") ?? "5000";
var host = Environment.GetEnvironmentVariable("BINDHOST") ?? "127.0.0.1";
app.Run($"http://{host}:{port}");Với cách này, đổi cổng chỉ là đổi biến môi trường của tiến trình, không phải sửa code rồi build lại. Điều đó quan trọng khi bạn có nhiều ứng dụng trên cùng một VPS: mỗi ứng dụng một cổng, tất cả do cấu hình quyết định.
Cách thứ hai là bỏ hẳn tham số trong app.Run() và để Kestrel tự đọc cấu hình. Cách này cũng đúng, nhưng nó phụ thuộc vào việc biến môi trường được đặt đúng ở tầng nào — và đây là chỗ dễ sai. Nếu bạn chạy ứng dụng bằng Scheduled Task, biến môi trường của phiên đăng nhập không tự động có trong tiến trình đó. Bạn phải đặt biến bằng script khởi động hoặc trong cấu hình của task, rồi mới mong nó có hiệu lực.
Cách kiểm tra đúng không phải là đọc code mà là đọc thực tế: mở PowerShell trên VPS và hỏi hệ điều hành xem cổng nào đang được nghe, do tiến trình nào giữ.
Get-NetTCPConnection -State Listen -LocalPort 5101 |
Select-Object LocalAddress, LocalPort, OwningProcessNếu lệnh này không trả về gì, ứng dụng của bạn không nghe ở cổng đó, bất kể trong log ghi gì. Đây là phép kiểm tra tôi dùng nhiều nhất trong toàn bộ bài viết này.
6. Lỗi 2 — Mở terminal thì API chạy, tắt terminal là API chết
Đây chính là sự cố trong phần mở đầu. Cách chạy bằng tay — mở PowerShell, gõ lệnh khởi động ứng dụng, thu nhỏ cửa sổ — phù hợp để thử nghiệm và hoàn toàn không phù hợp để vận hành.
Có hai lý do. Lý do thứ nhất là tiến trình ứng dụng là con của cửa sổ terminal: đóng cửa sổ, tiến trình con nhận tín hiệu kết thúc. Lý do thứ hai tinh tế hơn: phiên đăng nhập Windows có thể bị kết thúc vì cập nhật hệ thống, vì chính sách bảo mật, hoặc vì bạn quên đang Remote Desktop. Khi phiên kết thúc, mọi thứ chạy trong phiên đó kết thúc theo.
Cách đúng trên Windows, không cần cài thêm phần mềm, là dùng Scheduled Task. Task Scheduler không phải chỉ để chạy việc theo lịch — nó là cách chuẩn để chạy một tiến trình nền bền vững dưới quyền người dùng hiện tại.
Bốn thiết lập quyết định task của bạn sống hay chết, và cả bốn đều dễ đặt sai:
Thứ nhất, thời hạn chạy. Mặc định của Windows là dừng task sau 72 giờ. Với một API chạy 24/7, bạn không muốn nó tự dừng vào ngày thứ tư. Đặt thời hạn bằng 0, nghĩa là không giới hạn.
Thứ hai, quyền chạy. RunLevel Limited (quyền người dùng thường) là đủ cho hầu hết ứng dụng, và nó tránh cho bạn phải nhập mật khẩu quản trị mỗi lần task khởi động lại. Chỉ nâng lên quyền cao nhất khi ứng dụng thật sự cần mở cổng dưới 1024 hoặc sửa file hệ thống.
Thứ ba, kiểu đăng nhập. Với máy bạn vẫn đăng nhập bằng Remote Desktop, LogonType Interactive là lựa chọn đơn giản nhất: task chạy trong phiên của bạn, và bạn vẫn nhìn thấy tiến trình khi cần chẩn đoán.
Thứ tư, chống chạy trùng. Nếu task có bộ kích hoạt lặp lại và bản chạy trước chưa kết thúc, bạn cần MultipleInstances IgnoreNew. Không có nó, bạn có thể có hai bản API cùng cố gắng mở một cổng, và bản thứ hai chết ngay khi khởi động với lỗi "địa chỉ đã được sử dụng".
Một cái bẫy riêng của dòng lệnh quản trị task mà bạn nên biết trước: khi bạn sửa hành động của một task đang có bằng Set-ScheduledTask -Action, phần cấu hình lặp lại của bộ kích hoạt có thể bị xoá. Task vẫn còn, vẫn "đang bật", nhưng không bao giờ tự chạy lại nữa — và bạn chỉ phát hiện ra vài ngày sau, khi mở danh sách task lên và thấy cột lần chạy kế tiếp để trống. Cách an toàn là đăng ký lại task với đầy đủ bộ kích hoạt, cấu hình và tài khoản chạy, rồi so sánh thời điểm chạy kế tiếp trước và sau khi sửa. Nếu hai giá trị giống nhau, bạn làm đúng.
Cuối cùng, hãy đặt cho task một cái tên theo quy ước rõ ràng, ví dụ FindMe-Api, và đừng bao giờ đặt tên chung chung như `Api`. Khi bạn có mười task, cái tên là thứ duy nhất giúp bạn biết task nào đang giữ cổng 5101.
7. Lỗi 3 — Cấu hình biến mất sau mỗi lần publish
Triệu chứng: bạn publish bản mới, ứng dụng chạy lên, và đột nhiên nó kết nối vào cơ sở dữ liệu sai, hoặc mất khoá bí mật, hoặc quên đường dẫn thư mục log.
Nguyên nhân rất đơn giản: lệnh publish ghi đè thư mục đầu ra, bao gồm cả các file cấu hình nằm trong đó. Nếu bạn sửa appsettings.json trực tiếp trên VPS để trỏ vào cơ sở dữ liệu chạy thật, thì lần publish tiếp theo sẽ xoá sạch thay đổi đó.
Cách sửa tôi dùng và đã kiểm chứng qua nhiều lần deploy: tách cấu hình riêng máy ra một file khác, và loại file đó khỏi quá trình publish.
Đầu tiên, thêm một file cấu hình chỉ dành cho máy chạy, ví dụ appsettings.local.json. Trong .NET, các file cấu hình được nạp theo thứ tự, file sau ghi đè file trước, nên bạn chỉ cần khai báo thêm một nguồn cấu hình nữa:
builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: false);Sau đó loại file này khỏi danh sách file được publish. Trong file dự án:
<ItemGroup>
<Content Update="appsettings.local.json" CopyToOutputDirectory="Never" CopyToPublishDirectory="Never" />
</ItemGroup>Kết quả: file appsettings.local.json trên VPS không bao giờ bị publish động tới. Bạn publish bao nhiêu lần cũng được, chuỗi kết nối cơ sở dữ liệu, khoá bí mật, đường dẫn log vẫn nguyên.
Ba lưu ý đi kèm, vì tôi đã từng làm sai cả ba.
Thứ nhất, file appsettings.local.json không được vào kho mã nguồn. Nó chứa bí mật của môi trường chạy. Nếu kho của bạn là công khai, hãy thêm nó vào .gitignore ngay hôm nay, trước cả khi bạn viết dòng cấu hình đầu tiên.
Thứ hai, vẫn nên có một file mẫu appsettings.local.example.json trong kho, chỉ chứa tên khoá và giá trị giả. Người tiếp theo mở dự án sẽ biết cần điền gì.
Thứ ba, sau mỗi lần publish, hãy xác nhận file cấu hình còn nguyên bằng cách kiểm tra thời gian sửa đổi của nó:
Get-Item C:\FindMe\Api\appsettings.local.json | Select-Object LastWriteTime, LengthNếu thời gian sửa đổi nhảy sang đúng thời điểm bạn publish, bạn đã cấu hình sai ở đâu đó — và tốt nhất là phát hiện ngay lúc đó, chứ không phải sáng hôm sau khi có người báo không đăng nhập được.
8. Lỗi 4 — Cửa sổ đen nháy lên mỗi lần task chạy
Bạn đã chuyển sang Scheduled Task, API chạy ổn, nhưng mỗi lần task kích hoạt thì một cửa sổ console đen hiện lên khoảng một giây rồi tắt. Nếu task chạy mỗi năm phút, bạn có một cửa sổ nháy mỗi năm phút. Nếu task chạy mỗi phút, rất khó chịu.
Phản xạ đầu tiên của tôi là thêm -WindowStyle Hidden vào dòng lệnh. Trên Windows 11, cách này không đủ. Cờ đó ẩn cửa sổ chính, nhưng hệ thống vẫn tạo một console cho tiến trình con, và bạn vẫn thấy nó nháy. Tôi đã mất một buổi tối đo đạc để tin vào điều này.
Cách chặn đúng là chạy tiến trình bên trong một console ẩn do hệ thống tạo, bằng cách gọi tiến trình qua conhost.exe với cờ --headless:
conhost.exe --headless powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\FindMe\Api\start-api.ps1"Đặt dòng lệnh này vào hành động của Scheduled Task, và cửa sổ biến mất vĩnh viễn: tiến trình vẫn chạy, vẫn ghi log ra file, nhưng không có gì hiện trên màn hình.
Một lưu ý hữu ích khi bạn cần kiểm tra bằng chứng: một console ẩn vẫn tạo tiến trình conhost.exe, nhưng nó không có cửa sổ chính. Bạn kiểm tra được bằng số:
Get-Process conhost | Where-Object { $_.MainWindowHandle -ne 0 } | Measure-ObjectNếu lệnh trả về 0, không có cửa sổ console nào đang hiện trên màn hình.
Cuối cùng, đừng quên đọc kết quả chạy gần nhất của task. Task Scheduler lưu mã kết quả, và hai giá trị bạn gặp nhiều nhất là 0 (chạy xong bình thường) và 267009 — mã này nghĩa là task đang chạy, hoàn toàn bình thường với một tiến trình dài hạn. Nhiều người thấy con số lạ rồi tưởng task lỗi và đi sửa những thứ không hỏng.
Đọc mã kết quả bằng một lệnh:
Get-ScheduledTaskInfo -TaskName FindMe-Api | Select-Object LastRunTime, LastTaskResult, NextRunTime9. Lỗi 5 — Chứng chỉ, proxy và lỗi 502
Lỗi cuối cùng trong nhóm năm lỗi đầu tiên là nhóm lỗi "đã làm mọi thứ đúng nhưng người dùng vẫn thấy lỗi". Trình duyệt báo không kết nối được, hoặc proxy báo 502 Bad Gateway, trong khi ngồi trên VPS gọi thẳng vào 127.0.0.1:5101 thì API trả lời bình thường.
Trong nhóm này có ba nguyên nhân, xếp theo thứ tự tần suất.
Proxy trỏ sai cổng. Cấu hình proxy ghi 5000 nhưng ứng dụng đang nghe 5101. Sửa một dòng là xong, nhưng chỉ tìm ra được nếu bạn nhớ rằng cổng là thứ phải đối chiếu giữa hai nơi: cấu hình proxy và cổng thật đang nghe.
Chứng chỉ chưa được cấp lại sau khi đổi tên miền. Công cụ proxy hiện đại tự xin chứng chỉ cho tên miền trong cấu hình, nhưng chúng thường chỉ làm việc đó một lần, lúc khởi động. Nếu bạn sửa cấu hình rồi nạp lại cấu hình bằng lệnh reload, nó sẽ chạy với tên miền mới nhưng chứng chỉ cũ — và trình duyệt từ chối kết nối. Cách chắc chắn nhất là khởi động lại hẳn tiến trình proxy sau khi đổi tên miền, rồi kiểm tra lại bằng một lệnh gọi HTTPS từ ngoài.
Firewall chặn cổng 443. Bạn mở cổng nội bộ đúng cách, dựng proxy đúng cách, nhưng tường lửa của Windows vẫn chặn cổng vào. Kiểm tra bằng cách gọi từ một máy khác trong cùng mạng LAN, sau đó từ internet. Hai phép thử cho ra hai kết quả khác nhau là dấu hiệu gần như chắc chắn của tường lửa.
Nguyên tắc tôi rút ra sau khi sửa đủ ba lỗi này nhiều lần: chỉ mở đúng một cổng ra internet, và biết chắc cổng nào là cổng đó. Mọi cổng khác — cổng của Kestrel, cổng của cơ sở dữ liệu, cổng quản trị — nên chỉ nghe trên 127.0.0.1. Khi cần truy cập từ xa để quản trị, dùng một kênh riêng như mạng riêng ảo, đừng mở cổng ra ngoài cho tiện.
10. Bốn bước chẩn đoán khi API "không chạy"
Khi có sự cố, thứ tự kiểm tra quan trọng không kém nội dung kiểm tra. Kiểm tra sai thứ tự là cách nhanh nhất để mất một tiếng đồng hồ vào việc không liên quan.
Bước một: cổng có ai nghe không?
Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 5101, 443 } |
Select-Object LocalAddress, LocalPort, OwningProcessKhông có kết quả nghĩa là chưa có tiến trình nào nghe ở cổng đó. Mọi kiểm tra tiếp theo đều vô nghĩa cho tới khi câu hỏi này có câu trả lời.
Bước hai: tiến trình còn sống không, và sống bao lâu rồi?
Get-Process -Name dotnet -ErrorAction SilentlyContinue |
Select-Object Id, ProcessName, StartTime, WorkingSetThời điểm khởi động quan trọng hơn bạn tưởng. Nếu nó là "hai phút trước", API vừa khởi động lại — nghĩa là nó đã chết và được bật lại, và bạn cần tìm nguyên nhân chết chứ không phải lỗi hiện tại.
Bước ba: log nói gì?
Đây là bước cho nhiều thông tin nhất và cũng là bước hay bị bỏ qua nhất, vì log thường nằm ở nơi không ai nhớ. Hãy chốt ngay từ đầu một đường dẫn log cố định, ví dụ C:\FindMe\Api\logs\api-<ngày>.log, và ghi nó vào tài liệu vận hành. Sau đó, chỉ xem hai mươi dòng cuối trước khi xem cả file:
Get-Content C:\FindMe\Api\logs\api-2026-10-01.log -Tail 20Trong gần như mọi trường hợp tôi gặp, dòng gây lỗi nằm trong hai mươi dòng cuối.
Bước bốn: proxy có chuyển tiếp đúng không?
Gọi thẳng API từ trong máy, rồi gọi qua proxy. Nếu gọi thẳng được mà gọi qua proxy không được, vấn đề nằm ở tầng proxy hoặc tường lửa, không nằm ở ứng dụng. Kết luận đó tiết kiệm cho bạn toàn bộ thời gian đọc lại code ứng dụng.
11. Bẫy publish ghi đè giữa nhiều môi trường
Đây là lỗi nặng nhất tôi từng gặp, vì triệu chứng của nó không giống lỗi triển khai mà giống lỗi logic.
Bối cảnh: cùng một mã nguồn dùng để dựng hai sản phẩm khác nhau, mỗi sản phẩm deploy vào một thư mục trên cùng VPS. Một lần, sau khi publish sản phẩm A, sản phẩm B bắt đầu lỗi ở một chức năng đã chạy tốt nhiều tháng. Kiểm tra mã nguồn: không có gì thay đổi. Kiểm tra log: lỗi đến từ một đoạn mã không tồn tại trong nhánh của B.
Nguyên nhân là do publish nhầm thư mục: lệnh publish của A trỏ vào thư mục của B, và ghi đè file thư viện đã biên dịch của B. File đó tồn tại, đúng tên, nhưng là bản của sản phẩm khác.
Cách chặn gồm ba việc.
Thứ nhất, mỗi môi trường một thư mục riêng, và ghi đường dẫn đó vào một file script publish, không gõ lại đường dẫn bằng tay mỗi lần. Lỗi gõ tay gây ra chuyện này chính vì nó không bị kiểm tra.
Thứ hai, đặt tên thư mục trùng với tên sản phẩm, không dùng tên chung như api, web, build. Khi hai thư mục cùng tên ở hai nơi, bạn sẽ có ngày gõ nhầm.
Thứ ba, khi nghi ngờ file đã bị ghi đè, đừng so sánh bằng tên hay ngày tháng — hãy tìm một chuỗi đặc trưng của từng sản phẩm ngay trong file nhị phân. Với tôi, mỗi sản phẩm có một tên lớp hoặc một chuỗi định danh riêng, và một lệnh quét là đủ để biết file đang chạy là của ai:
Select-String -Path C:\FindMe\Api\FindMe.Cloud.dll -Pattern "LoginChallenge","verify-otp" -Encoding Byte -SimpleMatchKỹ thuật này đã cứu tôi một lần khi file trên máy chủ có kích thước khác bản build trên máy dev, và tôi mất nửa ngày vì tin vào cảm giác "chắc là bản mới nhất".
12. Phía frontend: ba nguyên nhân của "Failed to fetch"
Một nửa các sự cố "API không chạy" thực ra không nằm ở backend. Chúng nằm ở phía gọi API, và biểu hiện thường giống hệt nhau: trình duyệt báo một lỗi mạng chung chung, không nói gì thêm.
Nguyên nhân thứ nhất: địa chỉ API bị hard-code. Frontend đóng gói cứng một địa chỉ, và khi bạn đổi tên miền hay đổi cổng, nó vẫn gọi vào địa chỉ cũ. Cách chống là đưa địa chỉ API ra biến môi trường ngay từ đầu dự án, và có đúng một nơi đọc biến đó.
Nguyên nhân thứ hai: thiếu cấu hình cho phép gọi khác nguồn. Nếu frontend và API nằm ở hai tên miền khác nhau, trình duyệt sẽ chặn theo chính sách cùng nguồn. Backend phải trả về đúng các tiêu đề cho phép, và phải xử lý cả yêu cầu kiểm tra trước mà trình duyệt gửi tự động. Một chi tiết rất hay bị bỏ sót: khi dùng chứng thực bằng cookie hoặc tiêu đề xác thực, bạn phải cho phép gửi thông tin xác thực — và khi đó, không được dùng ký tự đại diện cho danh sách nguồn được phép. Trình duyệt yêu cầu một danh sách cụ thể.
Nguyên nhân thứ ba: chứng chỉ HTTPS không hợp lệ. Nếu chứng chỉ tự ký hoặc hết hạn, trình duyệt chặn yêu cầu trước cả khi nó tới backend. Bạn nhìn thấy lỗi mạng ở phía giao diện, còn log backend hoàn toàn trống — dấu hiệu rất rõ để phân biệt với hai nguyên nhân trên.
Cách kiểm tra nhanh để phân biệt ba nguyên nhân: gọi endpoint bằng công cụ dòng lệnh từ chính máy đang mở trình duyệt. Nếu dòng lệnh gọi được mà trình duyệt không, vấn đề nằm ở chính sách cùng nguồn hoặc chứng chỉ, không nằm ở mạng hay backend.
13. Nhật ký và giám sát: hai thứ phải có trước khi có khách
Hai việc cuối cùng trước khi mở hệ thống cho người dùng thật, và cả hai đều rẻ hơn nhiều so với việc phải làm lại sau một sự cố.
Ghi log ra file, có cấu trúc, có ngày. Log ghi ra console sẽ biến mất cùng cửa sổ console. Log ghi ra file nhưng xoay vòng không đúng cách sẽ ăn hết ổ đĩa trong vài tuần. Hãy chốt một quy ước: mỗi ngày một file, giữ ba mươi ngày, mỗi dòng log có thời gian, mức độ, mã lỗi nếu có và định danh yêu cầu để bạn ghép được các dòng thuộc cùng một lượt gọi.
Giám sát chủ động, không phải tự mình đi kiểm tra. Một endpoint kiểm tra sức khoẻ trả về trạng thái ứng dụng và trạng thái kết nối cơ sở dữ liệu là đủ. Điều quan trọng hơn là có thứ gì đó gọi endpoint đó định kỳ, và gọi cho bạn khi nó không trả lời. Không có bước này, mọi thứ ở trên chỉ giúp hệ thống chạy ổn hơn — không giúp bạn biết nó đang không chạy.
Ở đây có một cái bẫy nhỏ nhưng đáng nhớ: một endpoint sức khoẻ trả về "OK" không có nghĩa là ứng dụng làm được việc. Nó chỉ trả lời rằng tiến trình còn sống và còn phản hồi. Nếu bạn muốn biết ứng dụng có làm được việc hay không, endpoint đó phải kiểm tra thêm những thứ mà ứng dụng thật sự cần — ví dụ cơ sở dữ liệu còn kết nối được, và một tác vụ nền quan trọng vừa chạy xong trong khoảng thời gian mong đợi.
Cách chia lớp giám sát — lớp canh máy, lớp canh tiến trình giao dịch — được viết kỹ hơn trong bài 2 lớp canh chừng: Agent và EA Watchdog.
14. Checklist 17 điểm trước khi mở cho khách
Hãy chấm thật, đừng chấm theo cảm giác. Mỗi mục "chưa" là một sự cố đang chờ đúng thời điểm để xảy ra.
Đóng gói và cấu hình
- Ứng dụng publish vào thư mục riêng của từng môi trường, đường dẫn nằm trong script chứ không gõ tay.
- Cấu hình riêng máy để ở file
.local.jsonvà đã loại khỏi publish. - File cấu hình riêng máy không nằm trong kho mã nguồn; có file mẫu cho người sau.
- Sau lần publish gần nhất, thời gian sửa đổi của file cấu hình không đổi.
Cổng và mạng
- Cổng của ứng dụng lấy từ biến môi trường, không ghi cứng trong code.
- Chỉ một cổng mở ra internet; mọi cổng khác chỉ nghe ở
127.0.0.1. - Cơ sở dữ liệu không nghe ra ngoài.
- Chứng chỉ HTTPS hợp lệ, còn hạn, đã cấp cho đúng tên miền đang dùng.
Tiến trình
- Ứng dụng chạy bằng Scheduled Task, không chạy bằng cửa sổ terminal.
- Thời hạn chạy của task là không giới hạn.
- Khi chạy lặp lại, task không mở ra cửa sổ nào trên màn hình.
- Đóng phiên Remote Desktop rồi mở lại, API vẫn còn sống.
Quan sát
- Có endpoint kiểm tra sức khoẻ, và nó kiểm tra cả kết nối cơ sở dữ liệu.
- Có thứ gì đó gọi endpoint đó định kỳ và báo cho bạn khi thất bại.
- Log ghi ra file, có xoay vòng, có ngày tháng trong tên file.
Đối chiếu lần cuối
- Đã khởi động lại VPS một lần và xác nhận API tự lên lại mà không cần bạn làm gì.
- Đã thử một lần "tắt API bằng tay" để xem cảnh báo có thật sự gửi tới bạn hay không.
Mục 16 và 17 là hai mục hay bị bỏ nhất, và cũng là hai mục nói thật nhất về việc hệ thống của bạn có tự chạy hay không. Nếu bạn muốn một checklist rộng hơn cho toàn bộ hệ thống giao dịch, xem thêm Checklist 25 điểm trước khi chạy bot tiền thật.
15. Câu hỏi thường gặp
Có cần cài IIS không? Không. Kestrel đã là web server đầy đủ cho hầu hết ứng dụng. IIS chỉ cần khi bạn phải tích hợp với một hệ thống đang chạy IIS, hoặc cần chạy nhiều ứng dụng trên cùng cổng theo cách đặc thù.
Có nên dùng Docker trên VPS Windows? Nếu bạn đã quen Docker thì được, nhưng "container trên Windows" có thêm một tầng phiền phức về mạng và ổ đĩa. Với một ứng dụng .NET duy nhất, chạy trực tiếp bằng Scheduled Task đơn giản hơn và dễ chẩn đoán hơn khi có sự cố.
Vì sao không dùng dịch vụ của Windows để chạy API? Chạy được, nhưng đăng ký một dịch vụ hệ thống đòi hỏi quyền quản trị và phải cài thêm công cụ bọc. Scheduled Task làm được điều tương tự mà không cần quyền cao, và bạn vẫn nhìn thấy tiến trình khi cần.
Làm sao để API tự chạy lại sau khi VPS khởi động lại? Dùng bộ kích hoạt theo thời điểm đăng nhập, hoặc đặt bộ kích hoạt lặp lại định kỳ mỗi vài phút — chính bộ kích hoạt lặp lại này vừa đóng vai trò bảo hiểm, vừa giúp API tự lên lại sau khi máy khởi động. Nhớ đặt chống chạy trùng, vì bản đang chạy sẽ giữ cổng và bản mới sẽ tự thoát.
Publish chậm và nặng, có cách nào nhanh hơn? Có. Chỉ publish lại phần thay đổi nếu bạn chắc chắn về cấu trúc thư mục, và luôn publish vào thư mục riêng rồi mới chuyển sang thư mục chạy. Cách thứ hai an toàn hơn và cho bạn một bước để quay lại nếu bản mới lỗi.
Có nên mở cổng cơ sở dữ liệu ra ngoài để dùng công cụ quản trị trên máy cá nhân? Không. Hãy dùng kênh riêng hoặc chạy công cụ quản trị ngay trên máy chủ. Cổng cơ sở dữ liệu mở ra internet là một trong những cách bị quét và tấn công phổ biến nhất.
Bao lâu nên kiểm tra hệ thống một lần? Nếu bạn có cảnh báo tự động thì việc kiểm tra định kỳ của bạn là để xem xu hướng: dung lượng ổ đĩa, độ trễ, số lần khởi động lại. Nếu chưa có cảnh báo, hãy làm cảnh báo trước rồi hãy nghĩ tới lịch kiểm tra.
16. Kết
Backend tự host trên VPS Windows không phải là bài toán khó về kỹ thuật. Nó là bài toán nhớ đủ thứ: cổng lấy từ đâu, cấu hình nằm ở đâu, ai giữ tiến trình sống, cửa sổ nào được phép hiện, và cái gì sẽ gọi cho bạn khi mọi thứ im lặng.
Năm lỗi trong bài này — cổng không đổi theo biến môi trường, tắt terminal là chết, cấu hình mất sau publish, cửa sổ nháy, và nhóm lỗi proxy với chứng chỉ — chiếm phần lớn thời gian tôi từng mất cho việc triển khai. Cả năm đều có cách chữa rẻ, nhưng chỉ rẻ khi bạn biết trước. Biết sau thì mỗi lỗi tính bằng một tối, và đôi khi bằng một khách hàng.
Nếu bạn đang làm bước chuyển này, hãy bắt đầu bằng hai việc nhỏ: đưa cổng ra biến môi trường, và tách cấu hình riêng máy ra khỏi publish. Hai việc đó mất chưa tới ba mươi phút, và chúng là nền cho mọi thứ còn lại.
Weekly Digest — Nhận Bản Tin Hàng Tuần
Nhận các bài viết phân tích kỹ thuật chuyên sâu, thuật toán giao dịch tự động (Trading Bot) và các giải pháp công nghệ mới nhất từ Hướng Nghiệp Dữ Liệu.
