把 Node + PostgreSQL 的 backend 裝進 Docker:Dockerfile、.dockerignore 與 compose
從「本機裝了就跑」到打包成容器的通用做法——Dockerfile 每一行的理由與 layer cache 為什麼決定了指令順序、.dockerignore 漏掉 node_modules 對原生模組是災難級的問題、容器之間為什麼不能用 localhost 互相找到對方,以及怎麼驗證資料真的寫進了 volume。
W
從「本機裝了就跑」到「打包成一個貨櫃、到哪都能開」——這份筆記整理把一個 Node.js(Express + pg,含 bcrypt 原生模組)+ PostgreSQL 的 backend 裝進 Docker 的通用做法:三個檔案分別在做什麼、每一行的理由、常見的兩個雷,以及最後怎麼驗證資料沒有因為重啟而憑空消失。同樣的邏輯套用到其他 Express/Node backend 專案也通用。
三個檔案,各管一段
| 檔案 | 角色 |
|---|---|
Dockerfile | 怎麼把程式碼蓋成一個能跑的環境(image 的食譜) |
.dockerignore | 蓋的時候,哪些東西不該被抄進去 |
docker-compose.yml | image 蓋好之後,怎麼跟資料庫一起啟動、怎麼連線 |
Step 1:Dockerfile
Dockerfile 是「食譜」,Docker 照順序一行行執行,每一行都會蓋成一層。層與層之間可以被快取——這也是為什麼順序不是隨便排的。
01 選底圖
FROM node:20-slim
不從零開始,直接用官方裝好 Node.js 的環境當地基。
這裡用 20 是照專案既有的 Node 版本;新專案直接挑當時的現行 LTS 就好,沒有理由跟著別人的筆記走。
為什麼不用 alpine 如果專案用到
bcrypt(原生模組,C++ 編譯,不是純 JS 的bcryptjs),alpine 用 musl libc,常抓不到 prebuilt binary、容易編譯失敗。slim(Debian glibc)相容性好很多,是最少踩雷的選擇。沒有原生模組依賴的話,alpine 體積更小也是合理選項。
02 設定工作目錄
WORKDIR /app
之後所有指令都在容器裡的 /app 資料夾底下做,不存在會自動建立——相當於容器版的 cd /app,但對後面所有指令持續生效。
03 先裝套件
COPY package*.json ./
RUN npm ci --omit=dev
先只複製 package.json 與 package-lock.json,還沒動到程式碼,就先把套件裝好。--omit=dev 跳過 devDependencies(像 nodemon),正式容器用不到。
用 npm ci 而不是 npm install:它嚴格照著 lock file 裝,版本完全可重現,而且不會回頭去改 lock file。沒有 lock file 時它會直接失敗——這在 CI 與容器裡是我們要的行為,因為「lock file 忘了 commit」應該當場被抓到,而不是安靜地裝到別的版本。
為什麼要拆開寫 Docker 每行都是一層快取。如果先複製全部程式碼再裝套件,之後只改一行程式碼,重新 build 時「複製程式碼」那層變了,後面所有層(含安裝套件)都要重跑——套件明明沒變還是要重新下載。拆開寫、先裝套件,只要
package.json沒變,這層就能直接吃快取。
04 複製其餘程式碼
COPY . .
這時候才把剩下的所有原始碼複製進容器——搭配 .dockerignore(Step 2)跳過 node_modules 跟 .env,不然會把剛裝好的東西蓋掉、也會把密鑰打包進 image。
05 宣告對外 port
EXPOSE 8080
純文件性質的宣告:「這個容器會在這個 port 服務」。實際要不要對外開放,是 docker-compose.yml 的 ports 才真正生效。
06 不要用 root 跑
USER node
官方的 node image 裡已經內建一個叫 node 的非特權使用者。不加這行的話容器裡的程式是以 root 身分執行,一旦被打進來,攻擊者在容器裡就有完整權限。
放在 CMD 之前、所有 COPY 與 RUN 之後——前面那些步驟需要寫入權限。
07 啟動指令
CMD ["npm", "start"]
容器啟動時真正執行的指令,等同 npm start(通常對應 node ./server.js 之類的正式啟動腳本)。不是 npm run dev——nodemon 是本機開發用的自動重啟,正式容器不需要,也沒被裝進來(Step 3 的 --omit=dev)。
需要編譯步驟(TypeScript 轉譯、前端打包)的專案值得再進一階,改成多階段建置:第一階段裝完整依賴並編譯,第二階段只把產出物複製過來。最終 image 裡不會有原始碼與建置工具,體積小很多。單純的 JavaScript 專案不必為此增加複雜度。
Step 2:.dockerignore
node_modules
.env
跟 .gitignore 的邏輯很像,但管的是「COPY . . 複製時要跳過誰」:
| 項目 | 為什麼要排除 |
|---|---|
node_modules | 本機(你的作業系統)裝的版本會蓋掉容器裡剛裝好、相容容器系統的版本——對 bcrypt 這種原生模組是直接爆炸等級的問題 |
.env | 裡面有真正的密鑰(如 JWT_SECRET、資料庫密碼),不該打包進 image(image 之後可能被分享、上傳,密鑰就外洩)。容器的環境變數改由 docker-compose.yml 注入 |
Step 3:docker-compose.yml 的 backend 服務
Image 蓋好只是半成品,這段負責「怎麼跟資料庫一起啟動、互相找到對方」。以服務名稱 backend 連 postgres 為例。
A build
build:
context: ./backend
dockerfile: Dockerfile
context 是 build 時的根目錄——這樣 Dockerfile 裡的 COPY package*.json ./ 才找得到 backend/package.json,不是專案根目錄那個。如果你的 Dockerfile 就放在專案根目錄,context 也要跟著改成 .。
B ports
ports:
- "8080:8080"
格式是 "外部:內部"。左邊(外部)是宿主機打進來的 port,通常照你的專案規格固定;右邊(內部)是容器裡 server 實際監聽的 port,來自下面 PORT 這個環境變數。兩邊數字不一定要一樣,只是設一樣比較不容易搞混。
C environment
environment:
- PORT=8080
- DB_HOST=postgres
- DB_PORT=5432
- DB_USERNAME=<照抄 postgres 服務的 POSTGRES_USER>
- DB_PASSWORD=<照抄 postgres 服務的 POSTGRES_PASSWORD>
- DB_DATABASE=<照抄 postgres 服務的 POSTGRES_DB>
- JWT_SECRET=<自己的亂數字串,不要用 .env.example 的預設值>
相當於容器裡有一份 .env,但值寫在這裡而不是檔案裡。資料庫相關變數要跟 docker-compose.yml 裡 postgres 服務本身設定的值對得起來,才連得上。
D depends_on 與 healthcheck
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL",
"node -e \"fetch('http://localhost:8080/healthcheck')
.then(r => process.exit(r.ok ? 0 : 1))
.catch(() => process.exit(1))\""]
interval: 10s
timeout: 3s
retries: 5
service_healthy 不只是等 postgres「開機」,而是等它自己的 healthcheck(例如 pg_isready)真的通過,backend 才開始啟動——不然應用程式一連線就失敗、直接 crash。backend 自己的 healthcheck 打的是你專案裡真正存在的健康檢查路由(範例用 GET /healthcheck)。
最核心的觀念
DB_HOST從本機開發的localhost改成容器化後的服務名稱(例如postgres)——這一行最容易漏改,往下看圖解釋為什麼。
圖解:localhost 與服務名稱
本機開發
┌───────────────┐ DB_HOST=localhost:5432 ┌──────────────────┐
│ backend │ ───(port mapping 到宿主機)──▶ │ postgres 容器 │
│ npm run dev │ │ 5432 對外開放 │
└───────────────┘ └──────────────────┘
容器化後
┌───────────┐ localhost:8080 ┌ ─ ─ ─ ─ ─ docker compose 內部網路 ─ ─ ─ ─ ─ ┐
│ 你的電腦 │ ─────────────────▶│ ┌───────────────┐ DB_HOST=postgres ┌──────────────────┐│
└───────────┘ │ │ backend 容器 │ ──(service 名稱解析)─▶│ postgres 容器 ││
│ │ PORT=8080 │ │ service: postgres││
│ └───────────────┘ └──────────────────┘│
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
本機開發時 backend 跟 postgres 都在同一台電腦,透過 port mapping 到 localhost 互通;容器化後 backend 自己也變成容器,容器之間不能用 localhost 找到彼此,要改用 compose 內部網路裡的「服務名稱」——這正是 DB_HOST 要改的原因,跟 postgres 服務在 docker-compose.yml 裡叫什麼名字,DB_HOST 就填什麼。
換成別的服務也是同一套規則:MongoDB 的連線字串要從 mongodb://localhost:27017 改成 mongodb://mongo:27017,Redis 則是 redis://redis:6379。主機名稱一律換成服務名稱。
常見雷點
Dockerfile:冒號後面多了一格
- FROM node: 20-slim
+ FROM node:20-slim
Docker 把它解析成 image 名稱是 node:,後面 20-slim 變成不合法的東西,抓不到對應的 image——編輯器會直接標紅警告。
docker-compose.yml:少了一個斜線
- context: .backend
+ context: ./backend
. 代表「目前檔案所在的資料夾」,./backend 才是「該資料夾底下的 backend 子資料夾」;少了斜線會被當成一個叫 .backend 的資料夾去找,這個資料夾不存在,build 會直接失敗。
驗證流程
-
docker compose up -d --build <backend 服務名> <db 服務名>backend 容器狀態變成healthy,log 裡看到「連線成功/伺服器啟動」之類的訊息。 - 打一次 healthcheck 或任一支簡單的 API,確認容器裡的服務真的活著。
- 先寫入一筆測試資料(例如註冊一個帳號)→
docker compose restart <backend 服務名>→ 再查一次同一筆資料。 重啟後資料依然查得到——證明資料是真的寫進資料庫的 volume,不是存在記憶體變數裡,重啟就消失。 - 跑一次完整測試(合約測試或整合測試),確認容器化後行為跟本機開發時一致。
速查表
| 檔案/欄位 | 值 |
|---|---|
| base image | node:<LTS>-slim(有原生模組如 bcrypt 時避開 alpine) |
| 安裝套件 | 先 COPY package*.json 再 npm ci,善用 layer cache |
| 執行身分 | USER node,不要用 root |
| CMD | npm start(不是 npm run dev,容器不需要 nodemon) |
.dockerignore | node_modules、.env |
ports | "外部:內部",外部通常固定成專案規定的 port |
DB_HOST | 資料庫服務在 docker-compose.yml 裡的服務名稱(容器間用服務名稱,不是 localhost) |
depends_on | <db 服務名>: condition: service_healthy |
healthcheck | 打 backend 自己的健康檢查路由 |