# Deploy ASP.NET Core lên VPS Linux

> Cách deploy .NET lên Linux: cài .NET 10 trên Ubuntu 24.04, publish, chạy Kestrel bằng systemd sau nginx với SSL, làm tay hoặc bằng ZoPanel.

Source: https://zopanel.net/vi/blog/deploy-aspnet-core-linux  
Updated: 2026-10-21

## Ý chính

- Trên Ubuntu 24.04, .NET 10 và .NET 8 có sẵn trong kho gói của Ubuntu; kho của Microsoft không còn gói .NET cho bản này.
- .NET 8 và .NET 9 đều hết hỗ trợ ngày 10/11/2026, nên dự án deploy mới hãy nhắm .NET 10 LTS.
- Kestrel nên lắng nghe trên 127.0.0.1 phía sau nginx, và bật forwarded headers để app biết request là https và IP thật của khách.
- Hãy lưu key ring của Data Protection ra ngoài thư mục release, nếu không mỗi lần khởi động lại người dùng sẽ bị đăng xuất.

Để deploy .NET lên Linux, bạn publish ứng dụng bằng `dotnet publish -c Release`, cài ASP.NET Core runtime trên server (hoặc publish dạng self-contained), chạy app bằng systemd với Kestrel chỉ lắng nghe trên `127.0.0.1`, rồi đặt nginx phía trước để lo tên miền và SSL. Hai chỗ hay làm người mới vấp: middleware forwarded headers, để app biết request gốc là HTTPS, và nơi lưu key Data Protection cố định, để khởi động lại app không đăng xuất toàn bộ người dùng. Trên Ubuntu 24.04, làm tay toàn bộ mất chưa tới một giờ.

Bài này làm từng bước trên Ubuntu 24.04, sau đó làm lại cùng việc đó bằng ZoPanel, và cuối cùng so sánh với Windows/IIS và Azure App Service.

## Tháng 10/2026 nên deploy phiên bản .NET nào?

Hãy dùng **.NET 10**. Theo [chính sách hỗ trợ .NET](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core) của Microsoft, bản LTS được hỗ trợ 3 năm, bản STS 2 năm, và mốc thời gian hiện tại là:

| Phiên bản | Loại | Phát hành | Hết hỗ trợ |
| --- | --- | --- | --- |
| .NET 10 | LTS | 11/11/2025 | 14/11/2028 |
| .NET 9 | STS | 12/11/2024 | 10/11/2026 |
| .NET 8 | LTS | 14/11/2023 | 10/11/2026 |

Tại thời điểm viết (tháng 10/2026), .NET 8 và .NET 9 chỉ còn chưa đầy một tháng nhận bản vá. Nếu app vẫn đang nhắm `net8.0` hay `net9.0`, hãy lên kế hoạch chuyển sang `net10.0` ngay; với đa số web app, việc này là sửa `<TargetFramework>` và cập nhật các gói NuGet.

## Cần chuẩn bị gì?

- **Một VPS Ubuntu 24.04** có quyền sudo. Một API ASP.NET Core nhỏ chạy được với 1 GB RAM; nếu build ngay trên server thì cần nhiều hơn.
- **Một tên miền** đã trỏ bản ghi A về IP server. Xem bài [trỏ tên miền về VPS](/vi/blog/point-domain-to-vps).
- **Phần bảo mật cơ bản** làm trước: SSH key, tường lửa, cập nhật tự động. Bài [bảo mật VPS Ubuntu](/vi/blog/secure-ubuntu-vps) có danh sách đầy đủ.

## Cách deploy .NET lên Linux thủ công

### Bước 1: Cài ASP.NET Core runtime

Theo [hướng dẫn cài .NET trên Ubuntu](https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install) của Microsoft, trên Ubuntu 24.04 thì .NET 10 và .NET 8 có trong kho gói mặc định của Ubuntu, .NET 9 nằm ở kho `ppa:dotnet/backports`, còn kho gói của Microsoft không còn chứa gói .NET cho Ubuntu. Vì vậy cài .NET 10 không cần thêm kho nào:

```bash
sudo apt-get update
sudo apt-get install -y aspnetcore-runtime-10.0
dotnet --list-runtimes
```

Gói `aspnetcore-runtime-10.0` gồm cả .NET runtime lẫn ASP.NET Core. Nếu bạn build luôn trên server thì cài `dotnet-sdk-10.0`; SDK đã kèm runtime. Đừng trộn gói từ kho Ubuntu với gói từ kho khác: Microsoft ghi rõ đây là nguyên nhân hay gặp của [lỗi lẫn gói](https://learn.microsoft.com/en-us/dotnet/core/install/linux-package-mixup).

### Bước 2: Publish ứng dụng

Chạy lệnh này trên máy bạn hoặc trong CI:

```bash
dotnet publish -c Release -o ./publish
```

Kết quả là bản build **framework-dependent**: chỉ có DLL của bạn, không kèm runtime. Cách còn lại là bản **self-contained**, mang theo runtime riêng:

```bash
dotnet publish -c Release -r linux-x64 --self-contained -o ./publish
```

| | Framework-dependent | Self-contained |
| --- | --- | --- |
| Runtime trên server | Cần (`aspnetcore-runtime-10.0`) | Không cần |
| Dung lượng | Nhỏ (chỉ code của bạn) | Lớn hơn: kèm runtime |
| Vá bảo mật runtime | `apt upgrade` rồi khởi động lại | Build lại và deploy lại app |
| Lệnh chạy | `dotnet MyApp.dll` | `./MyApp` |
| Kiến trúc | Bất kỳ (một bản build cho cả x64 và ARM64) | Mỗi RID một bản (`linux-x64`, `linux-arm64`) |

[Hướng dẫn host trên Linux với nginx](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/linux-nginx) của Microsoft dùng framework-dependent, bài này cũng vậy: bản cập nhật bảo mật của Ubuntu sẽ vá runtime cho mọi app cùng lúc. App self-contained vẫn cần các thư viện hệ thống như `libicu74` và `libssl3t64`, có liệt kê trong hướng dẫn cài đặt.

### Bước 3: Đưa lên server dưới một user riêng

Đừng chạy app bằng root. Tạo user hệ thống, thư mục releases và một thư mục dữ liệu riêng:

```bash
sudo adduser --system --group --home /srv/myapp myapp
sudo mkdir -p /srv/myapp/releases /var/lib/myapp/keys
sudo chown -R myapp:myapp /srv/myapp /var/lib/myapp
sudo chmod 700 /var/lib/myapp/keys
```

Từ máy bạn, đẩy mỗi bản build vào một thư mục riêng và trỏ symlink `current` vào đó:

```bash
REL=$(date +%Y%m%d%H%M%S)
rsync -a ./publish/ deploy@server:/tmp/myapp-$REL/
ssh deploy@server "sudo mv /tmp/myapp-$REL /srv/myapp/releases/$REL && \
  sudo chown -R myapp:myapp /srv/myapp/releases/$REL && \
  sudo ln -sfn /srv/myapp/releases/$REL /srv/myapp/current"
```

### Bước 4: Cho app biết nó đứng sau proxy và lưu key cố định

Sửa hai chỗ trong `Program.cs`. Thứ nhất, nginx giải mã SSL rồi gửi HTTP thường vào Kestrel, nên app phải đọc `X-Forwarded-Proto` và `X-Forwarded-For`. Microsoft hướng dẫn gọi `UseForwardedHeaders` trước các middleware khác; nếu không truyền tùy chọn thì không header nào được chuyển tiếp. Proxy trên loopback như `127.0.0.1` được tin cậy mặc định.

Thứ hai, key ring của [Data Protection](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/default-settings) dùng để ký cookie đăng nhập và token chống CSRF. Khi không rơi vào nơi lưu mặc định nào, key chỉ nằm trong bộ nhớ, và hướng dẫn của Microsoft cảnh báo rằng khi đó mỗi lần khởi động lại, cookie đăng nhập và token CSRF đều mất hiệu lực. Hãy lưu key ra đĩa, ngoài thư mục release:

```csharp
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo("/var/lib/myapp/keys"))
    .SetApplicationName("myapp");

var app = builder.Build();

app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});

// ... các middleware và endpoint còn lại
app.Run();
```

Microsoft lưu ý: khi bạn tự chỉ thư mục lưu key, cơ chế tự mã hóa key khi lưu sẽ bị tắt. Đó là lý do thư mục trên được `chmod 700` và thuộc về user của app.

### Bước 5: Chạy Kestrel bằng systemd

Để bí mật trong một file chỉ root đọc được. ASP.NET Core đọc biến môi trường làm cấu hình, dùng `__` thay cho `:`:

```bash
sudo tee /etc/myapp.env >/dev/null <<'EOF'
ConnectionStrings__Default=Server=127.0.0.1;Database=myapp;User=myapp;Password=change-me
EOF
sudo chmod 600 /etc/myapp.env
```

Tạo `/etc/systemd/system/myapp.service`, dựa trên file unit trong hướng dẫn của Microsoft:

```ini
[Unit]
Description=My ASP.NET Core app
After=network.target

[Service]
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/current
ExecStart=/usr/bin/dotnet /srv/myapp/current/MyApp.dll
Restart=always
RestartSec=10
KillSignal=SIGTERM
TimeoutStopSec=90
SyslogIdentifier=myapp
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000
Environment=DOTNET_NOLOGO=true
EnvironmentFile=/etc/myapp.env
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/lib/myapp

[Install]
WantedBy=multi-user.target
```

Vài dòng cần chú ý:

- **`ASPNETCORE_URLS=http://127.0.0.1:5000`** cho Kestrel chỉ nghe ở localhost. Hãy bỏ `UseUrls(...)` hoặc `Kestrel:Endpoints` trong `appsettings.json`, vì chúng sẽ ghi đè giá trị này.
- **`KillSignal=SIGTERM`** là mặc định của systemd, và hướng dẫn hiện tại của Microsoft khuyên giữ nguyên: .NET chờ `SIGTERM` để tắt êm, và Microsoft giờ khuyên không dùng `SIGINT` như các bản hướng dẫn cũ.
- **`ASPNETCORE_ENVIRONMENT=Production`** sẽ nạp `appsettings.Production.json`. Tên file trên Linux phân biệt hoa thường.

Nếu muốn, thêm gói `Microsoft.Extensions.Hosting.Systemd` và gọi `builder.Host.UseSystemd();`. Khi đó app báo cho systemd biết lúc nó thực sự đã chạy xong và đã dừng; theo [tài liệu UseSystemd](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.hosting.systemdhostbuilderextensions.usesystemd), cần đặt `Type=notify` trong unit. Chỉ thêm `Type=notify` khi có lời gọi đó, nếu không systemd sẽ chờ một thông báo không bao giờ tới.

Khởi động:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
journalctl -u myapp -f
curl -I http://127.0.0.1:5000
```

### Bước 6: Đặt nginx phía trước

```bash
sudo apt-get install -y nginx
```

Tạo `/etc/nginx/sites-available/myapp`, chỉnh từ ví dụ của Microsoft (khối `map` giúp WebSocket, SignalR và Blazor Server hoạt động):

```nginx
map $http_connection $connection_upgrade {
  "~*Upgrade" $http_connection;
  default keep-alive;
}

server {
    listen 80;
    server_name example.com www.example.com;

    location / {
        proxy_pass         http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection $connection_upgrade;
        proxy_set_header   Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }
}
```

Luôn ghi rõ `server_name`; hướng dẫn của Microsoft coi việc thiếu nó là một rủi ro bảo mật. Bật site và kiểm tra cấu hình trước khi reload:

```bash
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```

### Bước 7: SSL và tường lửa

```bash
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
```

Certbot tự sửa cấu hình nginx và đặt lịch gia hạn chứng chỉ. Cổng 5000 vẫn đóng với internet. Chi tiết hơn trong bài [SSL miễn phí với Let's Encrypt](/vi/blog/free-ssl-lets-encrypt).

## Cập nhật app thế nào?

Đẩy bản build mới vào một thư mục release mới, chuyển symlink `current` rồi khởi động lại:

```bash
sudo ln -sfn /srv/myapp/releases/20261021120000 /srv/myapp/current
sudo systemctl restart myapp
```

Rollback cũng là lệnh đó với thư mục cũ hơn. Có hai điểm cần biết: trong lúc khởi động lại sẽ có một khoảng ngắn nginx trả lỗi 502, và sau khi `apt upgrade` vá runtime, Microsoft khuyên khởi động lại các app. Muốn cập nhật không gián đoạn thì cần hai instance và health check trước khi chuyển traffic, việc mà công cụ deploy hoặc panel sẽ làm thay bạn. Bài [tự động deploy từ Git](/vi/blog/git-auto-deploy) hướng dẫn deploy mỗi lần push.

## Cách deploy .NET lên Linux bằng ZoPanel

ZoPanel build app .NET từ mã nguồn ngay trên server và chạy nó như một service của tài khoản hosting. Theo [tài liệu .NET](/vi/docs/dotnet):

1. **Cài SDK:** quản trị viên mở **Runtimes** và cài **.NET 10.0**, **9.0** hoặc **8.0** trên thẻ **.NET SDK**. Các phiên bản cài song song được. Trên Ubuntu 24.04, 8.0 và 10.0 lấy từ gói của Ubuntu, 9.0 từ kho backports.
2. **Tạo website:** **Websites → New website → Git deploy**, nhập tên miền và repository. Xem [Git deploy](/vi/docs/git-deploy).
3. **Kiểm tra nhận diện:** file `.csproj` hoặc `.sln` được nhận là **ASP.NET Core** (tìm project sâu tối đa hai cấp thư mục). Lệnh đề xuất là `dotnet restore`, `dotnet publish 'MyApp.csproj' -c Release -o out --no-restore` và `ASPNETCORE_URLS=http://127.0.0.1:$PORT dotnet out/'MyApp.dll'`. Phiên bản SDK đi theo `<TargetFramework>`, trừ khi repository có `global.json` riêng.
4. **Thêm cấu hình** trong **Environment variables**, ví dụ `ConnectionStrings__Default`. Giá trị được lưu trong file chỉ root đọc được và truyền qua systemd, không bao giờ nằm trên dòng lệnh.
5. **Deploy.** Bản mới khởi động ở slot đang rảnh và chỉ nhận traffic khi trả lời **Health check path** với mã dưới 500 trong vòng 90 giây. Trong lúc đó bản cũ vẫn phục vụ. **Rollback** giữ 5 bản release gần nhất.

Bạn vẫn cần thêm `UseForwardedHeaders` như ở bước 4, vì nginx gửi `X-Forwarded-For` và `X-Forwarded-Proto`. Mỗi release là một thư mục mới, nên hãy để key Data Protection và file upload trong một mục **Persistent paths** hoặc trong database. Đừng viết cứng cổng: `PORT` khác nhau giữa hai slot blue/green.

Cần biết trước khi chọn: build chạy trên server, trong giới hạn RAM của tài khoản, nên với gói nhỏ hãy đặt `DOTNET_gcServer=0` hoặc `DOTNET_GCHeapHardLimit` như tài liệu hướng dẫn. Trên Debian arm64 chỉ có .NET 10, vì Microsoft chỉ phát hành gói .NET 8 và 9 cho Debian trên amd64. Và ZoPanel là panel Linux: không chạy IIS hay app .NET Framework 4.x.

## Lựa chọn khác: Windows/IIS và Azure App Service

| Cách | Hợp với | Đánh đổi |
| --- | --- | --- |
| VPS Linux làm tay | Toàn quyền kiểm soát, chi phí thấp, hiểu rõ hệ thống | Bạn tự lo cập nhật, release và giám sát |
| VPS Linux với ZoPanel | Nhiều app hoặc nhiều khách hàng trên một server, Git deploy, rollback | Chỉ Linux; build dùng RAM của server |
| Windows Server với IIS | App .NET Framework, Windows Authentication, đội đã quen Windows | Bản quyền Windows Server; phải quản trị IIS |
| Azure App Service | Nền tảng được quản lý, có deployment slot và scale | Chi phí tăng theo gói; ít quyền kiểm soát máy chủ |

[Host trên IIS](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/iis/) qua ASP.NET Core Module là cách trưởng thành, được hỗ trợ đầy đủ, và là lựa chọn duy nhất ở đây nếu bạn còn code .NET Framework. [Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/) bỏ hẳn việc bảo trì server và tự chia sẻ key ring Data Protection giữa các instance.

## Lỗi thường gặp

- **502 Bad Gateway:** app không chạy hoặc nghe ở chỗ khác, thường là `localhost:5000` mặc định hoặc URL trong `appsettings.json`. Kiểm tra `journalctl -u myapp` và `ss -ltnp`.
- **Redirect vòng lặp hoặc link `http://` sau khi bật SSL:** thiếu `UseForwardedHeaders` hoặc gọi nó sau các middleware khác.
- **Người dùng bị đăng xuất sau mỗi lần deploy:** key Data Protection nằm trong bộ nhớ hoặc trong thư mục release.
- **`The application to execute does not exist`:** tên DLL trong `ExecStart` sai, thường do project đặt `<AssemblyName>`.

## Kết luận

Deploy .NET lên Linux gói gọn trong: một runtime còn hỗ trợ (.NET 10 từ kho gói của Ubuntu), bản build đã publish, systemd, nginx với forwarded headers, key ring được lưu cố định và SSL. Làm tay một lần giúp bạn hiểu từng thành phần. Khi đã có nhiều app, hoặc muốn release có health check và rollback một cú click mà không phải viết script, hãy thử [gói Free của ZoPanel](/vi/pricing): 3 tài khoản hosting, 10 website, 10 database, không giới hạn thời gian, có sẵn app .NET và Git deploy. Quy trình tương tự cho các ngôn ngữ khác có trong bài [deploy Node.js](/vi/blog/deploy-nodejs-vps) và [deploy Python](/vi/blog/deploy-python-app-vps).

## Câu hỏi thường gặp

### ASP.NET Core có chạy production trên Linux được không?

Được. ASP.NET Core là đa nền tảng, và Microsoft có tài liệu, có hỗ trợ việc host trên Linux sau nginx với systemd. Kestrel phục vụ app, còn nginx lo SSL, tên miền và giảm tải file tĩnh.

### Có cần cài SDK trên server không?

Không, trừ khi bạn build trên đó. App framework-dependent chỉ cần `aspnetcore-runtime-10.0`; app self-contained thì không cần cả runtime. Các panel build từ Git như ZoPanel thì cài SDK, vì chúng chạy `dotnet publish` ngay trên server.

### Dự án mới nên dùng .NET 8 hay .NET 10?

.NET 10. Cả .NET 8 và .NET 9 đều hết hỗ trợ ngày 10/11/2026, còn .NET 10 LTS được hỗ trợ đến 14/11/2028. Sau ngày hết hỗ trợ sẽ không còn bản vá bảo mật nào.

### Mở Kestrel thẳng ra internet có an toàn không?

Kestrel có thể phục vụ traffic trực tiếp, nhưng trên VPS thì đơn giản hơn nếu để nó ở `127.0.0.1` và cho nginx lo SSL và nhiều tên miền trên cổng 80 và 443. Khi đó việc gia hạn chứng chỉ không bao giờ đụng tới app.

### Vì sao người dùng bị đăng xuất mỗi lần khởi động lại app?

Vì key Data Protection dùng để mã hóa cookie đăng nhập không được lưu lại. Hãy lưu bằng `PersistKeysToFileSystem` vào một thư mục ngoài thư mục release, chỉ user của app được ghi, và đặt `SetApplicationName` cố định cho app.
