Docker 教學 第 6 堂:Docker Compose 全端專題:React + Flask + MySQL
本系列為 18 小時 Docker 基礎教學講義,適合初學者,使用 Windows 電腦。 本篇為第 6 堂課,約 3 小時。
本堂課目標
前五堂課已經學過:
- Docker 的基本概念與容器操作
- Volume、port mapping、container lifecycle
- Docker Compose 管理多個服務
- Dockerfile 建立自己的 image
- 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 把
/apiproxy 到 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
畫面可以很簡單,功能也可以很小,但架構要完整。整套系統的啟動方式只有一個主指令:
docker compose up -d --build這行指令會同時 build frontend/backend images,啟動 MySQL,建立 network,掛載 volume,並讓三個服務可以用 service name 溝通。
為什麼這不只是玩具?
很多正式系統都是類似這種架構:
| 層級 | 本課專題 | 真實專案常見對應 |
|---|---|---|
| Frontend | React + 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 各自負責什麼?
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 後的靜態檔
- 把
/apiproxy 到 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\workflowsmacOS / Linux:
mkdir -p backend frontend/src db .github/workflows.gitignore
根目錄建立 .gitignore:
.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/<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.3Flask 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 在這堂課只負責:
- 呼叫
/api/todos - 顯示 Todo list
- 新增 Todo
- 切換完成狀態
- 刪除 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:5000Frontend Dockerfile
frontend/.dockerignore:
node_modules/
dist/
.env
npm-debug.logfrontend/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 .envmacOS / 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 -> MySQL6-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 失敗
檢查順序:
docker compose ps看三個服務有沒有 runningcurl http://localhost:8080/api/healthdocker compose logs frontend看 Nginx proxy 錯誤docker compose logs backend看 Flask 錯誤- 確認
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 --build6-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-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:
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

這是一個 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 分鐘)
六堂課能力回顧
| 堂次 | 主題 | 最後應該具備的能力 |
|---|---|---|
| 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 + 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 如何把
/apiproxy 到 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 就是你的最終作品說明書。