# Docker 教學 第 6 堂：Docker Compose 全端專題：React &#43; Flask &#43; MySQL


&gt; 本系列為 18 小時 Docker 基礎教學講義，適合初學者，使用 Windows 電腦。
&gt; 本篇為第 6 堂課，約 3 小時。

&lt;!--more--&gt;

## 本堂課目標

前五堂課已經學過：

1. Docker 的基本概念與容器操作
2. Volume、port mapping、container lifecycle
3. Docker Compose 管理多個服務
4. Dockerfile 建立自己的 image
5. GitHub Actions 自動測試、build、health check、push image

最後一堂課要把這些能力整合成一個 **Docker Compose 全端專題**。本章的重點不是用 `docker run` 一個一個手動啟動 container，而是用一份 `compose.yaml` 把 frontend、backend、db 三個服務完整串起來：

```text
Browser
  |
  v
frontend container
React build &#43; Nginx
  |
  | /api proxy
  v
backend container
Flask REST API
  |
  v
db container
MySQL &#43; Volume
```

這堂課不是 React 教學，也不是 Flask 教學。React 只做最簡單的 Todo 畫面，Flask 只做 REST API，真正重點是用 **Docker Compose** 把前端、後端、資料庫組成一套可以交付的系統。

這堂課結束後，你應該能做到：

- 看懂前端、後端、資料庫三層容器架構
- 寫 backend Dockerfile
- 寫 frontend Dockerfile，使用 Node build React，再用 Nginx serve 靜態檔
- 用 Nginx 把 `/api` proxy 到 backend
- 用 Docker Compose 串起 frontend、backend、db 三個 service
- 用 service name 做 container 之間的 DNS，例如 `backend`、`db`
- 用 volume 保存 MySQL 資料
- 用 `.env` 和 environment variables 傳設定
- 用 logs、exec、rebuild 排查問題
- 接上第五堂的 CI/CD，build 並 push frontend/backend images

---

## 6-1 Docker Compose 專題成果與架構說明（約 20 分鐘）

### 今天要完成什麼？

今天要做一個簡單的 Todo Web App：

- 瀏覽器可以看到任務清單
- 可以新增任務
- 可以切換完成 / 未完成
- 可以刪除任務
- Todo 資料存在 MySQL
- 關掉 container 再開，資料還在
- 用一個 `docker compose up -d --build` 啟動整套系統
- GitHub Actions 可以自動測試、build image、push 到 Docker Hub

畫面可以很簡單，功能也可以很小，但架構要完整。整套系統的啟動方式只有一個主指令：

```powershell
docker compose up -d --build
```

這行指令會同時 build frontend/backend images，啟動 MySQL，建立 network，掛載 volume，並讓三個服務可以用 service name 溝通。

### 為什麼這不只是玩具？

很多正式系統都是類似這種架構：

| 層級 | 本課專題 | 真實專案常見對應 |
|------|----------|------------------|
| Frontend | React &#43; Nginx | React、Vue、Next.js 靜態輸出、Nginx |
| Backend | Flask REST API | Flask、FastAPI、Django、Express、Spring Boot |
| Database | MySQL | MySQL、PostgreSQL、MariaDB |
| Orchestration | Docker Compose | Docker Compose、Kubernetes、ECS、Cloud Run |
| CI/CD | GitHub Actions | GitHub Actions、GitLab CI、Jenkins |

所以這堂課不是要做很複雜的功能，而是要練標準部署架構。Docker Compose 是本章的核心工具，React、Flask、MySQL 都只是被 Compose 管起來的服務。

### 三個 container 各自負責什麼？

```text
frontend
  - 使用 Node build React
  - 使用 Nginx 提供靜態 HTML/CSS/JS
  - 把 /api 請求 proxy 到 backend

backend
  - 提供 Flask REST API
  - 負責商業邏輯
  - 連線到 MySQL

db
  - 使用 mysql:8.0 image
  - 保存 Todo 資料
  - 資料放在 Docker volume
```

### 為什麼這裡要用 Nginx？

先釐清一件事：**Flask 後端不是一定要自己加 Nginx**。根據遠端服務的架構不同，部署形式很可能會不同，不一定每種架構都需要你自己管理 Nginx。

如果你部署到 Cloud Run、Render、Railway、Fly.io 這類平台，平台前面通常已經有 load balancer 或 proxy，很多時候你不需要自己管理 Nginx。

但如果你是在 GCP Compute Engine、AWS EC2、一般 VPS 這種「自己租一台 VM」的情境，Nginx 很常見，因為 VM 比較像自己管理一台 Linux 主機，你通常需要一個對外入口來處理：

- 對外開 `80` / `443`
- serve React build 後的靜態檔
- 把 `/api` proxy 到 Flask backend
- 隱藏 backend port，不讓使用者直接打 backend
- 之後接 domain 和 HTTPS

本章的 Nginx 放在 `frontend` container 裡，不另外開第四個 service。也就是：

```text
frontend container = React build &#43; Nginx
backend container  = Flask API
db container       = MySQL
```

這樣架構仍然是三個 service，但已經很接近自租 VM 上常見的全端部署方式。請記住：本章使用 Nginx 是為了示範一種典型的 Docker Compose 全端部署，不是規定所有遠端部署都必須長這樣。

### 這堂課最重要的觀念

第一個觀念：container 之間不要用 `localhost` 找彼此。

在 Compose 裡，每個 service name 都會變成 DNS 名稱：

```text
frontend -&gt; backend:5000
backend  -&gt; db:3306
```

所以 Flask 連 MySQL 時要用：

```text
MYSQL_HOST=db
```

不是：

```text
MYSQL_HOST=localhost
```

第二個觀念：React 在 production 不應該靠 `npm run dev`。

開發時可以用 Vite dev server，但部署時比較常見的是：

```text
npm run build -&gt; 產生靜態檔 -&gt; Nginx serve
```

第三個觀念：container 可以刪掉，但資料不能跟著消失。

MySQL 資料要放在 volume：

```yaml
volumes:
  todo-db-data:
```

---

## 6-2 專案骨架與 API 設計（約 25 分鐘）

### 專案目錄

建議建立這樣的目錄：

```text
todo-fullstack/
  ├── compose.yaml
  ├── .env.example
  ├── .gitignore
  ├── README.md
  ├── frontend/
  │   ├── .dockerignore
  │   ├── Dockerfile
  │   ├── nginx.conf
  │   ├── package.json
  │   ├── index.html
  │   └── src/
  │       ├── main.jsx
  │       ├── App.jsx
  │       └── App.css
  ├── backend/
  │   ├── .dockerignore
  │   ├── Dockerfile
  │   ├── requirements.txt
  │   ├── app.py
  │   └── test_app.py
  ├── db/
  │   └── init.sql
  └── .github/
      └── workflows/
          └── cicd.yml
```

課堂中可以提供 React 和 Flask 程式碼，不要求學生從零寫前後端。學生真正要完成的是 Dockerfile、Nginx proxy、`compose.yaml`、Volume、環境變數與除錯。

### 建立資料夾

PowerShell：

```powershell
mkdir backend
mkdir frontend
mkdir frontend\src
mkdir db
mkdir .github
mkdir .github\workflows
```

macOS / Linux：

```bash
mkdir -p backend frontend/src db .github/workflows
```

### `.gitignore`

根目錄建立 `.gitignore`：

```text
.env
node_modules/
dist/
__pycache__/
*.pyc
.pytest_cache/
```

### API 設計

後端提供 5 個 endpoint：

| Method | Path | 用途 |
|--------|------|------|
| `GET` | `/api/health` | 健康檢查 |
| `GET` | `/api/todos` | 取得所有 Todo |
| `POST` | `/api/todos` | 新增 Todo |
| `PATCH` | `/api/todos/&lt;id&gt;` | 切換完成狀態 |
| `DELETE` | `/api/todos/&lt;id&gt;` | 刪除 Todo |

React 只需要會呼叫這幾個 API，不需要 router、不需要登入、不需要複雜狀態管理。

### MySQL 資料表

`db/init.sql`：

```sql
SET NAMES utf8mb4;

ALTER DATABASE todo_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS todos (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  completed BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

INSERT INTO todos (title, completed) VALUES
  (&#39;完成 Docker 第六堂專題&#39;, FALSE),
  (&#39;確認 MySQL volume 會保存資料&#39;, FALSE),
  (&#39;讓 GitHub Actions build image&#39;, FALSE);
```

MySQL 官方 image 會在第一次初始化資料庫時，自動執行 `/docker-entrypoint-initdb.d/` 裡的 `.sql` 檔案。

注意：只有第一次建立資料庫 volume 時會執行。如果 volume 已經存在，修改 `init.sql` 不會重新套用。

---

## 6-3 Backend：Flask API 連接 MySQL（約 35 分鐘）

### Backend requirements

`backend/requirements.txt`：

```text
flask==3.0.3
mysql-connector-python==9.0.0
pytest==8.3.3
```

### Flask app

`backend/app.py`：

```python
import os
import mysql.connector
from flask import Flask, jsonify, request

app = Flask(__name__)


def get_connection():
    return mysql.connector.connect(
        host=os.getenv(&#34;MYSQL_HOST&#34;, &#34;db&#34;),
        port=int(os.getenv(&#34;MYSQL_PORT&#34;, &#34;3306&#34;)),
        user=os.getenv(&#34;MYSQL_USER&#34;, &#34;todo_user&#34;),
        password=os.getenv(&#34;MYSQL_PASSWORD&#34;, &#34;todo_password&#34;),
        database=os.getenv(&#34;MYSQL_DATABASE&#34;, &#34;todo_app&#34;),
    )


def fetch_todos():
    conn = get_connection()
    cursor = conn.cursor(dictionary=True)
    cursor.execute(
        &#34;&#34;&#34;
        SELECT id, title, completed, created_at
        FROM todos
        ORDER BY id DESC
        &#34;&#34;&#34;
    )
    rows = cursor.fetchall()
    cursor.close()
    conn.close()
    return rows


@app.route(&#34;/api/health&#34;)
def health():
    return jsonify(status=&#34;ok&#34;)


@app.route(&#34;/api/todos&#34;, methods=[&#34;GET&#34;])
def list_todos():
    return jsonify(fetch_todos())


@app.route(&#34;/api/todos&#34;, methods=[&#34;POST&#34;])
def create_todo():
    data = request.get_json(silent=True) or {}
    title = str(data.get(&#34;title&#34;, &#34;&#34;)).strip()
    if not title:
        return jsonify(error=&#34;title is required&#34;), 400

    conn = get_connection()
    cursor = conn.cursor()
    cursor.execute(&#34;INSERT INTO todos (title) VALUES (%s)&#34;, (title,))
    conn.commit()
    cursor.close()
    conn.close()
    return jsonify(message=&#34;created&#34;), 201


@app.route(&#34;/api/todos/&lt;int:todo_id&gt;&#34;, methods=[&#34;PATCH&#34;])
def toggle_todo(todo_id):
    conn = get_connection()
    cursor = conn.cursor()
    cursor.execute(
        &#34;&#34;&#34;
        UPDATE todos
        SET completed = NOT completed
        WHERE id = %s
        &#34;&#34;&#34;,
        (todo_id,),
    )
    conn.commit()
    cursor.close()
    conn.close()
    return jsonify(message=&#34;updated&#34;)


@app.route(&#34;/api/todos/&lt;int:todo_id&gt;&#34;, methods=[&#34;DELETE&#34;])
def delete_todo(todo_id):
    conn = get_connection()
    cursor = conn.cursor()
    cursor.execute(&#34;DELETE FROM todos WHERE id = %s&#34;, (todo_id,))
    conn.commit()
    cursor.close()
    conn.close()
    return jsonify(message=&#34;deleted&#34;)


def add(a, b):
    return a &#43; b


if __name__ == &#34;__main__&#34;:
    app.run(host=&#34;0.0.0.0&#34;, port=5000)
```

這份程式刻意保持簡單。正式專案通常會再拆成 model、service、repository，但本課重點是 Docker，不是 Flask 架構設計。

### Backend 測試

`backend/test_app.py`：

```python
from app import add, app


def test_add():
    assert add(2, 3) == 5


def test_health():
    app.config[&#34;TESTING&#34;] = True
    with app.test_client() as client:
        response = client.get(&#34;/api/health&#34;)
        assert response.status_code == 200
        assert response.get_json()[&#34;status&#34;] == &#34;ok&#34;
```

這裡只測不用連 MySQL 的部分。完整的資料庫整合測試會放到 Compose 啟動後，用 `curl` 測整套系統。

### Backend Dockerfile

`backend/Dockerfile`：

```dockerfile
FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD [&#34;python&#34;, &#34;app.py&#34;]
```

`.dockerignore`：

```text
__pycache__/
*.pyc
.pytest_cache/
venv/
.venv/
.env
```

教學重點：

- 先 copy `requirements.txt`，讓 pip install 可以吃到 Docker build cache
- `PYTHONUNBUFFERED=1` 讓 log 比較即時出現在 `docker compose logs`
- Flask 必須 listen `0.0.0.0`，container 外面才連得到

---

## 6-4 Frontend：React build 與 Nginx Proxy（約 35 分鐘）

### React 只做最小功能

React 在這堂課只負責：

1. 呼叫 `/api/todos`
2. 顯示 Todo list
3. 新增 Todo
4. 切換完成狀態
5. 刪除 Todo

不用 router，不用 Redux，不用 TypeScript，不用登入。

### package.json

`frontend/package.json`：

```json
{
  &#34;scripts&#34;: {
    &#34;dev&#34;: &#34;vite --host 0.0.0.0&#34;,
    &#34;build&#34;: &#34;vite build&#34;,
    &#34;preview&#34;: &#34;vite preview --host 0.0.0.0&#34;
  },
  &#34;dependencies&#34;: {
    &#34;@vitejs/plugin-react&#34;: &#34;latest&#34;,
    &#34;vite&#34;: &#34;latest&#34;,
    &#34;react&#34;: &#34;latest&#34;,
    &#34;react-dom&#34;: &#34;latest&#34;
  },
  &#34;devDependencies&#34;: {}
}
```

`package-lock.json` 不需要手打。執行 `npm install` 或 Docker build 時會產生。

### index.html

`frontend/index.html`：

```html
&lt;!doctype html&gt;
&lt;html lang=&#34;zh-Hant&#34;&gt;
  &lt;head&gt;
    &lt;meta charset=&#34;UTF-8&#34; /&gt;
    &lt;meta name=&#34;viewport&#34; content=&#34;width=device-width, initial-scale=1.0&#34; /&gt;
    &lt;title&gt;Docker Todo App&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id=&#34;root&#34;&gt;&lt;/div&gt;
    &lt;script type=&#34;module&#34; src=&#34;/src/main.jsx&#34;&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
```

### React entry point

`frontend/src/main.jsx`：

```jsx
import React from &#39;react&#39;
import { createRoot } from &#39;react-dom/client&#39;
import App from &#39;./App.jsx&#39;

createRoot(document.getElementById(&#39;root&#39;)).render(
  &lt;React.StrictMode&gt;
    &lt;App /&gt;
  &lt;/React.StrictMode&gt;,
)
```

### React App

`frontend/src/App.jsx`：

```jsx
import { useEffect, useState } from &#39;react&#39;
import &#39;./App.css&#39;

export default function App() {
  const [todos, setTodos] = useState([])
  const [title, setTitle] = useState(&#39;&#39;)
  const [loading, setLoading] = useState(true)

  async function loadTodos() {
    const response = await fetch(&#39;/api/todos&#39;)
    const data = await response.json()
    setTodos(data)
    setLoading(false)
  }

  async function addTodo(event) {
    event.preventDefault()
    const trimmed = title.trim()
    if (!trimmed) return

    await fetch(&#39;/api/todos&#39;, {
      method: &#39;POST&#39;,
      headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
      body: JSON.stringify({ title: trimmed }),
    })

    setTitle(&#39;&#39;)
    await loadTodos()
  }

  async function toggleTodo(id) {
    await fetch(`/api/todos/${id}`, { method: &#39;PATCH&#39; })
    await loadTodos()
  }

  async function deleteTodo(id) {
    await fetch(`/api/todos/${id}`, { method: &#39;DELETE&#39; })
    await loadTodos()
  }

  useEffect(() =&gt; {
    loadTodos()
  }, [])

  return (
    &lt;main className=&#34;page&#34;&gt;
      &lt;section className=&#34;shell&#34;&gt;
        &lt;p className=&#34;eyebrow&#34;&gt;Docker Fullstack Capstone&lt;/p&gt;
        &lt;h1&gt;Todo App&lt;/h1&gt;

        &lt;form className=&#34;todo-form&#34; onSubmit={addTodo}&gt;
          &lt;input
            value={title}
            onChange={(event) =&gt; setTitle(event.target.value)}
            placeholder=&#34;新增一個任務&#34;
          /&gt;
          &lt;button type=&#34;submit&#34;&gt;新增&lt;/button&gt;
        &lt;/form&gt;

        {loading ? (
          &lt;p className=&#34;muted&#34;&gt;載入中...&lt;/p&gt;
        ) : (
          &lt;ul className=&#34;todo-list&#34;&gt;
            {todos.map((todo) =&gt; (
              &lt;li key={todo.id} className={todo.completed ? &#39;done&#39; : &#39;&#39;}&gt;
                &lt;button type=&#34;button&#34; onClick={() =&gt; toggleTodo(todo.id)}&gt;
                  {todo.completed ? &#39;完成&#39; : &#39;未完成&#39;}
                &lt;/button&gt;
                &lt;span&gt;{todo.title}&lt;/span&gt;
                &lt;button type=&#34;button&#34; onClick={() =&gt; deleteTodo(todo.id)}&gt;
                  刪除
                &lt;/button&gt;
              &lt;/li&gt;
            ))}
          &lt;/ul&gt;
        )}
      &lt;/section&gt;
    &lt;/main&gt;
  )
}
```

這段 React 的重點不是語法，而是 `fetch(&#39;/api/todos&#39;)`。

前端不直接知道 backend container 的位置。瀏覽器只打到 frontend container，Nginx 會把 `/api` 轉給 backend。

### React CSS

`frontend/src/App.css`：

```css
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &#34;Noto Sans TC&#34;, sans-serif;
  color: #172033;
  background: #f4f6fb;
}

button,
input {
  font: inherit;
}

.page {
  min-height: 100vh;
  padding: 40px 16px;
}

.shell {
  width: min(760px, 100%);
  margin: 0 auto;
  padding: 32px;
  background: white;
  border: 1px solid #d9deea;
  border-radius: 12px;
  box-shadow: 0 16px 40px rgba(20, 32, 56, 0.08);
}

.eyebrow {
  margin: 0 0 8px;
  color: #4764f0;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

h1 {
  margin: 0 0 24px;
  font-size: 36px;
}

.todo-form {
  display: flex;
  gap: 12px;
  margin-bottom: 24px;
}

.todo-form input {
  flex: 1;
  min-width: 0;
  padding: 12px 14px;
  border: 1px solid #c8cfdd;
  border-radius: 8px;
}

button {
  padding: 10px 14px;
  border: 0;
  border-radius: 8px;
  color: white;
  background: #4764f0;
  cursor: pointer;
}

button:hover {
  background: #344ec7;
}

.muted {
  color: #6b7280;
}

.todo-list {
  display: grid;
  gap: 10px;
  padding: 0;
  margin: 0;
  list-style: none;
}

.todo-list li {
  display: grid;
  grid-template-columns: 86px 1fr 68px;
  gap: 10px;
  align-items: center;
  padding: 12px;
  border: 1px solid #e0e5ef;
  border-radius: 8px;
  background: #fbfcff;
}

.todo-list li.done span {
  color: #7a8395;
  text-decoration: line-through;
}

.todo-list li button:last-child {
  background: #d94a4a;
}

.todo-list li button:last-child:hover {
  background: #b83030;
}

@media (max-width: 560px) {
  .shell {
    padding: 20px;
  }

  .todo-form,
  .todo-list li {
    grid-template-columns: 1fr;
  }

  .todo-form {
    display: grid;
  }
}
```

### Nginx reverse proxy

`frontend/nginx.conf`：

```nginx
server {
  listen 80;
  server_name _;

  root /usr/share/nginx/html;
  index index.html;

  location / {
    try_files $uri /index.html;
  }

  location /api/ {
    proxy_pass http://backend:5000/api/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }
}
```

這裡的 `backend` 不是網域名稱，而是 Docker Compose service name。

在同一個 Compose project 裡，Docker 會自動提供 DNS：

```text
http://backend:5000
```

### Frontend Dockerfile

`frontend/.dockerignore`：

```text
node_modules/
dist/
.env
npm-debug.log
```

`frontend/Dockerfile`：

```dockerfile
FROM node:20-alpine AS build

WORKDIR /app

COPY package.json .
RUN npm install

COPY . .
RUN npm run build

FROM nginx:1.27-alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80
```

這是一個 multi-stage build：

| Stage | 用途 |
|-------|------|
| `node:20-alpine` | 安裝套件、執行 `npm run build` |
| `nginx:1.27-alpine` | 提供 build 後的靜態檔 |

最後 image 裡不需要 Node.js，也不需要原始碼的開發伺服器，只需要 Nginx 和靜態檔。

---

## 6-5 本章核心：Docker Compose 串起三個服務（約 40 分鐘）

### .env.example

`.env.example`：

```text
MYSQL_ROOT_PASSWORD=rootpassword
MYSQL_DATABASE=todo_app
MYSQL_USER=todo_user
MYSQL_PASSWORD=todo_password
```

上課時複製成 `.env`：

```powershell
copy .env.example .env
```

macOS / Linux 可以用：

```bash
cp .env.example .env
```

`.env` 不應該 commit 到 Git。範例密碼可以很簡單，正式環境不能用這種密碼。

### compose.yaml

`compose.yaml`：

```yaml
services:
  frontend:
    build:
      context: ./frontend
    ports:
      - &#34;8080:80&#34;
    depends_on:
      - backend

  backend:
    build:
      context: ./backend
    ports:
      - &#34;5000:5000&#34;
    environment:
      MYSQL_HOST: db
      MYSQL_PORT: 3306
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    volumes:
      - todo-db-data:/var/lib/mysql
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: [&#34;CMD&#34;, &#34;mysqladmin&#34;, &#34;ping&#34;, &#34;-h&#34;, &#34;localhost&#34;]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  todo-db-data:
```

### 啟動專案

```powershell
docker compose up -d --build
```

查看狀態：

```powershell
docker compose ps
```

打開瀏覽器：

```text
http://localhost:8080
```

測 API：

```powershell
curl http://localhost:8080/api/health
curl http://localhost:8080/api/todos
```

### 為什麼 frontend 不直接連 MySQL？

前端不應該直接連資料庫，原因是：

- MySQL 密碼不能放在瀏覽器
- 資料庫不應該直接暴露給使用者
- 權限、驗證、資料格式應該由 backend 控制

正確流程是：

```text
React -&gt; Flask API -&gt; MySQL
```

---

## 6-6 Volume、重建與除錯基本功（約 30 分鐘）

### 驗證資料持久化

啟動後在網頁新增幾筆 Todo，然後執行：

```powershell
docker compose down
docker compose up -d
```

再次打開 `http://localhost:8080`，資料應該還在。

因為 MySQL 資料存在 named volume：

```yaml
volumes:
  todo-db-data:
```

### 刪除 volume 會發生什麼？

```powershell
docker compose down -v
docker compose up -d --build
```

這次資料會回到 `init.sql` 的初始資料。

這個練習要讓學生記住：

```text
container 可以重建
image 可以重 build
volume 才是資料保存的位置
```

### 常用除錯指令

```powershell
docker compose ps
docker compose logs frontend
docker compose logs backend
docker compose logs db
docker compose logs -f backend
```

進 container：

```powershell
docker compose exec backend sh
docker compose exec db mysql -u todo_user -p todo_app
```

重建 image：

```powershell
docker compose build backend
docker compose build frontend
docker compose build --no-cache
```

重啟服務：

```powershell
docker compose restart backend
```

### 常見錯誤 1：backend 連不到 MySQL

錯誤寫法：

```text
MYSQL_HOST=localhost
```

正確寫法：

```text
MYSQL_HOST=db
```

在 backend container 裡，`localhost` 指的是 backend container 自己，不是 MySQL container。

### 常見錯誤 2：React 打 API 失敗

檢查順序：

1. `docker compose ps` 看三個服務有沒有 running
2. `curl http://localhost:8080/api/health`
3. `docker compose logs frontend` 看 Nginx proxy 錯誤
4. `docker compose logs backend` 看 Flask 錯誤
5. 確認 `nginx.conf` 裡是 `proxy_pass http://backend:5000/api/;`

### 常見錯誤 3：修改 `init.sql` 沒有效果

MySQL init script 只會在第一次初始化 volume 時執行。

如果要重新套用：

```powershell
docker compose down -v
docker compose up -d --build
```

注意：這會刪掉資料。

### 常見錯誤 4：改了 React 但畫面沒變

production image 裡的 React 是 build 後的靜態檔，所以改完前端程式要重 build：

```powershell
docker compose build frontend
docker compose up -d frontend
```

或者直接：

```powershell
docker compose up -d --build
```

---

## 6-7 接上 CI/CD 與最終交付（約 20 分鐘）

### CI/CD 在這個專題要做什麼？

第五堂已經教過 GitHub Actions，所以這裡只做整合。

CI/CD 流程：

```text
push
  |
  v
backend pytest
  |
  v
docker compose build frontend/backend
  |
  v
docker compose up
  |
  v
curl http://localhost:8080/api/health
  |
  v
push frontend/backend images to Docker Hub
```

### 建議 workflow 分成三個階段

| Job | 目的 |
|-----|------|
| `test-backend` | 安裝 Python 依賴並跑 pytest |
| `compose-check` | 用 Docker Compose build、啟動、測 `/api/health` |
| `push-images` | push 到 main 時才登入 Docker Hub 並 push images |

### GitHub Secrets

需要設定：

| Secret | 用途 |
|--------|------|
| `DOCKERHUB_USERNAME` | Docker Hub 帳號 |
| `DOCKERHUB_TOKEN` | Docker Hub access token |

### 完整 workflow

建立 `.github/workflows/cicd.yml`：

```yaml
name: Fullstack Todo CI/CD

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

permissions:
  contents: read

env:
  BACKEND_IMAGE: todo-backend
  FRONTEND_IMAGE: todo-frontend

jobs:
  test-backend:
    runs-on: ubuntu-latest

    defaults:
      run:
        working-directory: backend

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: &#34;3.11&#34;

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest test_app.py -v

  compose-check:
    runs-on: ubuntu-latest
    needs: test-backend

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Prepare env file
        run: cp .env.example .env

      - name: Build and start services
        run: docker compose up -d --build

      - name: Check API through frontend proxy
        run: |
          for i in 1 2 3 4 5 6 7 8 9 10; do
            curl --fail http://localhost:8080/api/health &amp;&amp; exit 0
            echo &#34;Waiting for fullstack app...&#34;
            docker compose ps
            sleep 5
          done
          exit 1

      - name: Create todo through API
        run: |
          curl --fail -X POST http://localhost:8080/api/todos \
            -H &#34;Content-Type: application/json&#34; \
            -d &#39;{&#34;title&#34;:&#34;Created by CI&#34;}&#39;
          curl --fail http://localhost:8080/api/todos

      - name: Show logs on failure
        if: failure()
        run: docker compose logs

      - name: Stop services
        if: always()
        run: docker compose down -v

  push-images:
    runs-on: ubuntu-latest
    needs: compose-check
    if: github.event_name == &#39;push&#39; &amp;&amp; github.ref == &#39;refs/heads/main&#39;

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Create image tags
        id: meta
        run: echo &#34;sha_short=$(git rev-parse --short HEAD)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;

      - name: Build and push backend
        uses: docker/build-push-action@v5
        with:
          context: backend
          push: true
          tags: |
            ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.BACKEND_IMAGE }}:latest
            ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.BACKEND_IMAGE }}:${{ steps.meta.outputs.sha_short }}

      - name: Build and push frontend
        uses: docker/build-push-action@v5
        with:
          context: frontend
          push: true
          tags: |
            ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.FRONTEND_IMAGE }}:latest
            ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.FRONTEND_IMAGE }}:${{ steps.meta.outputs.sha_short }}
```

### README 要寫什麼？

一個可以交付的專案，README 至少要包含：

- 專案架構圖
- 使用技術
- 本機啟動方式
- 預設網址
- 常用指令
- CI badge
- 環境變數說明

`README.md`：

````markdown
# Todo Fullstack Docker Demo

![CI](https://github.com/your-name/todo-fullstack/actions/workflows/cicd.yml/badge.svg)

這是一個 Docker 總整合範例：

- React frontend
- Nginx static server &#43; `/api` reverse proxy
- Flask REST API backend
- MySQL database
- Docker volume 保存資料
- Docker Compose 一鍵啟動
- GitHub Actions CI/CD

## 架構

```text
Browser
  |
  v
frontend:80
React &#43; Nginx
  |
  | /api
  v
backend:5000
Flask API
  |
  v
db:3306
MySQL &#43; todo-db-data volume
```

## 啟動

```powershell
copy .env.example .env
docker compose up -d --build
```

打開：

```text
http://localhost:8080
```

## 測試 API

```powershell
curl http://localhost:8080/api/health
curl http://localhost:8080/api/todos
```

## 停止

```powershell
docker compose down
```

刪除資料庫 volume：

```powershell
docker compose down -v
```

## 除錯

```powershell
docker compose ps
docker compose logs frontend
docker compose logs backend
docker compose logs db
docker compose exec db mysql -u todo_user -p todo_app
```

## 重要觀念

Backend 連 MySQL 時使用：

```text
MYSQL_HOST=db
```

不要使用：

```text
MYSQL_HOST=localhost
```

在 backend container 裡，`localhost` 指的是 backend container 自己，不是 db container。
````

---

## 6-8 課程總結與最終檢查（約 10 分鐘）

### 六堂課能力回顧

| 堂次 | 主題 | 最後應該具備的能力 |
|------|------|--------------------|
| 1 | Docker 基礎 | 知道 container/image 是什麼，能跑第一個 container |
| 2 | Flask、Volume、生命週期 | 知道 container 可刪可重建，資料要放 volume |
| 3 | MySQL、Compose | 能用 Compose 管多個服務 |
| 4 | Dockerfile | 能把自己的 app 打包成 image |
| 5 | CI/CD | 能用 GitHub Actions 自動測試、build、push image |
| 6 | 全端專題 | 能容器化 React &#43; Flask &#43; MySQL 三層系統 |

### 最終成果檢查

下課前每個人至少要完成：

- `docker compose up -d --build` 可以啟動三個服務
- `http://localhost:8080` 看得到 React Todo 網頁
- 可以新增、完成、刪除 Todo
- Todo 資料存在 MySQL
- `docker compose down` 後再啟動，資料還在
- `docker compose logs backend` 看得懂錯誤訊息
- 知道 `MYSQL_HOST=db` 為什麼不能寫成 `localhost`
- 知道 Nginx 如何把 `/api` proxy 到 backend
- GitHub Actions 有綠色 workflow
- Docker Hub 上有 frontend 和 backend images
- README 寫好啟動方式

### 到這裡，代表你具備什麼 Docker 實力？

如果你能獨立完成這個專題，代表你已經具備 Docker 基礎實力：

- 能理解服務拆分
- 能寫 Dockerfile
- 能寫 Compose
- 能處理資料持久化
- 能排查 container 連線問題
- 能看 logs 找錯
- 能把前後端與資料庫組成可啟動的系統
- 能用 CI/CD 自動 build image

這還不是 Kubernetes 或大型 production 架構，但已經不是只會跑 `hello-world` 的程度。這是進入真實專案前很重要的一步。

### 接下來可以學什麼？

| 方向 | 建議學習內容 |
|------|--------------|
| 後端部署 | Gunicorn、uWSGI、production Flask 設定 |
| 資料庫 | migration、backup、restore |
| 前端部署 | Nginx cache、gzip、HTTPS |
| 安全 | secrets 管理、image scan、least privilege |
| 雲端 | Cloud Run、ECS、Azure Container Apps |
| 進階編排 | Kubernetes、Helm |
| 監控 | Prometheus、Grafana、centralized logs |

基礎班到這裡就夠了。下一步不是背更多指令，而是拿不同專案練習同一套能力：拆服務、寫 Dockerfile、寫 Compose、保留資料、看 logs、接 CI/CD。

---

---

## 課堂練習

### 練習 1：新增一個 Todo 欄位

在 `todos` table 加上 `priority` 欄位，讓 Todo 可以有：

- low
- normal
- high

思考：

- `init.sql` 要怎麼改？
- 已經存在的 volume 會不會自動更新？
- 如果只是練習，可以怎麼重建資料庫？

### 練習 2：讓 backend health check 檢查 DB

目前 `/api/health` 只回傳 `ok`。請改成真的連一次 MySQL：

```json
{
  &#34;status&#34;: &#34;ok&#34;,
  &#34;database&#34;: &#34;ok&#34;
}
```

如果資料庫連不上，要回傳 500。

### 練習 3：移除 backend 對外 port

目前 compose 把 backend 也開到 host：

```yaml
ports:
  - &#34;5000:5000&#34;
```

請移除它，確認瀏覽器仍然可以透過 frontend 的 `/api` 使用系統。

思考：

- 為什麼 frontend 還是能連 backend？
- 為什麼 host 不能再直接打 `localhost:5000`？

### 練習 4：讓 CI 抓到 Nginx proxy 錯誤

故意把 `frontend/nginx.conf` 改錯：

```nginx
proxy_pass http://wrong-backend:5000/api/;
```

Push 後觀察：

- Docker build 會不會成功？
- Compose 會不會啟動？
- `curl http://localhost:8080/api/health` 會不會失敗？
- `docker compose logs frontend` 會出現什麼？

### 練習 5：完成 README

README 至少要包含：

- 架構圖
- 啟動方式
- 停止方式
- 清除 volume 的方式
- 常用除錯指令
- CI badge
- Docker Hub image 名稱

這份 README 就是你的最終作品說明書。


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/docker/docker-session6-fullstack-todo/  

