Docker 教學 第 6 堂:Docker Compose 全端專題:React + Flask + MySQL

目錄

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

本堂課目標

前五堂課已經學過:

  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 三個服務完整串起來:

Browser
  |
  v
frontend container
React build + Nginx
  |
  | /api proxy
  v
backend container
Flask REST API
  |
  v
db container
MySQL + 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,例如 backenddb
  • 用 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

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

docker compose up -d --build

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

為什麼這不只是玩具?

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

層級本課專題真實專案常見對應
FrontendReact + NginxReact、Vue、Next.js 靜態輸出、Nginx
BackendFlask REST APIFlask、FastAPI、Django、Express、Spring Boot
DatabaseMySQLMySQL、PostgreSQL、MariaDB
OrchestrationDocker ComposeDocker Compose、Kubernetes、ECS、Cloud Run
CI/CDGitHub ActionsGitHub Actions、GitLab CI、Jenkins

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

三個 container 各自負責什麼?

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。也就是:

frontend container = React build + Nginx
backend container  = Flask API
db container       = MySQL

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

這堂課最重要的觀念

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

在 Compose 裡,每個 service name 都會變成 DNS 名稱:

frontend -> backend:5000
backend  -> db:3306

所以 Flask 連 MySQL 時要用:

MYSQL_HOST=db

不是:

MYSQL_HOST=localhost

第二個觀念:React 在 production 不應該靠 npm run dev

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

npm run build -> 產生靜態檔 -> Nginx serve

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

MySQL 資料要放在 volume:

volumes:
  todo-db-data:

6-2 專案骨架與 API 設計(約 25 分鐘)

專案目錄

建議建立這樣的目錄:

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:

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

macOS / Linux:

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

.gitignore

根目錄建立 .gitignore

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

API 設計

後端提供 5 個 endpoint:

MethodPath用途
GET/api/health健康檢查
GET/api/todos取得所有 Todo
POST/api/todos新增 Todo
PATCH/api/todos/<id>切換完成狀態
DELETE/api/todos/<id>刪除 Todo

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

MySQL 資料表

db/init.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
  ('完成 Docker 第六堂專題', FALSE),
  ('確認 MySQL volume 會保存資料', FALSE),
  ('讓 GitHub Actions build image', FALSE);

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

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


6-3 Backend:Flask API 連接 MySQL(約 35 分鐘)

Backend requirements

backend/requirements.txt

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

Flask app

backend/app.py

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

app = Flask(__name__)


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


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


@app.route("/api/health")
def health():
    return jsonify(status="ok")


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


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

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


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


@app.route("/api/todos/<int:todo_id>", methods=["DELETE"])
def delete_todo(todo_id):
    conn = get_connection()
    cursor = conn.cursor()
    cursor.execute("DELETE FROM todos WHERE id = %s", (todo_id,))
    conn.commit()
    cursor.close()
    conn.close()
    return jsonify(message="deleted")


def add(a, b):
    return a + b


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

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

Backend 測試

backend/test_app.py

from app import add, app


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


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

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

Backend Dockerfile

backend/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 ["python", "app.py"]

.dockerignore

__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

{
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0"
  },
  "dependencies": {
    "@vitejs/plugin-react": "latest",
    "vite": "latest",
    "react": "latest",
    "react-dom": "latest"
  },
  "devDependencies": {}
}

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

index.html

frontend/index.html

<!doctype html>
<html lang="zh-Hant">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Docker Todo App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

React entry point

frontend/src/main.jsx

import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

React App

frontend/src/App.jsx

import { useEffect, useState } from 'react'
import './App.css'

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

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

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

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

    setTitle('')
    await loadTodos()
  }

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

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

  useEffect(() => {
    loadTodos()
  }, [])

  return (
    <main className="page">
      <section className="shell">
        <p className="eyebrow">Docker Fullstack Capstone</p>
        <h1>Todo App</h1>

        <form className="todo-form" onSubmit={addTodo}>
          <input
            value={title}
            onChange={(event) => setTitle(event.target.value)}
            placeholder="新增一個任務"
          />
          <button type="submit">新增</button>
        </form>

        {loading ? (
          <p className="muted">載入中...</p>
        ) : (
          <ul className="todo-list">
            {todos.map((todo) => (
              <li key={todo.id} className={todo.completed ? 'done' : ''}>
                <button type="button" onClick={() => toggleTodo(todo.id)}>
                  {todo.completed ? '完成' : '未完成'}
                </button>
                <span>{todo.title}</span>
                <button type="button" onClick={() => deleteTodo(todo.id)}>
                  刪除
                </button>
              </li>
            ))}
          </ul>
        )}
      </section>
    </main>
  )
}

這段 React 的重點不是語法,而是 fetch('/api/todos')

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

React CSS

frontend/src/App.css

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, "Noto Sans TC", 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

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:

http://backend:5000

Frontend Dockerfile

frontend/.dockerignore

node_modules/
dist/
.env
npm-debug.log

frontend/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

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

上課時複製成 .env

copy .env.example .env

macOS / Linux 可以用:

cp .env.example .env

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

compose.yaml

compose.yaml

services:
  frontend:
    build:
      context: ./frontend
    ports:
      - "8080:80"
    depends_on:
      - backend

  backend:
    build:
      context: ./backend
    ports:
      - "5000:5000"
    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: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  todo-db-data:

啟動專案

docker compose up -d --build

查看狀態:

docker compose ps

打開瀏覽器:

http://localhost:8080

測 API:

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

為什麼 frontend 不直接連 MySQL?

前端不應該直接連資料庫,原因是:

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

正確流程是:

React -> Flask API -> MySQL

6-6 Volume、重建與除錯基本功(約 30 分鐘)

驗證資料持久化

啟動後在網頁新增幾筆 Todo,然後執行:

docker compose down
docker compose up -d

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

因為 MySQL 資料存在 named volume:

volumes:
  todo-db-data:

刪除 volume 會發生什麼?

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

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

這個練習要讓學生記住:

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

常用除錯指令

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

進 container:

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

重建 image:

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

重啟服務:

docker compose restart backend

常見錯誤 1:backend 連不到 MySQL

錯誤寫法:

MYSQL_HOST=localhost

正確寫法:

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 時執行。

如果要重新套用:

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

注意:這會刪掉資料。

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

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

docker compose build frontend
docker compose up -d frontend

或者直接:

docker compose up -d --build

6-7 接上 CI/CD 與最終交付(約 20 分鐘)

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

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

CI/CD 流程:

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-imagespush 到 main 時才登入 Docker Hub 並 push images

GitHub Secrets

需要設定:

Secret用途
DOCKERHUB_USERNAMEDocker Hub 帳號
DOCKERHUB_TOKENDocker Hub access token

完整 workflow

建立 .github/workflows/cicd.yml

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: "3.11"

      - 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 && exit 0
            echo "Waiting for fullstack app..."
            docker compose ps
            sleep 5
          done
          exit 1          

      - name: Create todo through API
        run: |
          curl --fail -X POST http://localhost:8080/api/todos \
            -H "Content-Type: application/json" \
            -d '{"title":"Created by CI"}'
          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 == 'push' && github.ref == 'refs/heads/main'

    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 "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

      - 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

# Todo Fullstack Docker Demo

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

這是一個 Docker 總整合範例:

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

## 架構

```text
Browser
  |
  v
frontend:80
React + Nginx
  |
  | /api
  v
backend:5000
Flask API
  |
  v
db:3306
MySQL + 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 分鐘)

六堂課能力回顧

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

{
  "status": "ok",
  "database": "ok"
}

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

練習 3:移除 backend 對外 port

目前 compose 把 backend 也開到 host:

ports:
  - "5000:5000"

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

思考:

  • 為什麼 frontend 還是能連 backend?
  • 為什麼 host 不能再直接打 localhost:5000

練習 4:讓 CI 抓到 Nginx proxy 錯誤

故意把 frontend/nginx.conf 改錯:

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 就是你的最終作品說明書。

0%