---
# System prepended metadata

title: 用筆電在本地起 Ubuntu Server 並部署 .NET Core 程式(2) - 準備好專案並用 GitHub Action 實現 CD 流程
tags: [C#, 部署, Ubuntu Server, dotnet]

---

# 用筆電在本地起 Ubuntu Server 並部署 .NET Core 程式(2) - 準備好專案並用 GitHub Action 實現 CD 流程

接續上篇，現在已經在小筆電上把環境準備好，同時讓外網可以連網，接下來做的是本地部署。

有些人可能會問為何不使用 Docker，但我會說其實作法大同小異，最後 SSH 連入電腦內去使用部署腳本，也可以將腳本修改成觸發 Docker 更新 Image，這也是種做法。

此處暫時以不用 Docker 的方式來做，目的是讓這些操作的學習成本最小化，Docker 的好處是可以讓你在不同的環境中保持一致性、讓你能夠快速地部署和擴展應用程式，但如果只是部署在一台小筆電上，使用 Docker 可能會增加不必要的複雜性，即殺雞用牛刀。

請注意以下的指令請「明確知道這些在做啥」再進行操作，不要盲目地複製。

每個人的做法不同，有些指令是必須要取代成自己的專案或使用者名稱的。

如果上述都了解，那我們就可以開始下一步驟了。

## DotNet 專案 部署流程

要部署就會需要有專案，我已經預先準備一個 Worker Service 來部署我的 Discord Bot，這個專案是用來當作範例的，實際上你可以用任何的專案來替代它。

以下將說明在 Ubuntu Server 上，為每個 GitHub 專案（以 ExampleBot 為例）建立獨立的部署流程，包含資料夾規劃、git pull、部署腳本、以及 GitHub Actions 觸發 CD（持續部署）。

ExampleBot 這個專案並不存在，只是我用來指定為範例的名稱，實際上你可以用任何的專案來替代它。

## 前置作業

- 確保 Ubuntu Server 上已安裝 `git` 、 `dotnet` 、 `vim` 。

### 安裝 git

```bash
sudo apt update
sudo apt install git
```

### 安裝 dotnet

SDK 9.0 版本：

```bash
sudo apt install dotnet-sdk-9.0
```

Runtime 9.0 版本：

```bash
sudo apt install aspnetcore-runtime-9.0
```

### 安裝 vim

```bash
sudo apt install vim
```

### 安裝 systemd（如果還沒有）

```bash
sudo apt install systemd
```

## 一、 規劃資料夾結構

每個人規劃的資料夾結構不一樣，這裡提供一個範例，建議先去理解 Ubuntu 的資料夾結構再來決定要如何整理專案，此處我採用在 `/opt/github-projects/` 下建立專案資料夾的方式。

在 `/opt/github-projects/` 下，每個專案一個資料夾，部署腳本放在專案資料夾內的 `scripts` 中。

```bash
sudo mkdir -p /opt/github-projects/ExampleBot/source
sudo mkdir -p /opt/github-projects/ExampleBot/deploy
sudo mkdir -p /opt/github-projects/ExampleBot/logs
sudo mkdir -p /opt/github-projects/ExampleBot/scripts
```

- `/opt/github-projects/ExampleBot/source`：放置專案原始碼（git clone 的位置）
- `/opt/github-projects/ExampleBot/deploy`：放置專案執行檔（publish 輸出）
- `/opt/github-projects/ExampleBot/logs`：放置日誌
- `/opt/github-projects/ExampleBot/scripts`：放置專案的部署腳本

想要查看資料夾結構可以安裝 `tree` 指令：

```bash
sudo apt install tree
```

這樣就可以顯示結構了：

```bash
tree /opt/github-projects/ExampleBot
```

你的專案結構可能如下：

```text
/opt/github-projects/ExampleBot
├── deploy
├── logs
├── scripts
└── source
```

## 二、 建立部署腳本（`ExampleBot-deploy.sh`）

名稱也可以自行取名，但此處我採用 `ExampleBot-deploy.sh`。

用 vim 編輯 `ExampleBot-deploy.sh` 的步驟如下：

### 1. 開啟檔案

```bash
sudo vim /opt/github-projects/ExampleBot/scripts/ExampleBot-deploy.sh
```

（如果檔案不存在會自動建立）

### 2. 進入「插入模式」開始編輯

- 按下 `i` 進入「插入模式」（此時左下角會顯示 `-- INSERT --`）。
- 開始輸入你的 shell script 內容，例如：

```bash
#!/bin/bash

set -e  # 發生錯誤時立即終止腳本

# === 參數設定 ===
SOURCE_DIR="/opt/github-projects/ExampleBot/source"      # 原始碼目錄
DEPLOY_DIR="/opt/github-projects/ExampleBot/deploy"      # 執行檔(publish)目錄
LOG_DIR="/opt/github-projects/ExampleBot/logs"           # 日誌目錄
REPO_URL="git@github.com:GithubUser/ExampleBot.git"      # GitHub 倉庫網址
SERVICE_NAME="ExampleBot.service"                        # systemd 服務名稱

# === 日誌檔案 ===
LOG_FILE="$LOG_DIR/deploy.log"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 開始部署 ExampleBot" | tee -a "$LOG_FILE"

# === 取得/更新原始碼 ===
if [ ! -d "$SOURCE_DIR/.git" ]; then
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] Clone 專案原始碼..." | tee -a "$LOG_FILE"
    git clone "$REPO_URL" "$SOURCE_DIR" | tee -a "$LOG_FILE"
else
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] 更新專案原始碼..." | tee -a "$LOG_FILE"
    cd "$SOURCE_DIR"
    git pull | tee -a "$LOG_FILE"
fi

# === 清空 deploy 目錄 ===
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 清空 deploy 目錄..." | tee -a "$LOG_FILE"
rm -rf "$DEPLOY_DIR"/*
mkdir -p "$DEPLOY_DIR"

# === dotnet publish ===
echo "[$(date '+%Y-%m-%d %H:%M:%S')] dotnet publish..." | tee -a "$LOG_FILE"
cd "$SOURCE_DIR/ExampleBot.Service"
dotnet clean | tee -a "$LOG_FILE"
# 此處的 publish 位置是 deploy 資料夾
dotnet publish -c Release -o "$DEPLOY_DIR" | tee -a "$LOG_FILE"

# === 重啟 systemd 服務 ===
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 重啟 systemd 服務..." | tee -a "$LOG_FILE"
sudo systemctl restart "$SERVICE_NAME"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] ✓ 部署完成！" | tee -a "$LOG_FILE"
```

### 3. 儲存並離開

- 按下 `Esc` 回到「一般模式」。
- 輸入 `:wq`（write & quit），然後按 `Enter`，即可儲存並離開。

### 4. 設定執行權限

```bash
sudo chmod +x /opt/github-projects/ExampleBot/scripts/ExampleBot-deploy.sh
```

註: 如果發現 `Permission denied` 錯誤，請檢查 `/opt/github-projects/ExampleBot/scripts/ExampleBot-deploy.sh` 的權限是否正確。

```bash
ls -l /opt/github-projects/ExampleBot/scripts/ExampleBot-deploy.sh
```

### 5. 執行腳本

記得不用 root 權限跑，即不使用 sudo，因為這個腳本會在 systemd 服務中執行。

```bash
/opt/github-projects/ExampleBot/scripts/ExampleBot-deploy.sh
```

### 6. 故障排除

如果發現 `sudo systemctl restart "$SERVICE_NAME"` 這行錯誤，一般是要求輸入密碼，如下：

```text
[2025-05-02 15:33:52] 重啟 systemd 服務...
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ====
Authentication is required to restart 'Example.service'.
Authenticating as: Ubuntu的使用者 (YourUserName)
```

#### (1) 新增新的 `visudo` 設定檔

這個檔案會在 `/etc/sudoers.d/` 下，檔名可以隨意取，但建議與專案名稱有關聯，例如 `ExampleBot`。

這麼做的原因是為了讓 `sudo` 在執行腳本時不需要輸入密碼。
而且不影響 `visudo` 的安全性，因為這個檔案的權限會是 `0440`，只有 root 和 sudo 群組的使用者可以讀取，從而確保其內容不會被未授權的使用者修改或查看。

```bash
sudo visudo -f /etc/sudoers.d/ExampleBot
```

#### (2) 在檔案中加入以下內容

操作內容如果是 `vim` 編輯器，請按下 `i` 進入插入模式，然後貼上以下內容：

```text
YourUserName ALL=(ALL) NOPASSWD: /bin/systemctl restart ExampleBot.service
```

這裡的 `YourUserName` 是你登入 Ubuntu Server 的使用者名稱，`ExampleBot.service` 是你要執行的 systemd 服務名稱。

#### (3) 儲存並離開

- 按下 `Esc` 鍵離開插入模式。
- 輸入 `:wq`（即英文冒號加 wq），然後按下 `Enter` 鍵即可儲存並離開。
  - wq：write & quit，儲存並離開 vim 編輯器。

現在你就可以在腳本中使用 `sudo systemctl restart ExampleBot.service` 而不需要輸入密碼了。
這點很重要，因為使用 `appleboy/ssh-action` 時，會讀腳本，腳本中帶有 `sudo`。
如果上述沒設定，會要求輸入密碼，導致 GitHub Actions 無法正常運行。

### vim 常用指令速查

- 進入插入模式：`i`
- 回到一般模式：`Esc`
- 儲存檔案：`:w`
- 離開 vim：`:q`
- 儲存並離開：`:wq`
- 不儲存強制離開：`:q!`

## 三、 設定 SSH 金鑰讓 Ubuntu Server 可以拉取 Private Repo

1. 在 Ubuntu Server 產生 SSH 金鑰（如果還沒有）：

```bash
ssh-keygen -t ed25519 -C "yourEmail@gmail.com"
```

2. 將公鑰加到伺服器的 `authorized_keys`
   確保伺服器已經允許你的金鑰登入：

```bash
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
```

確保 `~/.ssh` 目錄和 `authorized_keys` 檔案的權限正確：

```bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
```

3. 將公鑰內容（`~/.ssh/id_ed25519.pub`）加到你的 GitHub 帳號的 SSH keys。

4. 測試連線：

```bash
ssh -T git@github.com
```

## 四、 設定 systemd 服務

### 1. 使用 vim 建立 systemd 服務檔案

在終端機輸入以下指令，開啟（或建立）服務檔案：

```bash
sudo vim /etc/systemd/system/ExampleBot.service
```

### 2. 在 vim 中貼上服務內容

進入 vim 後，請按下 `i` 進入插入模式，然後貼上以下內容：

```ini
[Unit]
Description=ExampleBot Discord Bot

[Service]
WorkingDirectory=/opt/github-projects/ExampleBot/deploy
ExecStartPre=/usr/bin/echo "準備啟動 ExampleBot"
ExecStart=/usr/bin/dotnet /opt/github-projects/ExampleBot/deploy/ExampleBot.Service.dll
ExecStartPost=/usr/bin/echo "ExampleBot 啟動完成"
Restart=always
RestartSec=10
StartLimitIntervalSec=60
StartLimitBurst=3
User=YourUserName
EnvironmentFile=/etc/ExampleBot.env

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

### 3. 儲存並離開 vim

- 按下 `Esc` 鍵離開插入模式
- 輸入 `:wq`（即英文冒號加 wq），然後按下 `Enter` 鍵即可儲存並離開

### 4. 設定環境變數

也可以直接在 `ExampleBot.service` 中設定環境變數，但建議使用獨立的環境變數檔案，這樣更方便管理和修改。

#### (1) 建立環境變數檔案

```bash
sudo touch /etc/ExampleBot.env
sudo chmod 600 /etc/ExampleBot.env
```

#### (2) 編輯環境變數檔案

```bash
sudo vim /etc/ExampleBot.env
```

在 vim 中按下 `i` 進入插入模式，然後貼上以下內容：

```bash
# ExampleBot 環境變數設定檔
# 設定環境變數，例如：
BOT_TOKEN=somefakeDiscordToken(替換成你的Token)
ASPNETCORE_ENVIRONMENT=Production
# 其他環境變數設定可以在這裡添加
```

### 5. 重新載入 systemd 設定

```bash
sudo systemctl daemon-reload
```

### 6. 啟用服務（開機自動啟動）

```bash
sudo systemctl enable ExampleBot.service
```

### 7. 啟動服務

```bash
sudo systemctl start ExampleBot.service
```

### 8. 檢查服務狀態

```bash
sudo systemctl status ExampleBot.service
```

看到 `active (running)` 代表服務已經啟動成功。

### 9. 如果修改服務，用Vim修改後儲存即可

更新服務，語法與上述一樣：

```bash
sudo systemctl daemon-reload
sudo systemctl enable ExampleBot.service
sudo systemctl start ExampleBot.service
```

## 五. 設定 GitHub Actions 觸發 CD

### 1. GitHub Actions 直接 SSH 到 Server 執行部署腳本

1. 在 GitHub 專案設定 Secrets，新增 `KEY`（內容為 Ubuntu Server 的 SSH 私鑰）。

2. 在 `.github/workflows/dotnet-cd.yml` 新增 workflow：

```yaml
name: Deploy to Server

on:
  push:
    branches:
      - master # 當 `master` 分支有新推送時觸發

jobs:
  deploy:
    name: Deploy to Ubuntu Server
    runs-on: ubuntu-latest

    steps:
      # 檢出最新版本代碼
      - name: Checkout repository
        uses: actions/checkout@v3

      # 執行 SSH 部署
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HOST }} # 伺服器主機對外 IP 位址，例如：1.2.3.4 或www.myDomainName.com.tw
          username: ${{ secrets.USERNAME }} # 登入伺服器的使用者名稱，例如： YourUserName
          key: ${{ secrets.KEY }} # 私鑰內容，來自 GitHub Secrets
          port: ${{ secrets.PORT }} # SSH 連接埠，預設為 22
          script: |
            bash /opt/github-projects/ExampleBot/scripts/ExampleBot-deploy.sh
```

於第一篇中，我們有開放 SSH port (22) 至外網，並做 Port-Forwarding，所以此處記得是外網 IP 或 DDNS DomainName。

這裡的 key 是你在 GitHub 專案設定 Secrets 時新增的 SSH 私鑰，這個私鑰是用來連接到你的 Ubuntu Server 的。
而私鑰必須要在 Ubuntu Server 上的 `~/.ssh/authorized_keys` 中有對應的公鑰，這樣才能成功連接。

### 2. 在 GitHub 專案設定 Secrets，新增 `HOST`、`USERNAME`、`PORT`。

在 GitHub 專案設定 Secrets，新增 `HOST`、`USERNAME`、`PORT`。

- `HOST`：伺服器主機對外 IP 位址，例如：`1.2.3.4` 或 `www.myDomainName.com.tw`
- `USERNAME`：登入 Ubuntu 伺服器的使用者名稱，例如：`YourUserName`
- `PORT`：SSH 連接埠，預設為 `22`
- `KEY`：內容為 Ubuntu Server 的 SSH 私鑰。
  - 這個私鑰是用來連接到你的 Ubuntu Server 的，必須要在 Ubuntu Server 上的 `~/.ssh/authorized_keys` 中有對應的公鑰，這樣才能成功連接。

記得從 Ubuntu Server 上取得 SSH 私鑰，並將其內容複製到 GitHub Secrets 中。

1. 取得私鑰指令會是：

```bash
cat ~/.ssh/id_ed25519
```

```bash
# 確保私鑰的權限正確
chmod 600 ~/.ssh/id_ed25519
```

2. 複製私鑰內容到 GitHub Secrets 中，並命名為 `KEY`。私鑰範例為：

```text
--BEGIN OPENSSH PRIVATE KEY--
b3BlbnNzaC1rZDI1NiB... (省略) ...==
--END OPENSSH PRIVATE KEY--
```

## 六. 未來新增其他專案

- 在 `/opt/github-projects/` 下新增資料夾（如 `/opt/github-projects/OtherProject`）。
- 在 `/opt/github-projects/OtherProject/` 下新增 `deploy.sh`。
- 依照上述步驟複製調整即可。

## 結語

如果你能順利地在小筆電部署成功那就太棒了，但這途中一定會遇到各種指令衝突、權限、連網問題。

至於部署之後還有些內容必須處理，例如：

- Github Action 可以在 Merge Request 觸發 CI
- 完成後觸發 CD (上述 GitHub Action 已經提及)
- 如何有一套好的 GitFlow，Unit Test
- 如何將 Unit Test 綁入 CI 中等等，以上都是必須要自行去研究的

慶幸的是這個時代我們是有 AI 可以使用的，一邊爬官方文章一邊翻譯，或直接將問題丟給 AI 後，將得到的結果拿到網路上進行驗證，確保指令是「可信的」。

AI 也可能會為了簡單了事，給你不安全的作法，所以每一個指令都必須要再三確認，特別是自己並不懂哪種實作較佳，你可以選擇在直接使用指令之前，先將指令拿去查詢文件，也同時拿去問 AI 這是否是安全的做法。


## 可以改善

從上述的部署流程來看，這個部署方式還留有些缺陷：

### 一、現有流程的問題

目前的流程是：
1. 在 Ubuntu Server 上 git clone 原始碼。
2. 在 Server 上 dotnet publish。
3. 在 Server 上執行部署腳本、重啟服務。

這種方式的**缺點**：

- **Server 上需要安裝完整 .NET SDK**（而不是只需 Runtime），這樣會佔用較多資源。
- **Server 上有完整原始碼**，有資安風險。
- **部署流程分散**：source、deploy、logs、scripts、env、service 檔案分散各處，換主機時要重新設定很多東西。
- **維護成本高**：每次部署都要在 Server 上拉原始碼、編譯、publish，容易出錯且速度慢。

## 二、最佳實踐建議

### 1. **建議的自動化部署流程**

- **CI/CD（如 GitHub Actions）負責編譯與 publish**，產生已經可以直接執行的檔案（dll、config、靜態資源等）。
- **用 scp/rsync 將 publish 輸出檔案直接傳到 Server**，Server 只需要 .NET Runtime。
- **Server 上不需要原始碼，也不需要 .NET SDK**，只要能執行 dotnet xxx.dll 即可。
- **Server 上只需要簡單的 systemd 服務與環境變數檔案**，部署腳本也可以很簡單（甚至只剩下重啟服務）。

### 2. **資料夾結構建議**

- `/opt/github-projects/ExampleBot/deploy`：只放 publish 輸出檔案。
- `/opt/github-projects/ExampleBot/logs`：放日誌。
- `/etc/ExampleBot.env`：環境變數。
- `/etc/systemd/system/ExampleBot.service`：systemd 服務檔案。

**不需要** `/source` 目錄，也不需要在 Server 上 git clone。

### 3. **GitHub Actions YAML 範例**

```yaml
name: Deploy to Server

on:
  push:
    branches:
      - master

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'

      - name: Publish
        run: dotnet publish -c Release -o ./publish

      - name: Copy files to server
        uses: appleboy/scp-action@v0.1.4
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.KEY }}
          port: ${{ secrets.PORT }}
          source: "./publish/*"
          target: "/opt/github-projects/ExampleBot/deploy/"

      - name: Restart systemd service
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.KEY }}
          port: ${{ secrets.PORT }}
          script: |
            sudo systemctl restart ExampleBot.service
```

## 三、這樣做的優點

- **Server 只需安裝 .NET Runtime**，更輕量、更安全。
- **Server 不會有原始碼**，資安風險降低。
- **部署速度快**，因為只傳遞已編譯好的檔案。
- **維護簡單**，換主機只要複製 deploy、logs、env、service 檔案即可。
- **CI/CD 流程標準化**，容易擴展到多個專案。

## 四、補充說明

- 可以把部署腳本（如重啟服務）寫在 Server 上，也可以直接在 GitHub Actions 的 SSH Action 裡執行。
- 如果有多個專案，可以用同樣的結構，每個專案一個 deploy 目錄、一個 systemd 服務、一個 env 檔案。
- 若有靜態檔案、前端專案，也可以用同樣方式處理。

### 環境變數搬移至專案資料夾

環境變數檔案（如 `ExampleBot.env`）可以放在專案資料夾，只要在 systemd 服務檔案（ `/etc/systemd/system/ExampleBot.service`）中這樣寫：

```ini
EnvironmentFile=/opt/github-projects/ExampleBot/ExampleBot.env
```
systemd 啟動服務時，就會讀取這個檔案的環境變數。

### 實務建議

- **單一主機多專案**：建議把每個專案的環境變數檔案放在專案資料夾下（如 `/opt/github-projects/ExampleBot/ExampleBot.env`），方便管理與備份。
- **權限設定**：請務必設定檔案權限，避免敏感資訊外洩，例如：
  ```bash
  sudo chmod 600 /opt/github-projects/ExampleBot/ExampleBot.env
  sudo chown youruser:youruser /opt/github-projects/ExampleBot/ExampleBot.env
  ```