Command Palette

Search for a command to run...

Ship Next.js bằng Docker mà không lộ secret

Setup Docker thiên production cho Next.js standalone — build arg vs env runtime, .dockerignore, và vì sao API key không bao giờ được bake vào image.

Nguyễn Đình Giang

· · 4 phút đọc

Lập trình viên frontend ở Hà Nội. Mình viết về Next.js, WordPress và hiệu năng của những thứ mình thực sự ship.

Docker giúp deploy lặp lại được. Nó cũng khiến việc bake API key vào image rồi push lên registry trở nên dễ đến mức nguy hiểm.

Mình đã siết phần này trên portfolio: Next.js standalone, Dockerfile multi-stage, Compose cho config runtime, và một quy tắc cứng — secret không bao giờ vào giai build.

Hình dạng image

  1. Builder — cài deps, chạy next build với output: "standalone"
  2. Runner — copy standalone output, static asset, chạy bằng user non-root

Chỉ config không phải secret mới thuộc ARG / ENV lúc build. Với mình đó là thứ như APP_URL cho link tuyệt đối và metadata — không phải key Gmail, Resend hay OpenAI.

# Build stage — chỉ config public
ARG APP_URL
ENV APP_URL=${APP_URL}
 
# Runner — inject secret lúc start container, không bake
# Compose: env_file: .env

Vì sao secret lúc build là cái bẫy

Nếu truyền RESEND_API_KEY qua Docker ARG, nó có thể nằm trong image history dù bạn “không dùng” ở stage cuối. Ai pull được image là inspect được layer.

Cách sửa nhàm chán nhưng đúng:

  • Cho .env vào .dockerignore
  • Chỉ truyền secret lúc runtime (env_file / secret của orchestrator)
  • Không gắn prefix NEXT_PUBLIC_* cho key server để chúng không bao giờ ra browser

Việc của Compose

services:
  next-app:
    build:
      context: .
      args:
        APP_URL: ${APP_URL}
    env_file:
      - .env

Build nhận URL public. Container đang chạy nhận key email và AI. Image publish được tái dùng giữa các môi trường.

Checklist trước docker compose up --build

  • .env đã gitignore và dockerignore
  • Dockerfile không còn secret ARG/ENV
  • API route đọc process.env lúc request
  • Tag registry cũ được rebuild sau khi gỡ secret đã bake
  • Rotate key nếu image cũ từng public

Dùng BuildKit secret mount

env_file lúc chạy là mặc định đúng cho portfolio này: server Next.js đọc process.env khi request vào API route. Nó không che lỗ hổng kia — secret mà chính bước build cần, như token npm private.

Đưa secret đó vào ARG vẫn ghi vào history của image. Secret mount của BuildKit giữ giá trị trên tmpfs cho một RUN và không commit thành layer.

# syntax=docker/dockerfile:1
 
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN="$(cat /run/secrets/npm_token)" \
    corepack enable && pnpm install --frozen-lockfile

Build với secret nằm ngoài image:

docker build \
  --secret id=npm_token,env=NPM_TOKEN \
  --build-arg APP_URL=https://rivernguyen.id.vn \
  -t river-portfolio .

APP_URL vẫn là build arg vì nó public và Next.js inline vào metadata. NPM_TOKEN không phải ARG. Key email hoặc model nằm trong env_file của Compose và chỉ được đọc lúc request.

docker history phải hiện gì

Sau một bản build hỏng, history là bằng chứng, không phải Dockerfile bạn ước mình đã viết. Key lộ trông như thế này (mẫu, không phải token thật):

docker history river-portfolio:leaked
IMAGE  CREATED BY
...    ENV RESEND_API_KEY=re_sample_not_a_real_key
...    ARG RESEND_API_KEY

Image sạch không có dòng đó. Trước khi push tag, mình kiểm tra:

docker history --no-trunc river-portfolio:latest | grep -Ei 'key|token|secret|password' || echo "no secret strings in history"
docker image inspect river-portfolio:latest --format '{{json .Config.Env}}'

grep chỉ là smoke test. .dockerignore vẫn phải loại .env, .env.local và *.pem để COPY . . không kéo chúng vào context.

Runbook rotate key

Rebuild app mà không rotate key thì image cũ vẫn dùng được. Thứ tự mình dùng:

  1. Thu hồi key ở nhà cung cấp trước khi rebuild, để image cũ bị pull cũng đã chết.
  2. Cấp key mới chỉ vào secret runtime — .env trên host hoặc secret của orchestrator. Không phải build arg.
  3. Rebuild từ Dockerfile không còn secret ARG/ENV, --no-cache ở stage từng copy chỗ lộ.
  4. Xóa digest hỏng trên registry. Xóa tag không đủ nếu digest vẫn pull được.
  5. Xác nhận docker history trên tag mới, rồi deploy. Route server phải fail nếu thiếu env mới, không fallback về giá trị đã bake.

Mình coi image là thứ có thể push nhầm registry. Cùng quy tắc đó cho Dockerfile khách và bản standalone của portfolio này. Trước khi tag, mình còn đối chiếu .dockerignore với .gitignore: file nào không được commit thì cũng không được vào build context. Một file credentials.json bị gitignore nhưng quên dockerignore vẫn thành layer. Mình đọc lại history của image vừa build, không phải history của commit, vì layer không nằm trong git. Nếu registry không cho xóa digest, mình rotate key rồi coi digest cũ là public vĩnh viễn — không có bước “để đó, ít người pull”. Standalone output của Next.js cũng không ngoại lệ: server.js và các chunk trong .next/standalone vẫn là layer. Secret không được nằm trong những file đó chỉ vì chúng “không phải source”. Mình grep bản standalone trước khi tag, cùng cách grep docker history.

Các bản booking ngồi cạnh setup này được viết thành case study, bắt đầu từ nền tảng tour đa ngôn ngữ. Cú pháp mount: Build secrets. Output standalone: output: standalone.

Kết

Docker không chỉ là đóng gói — nó là ranh giới bảo mật. Coi image như thứ bạn có thể vô tình publish. Nếu một key khiến bạn xấu hổ khi bị dump từ registry, nó không thuộc về bước build.

Cần tính năng kiểu này cho site của bạn? Xem trang thuê →

Nhận bài mới qua email

Mỗi khi có bài mới, mình gửi một email. Không spam, huỷ lúc nào cũng được.

Đọc tiếp