jvinhit//lab

Search posts

Type to search across journal entries.

↑ ↓ navigate ↵ open esc close

AWS cho Frontend Developer · Phần 12 — Biến môi trường: build-time, runtime và secret

VITE_*, NEXT_PUBLIC_*, REACT_APP_* không phải bí mật vì nằm nguyên văn trong bundle. So build-time với runtime, mẫu config.json để một bản build chạy mọi môi trường, và chỗ đặt secret thật: SSM, Secrets Manager, proxy ở backend.

Câu hỏi “làm sao đổi API URL giữa dev, staging và prod” nghe đơn giản: đặt VITE_API_URL khác nhau cho mỗi môi trường là xong. Nhưng phía sau nó có một bẫy lớn mà rất nhiều frontend mắc phải, và hậu quả không phải là bug mà là khoá API bị lộ công khai. Bạn đã thấy một góc của nó ở Phần 10: truyền NEXT_PUBLIC_* vào container lúc runtime là vô nghĩa.

Bài này trả lời ba câu hỏi theo thứ tự: biến môi trường ở frontend thực chất là gì sau khi build; khi nào nên nhúng lúc build, khi nào nên tải lúc chạy (kèm mẫu config.json để một bản build đi qua cả ba môi trường); và secret thật sự thì để ở đâu để frontend vẫn dùng được dịch vụ có khoá bí mật mà không bao giờ cầm khoá.


Sự thật quan trọng nhất về biến môi trường ở frontend

Nguy hiểm: biến VITE_*, NEXT_PUBLIC_*, REACT_APP_* KHÔNG PHẢI BÍ MẬT. Chúng được thay thế bằng giá trị thật ngay lúc build và nằm nguyên văn trong file JS mà ai cũng tải về được. Mở DevTools → Sources → tìm chuỗi là thấy. Không bao giờ đặt vào đó: khoá API riêng tư, AWS access key, chuỗi kết nối database, secret của OAuth. Nếu bạn đã lỡ làm, khoá đó coi như đã lộ — phải thu hồi và tạo mới, không chỉ xoá khỏi code.

Cơ chế thật sự đơn giản đến mức gây hiểu nhầm. Ở local, process.env hay import.meta.env trông như một object đọc từ hệ điều hành lúc chạy. Nhưng trình duyệt không có hệ điều hành nào để hỏi, nên build tool làm việc thay thế tĩnh trước khi đóng gói:

Lúc build (npm run build)                 Trong file dist/assets/main-a3f9.js
─────────────────────────────             ─────────────────────────────────────
VITE_API_URL=https://api.ex.com   ───►    ...fetch("https://api.ex.com/orders")...
                                                       ▲
                                          nằm nguyên văn ở đây, ai cũng đọc được

Sau bước này không còn “biến” nào nữa, chỉ còn chuỗi ký tự. Đó là lý do:

  • Đổi biến trong task definition ECS hoặc trong Amplify sau khi build không có tác dụng: bundle đã chứa giá trị cũ.
  • Mỗi môi trường cần một lần build riêng nếu bạn dùng cách này.
  • Bất kỳ ai curl file JS về đều đọc được mọi giá trị bạn đã đặt.

Tự kiểm tra bundle của mình mất đúng một lệnh. Chạy ngay sau npm run build:

# Tìm mọi URL và chuỗi trông giống khoá trong bundle
grep -ohE "https?://[a-zA-Z0-9./_-]+" dist/assets/*.js | sort -u
grep -oiE "(sk_live|AKIA|secret|password)[A-Za-z0-9_=-]{0,40}" dist/assets/*.js

Lệnh đầu cho bạn danh sách endpoint app đang gọi — hữu ích cả khi viết CSP ở Phần 15. Lệnh sau phải không in ra gì. Thêm nó vào pipeline như một bước chặn deploy.

Từng framework lộ cái gì

FrameworkTiền tố được nhúng vào bundleCách đọc trong codeGhi chú
ViteVITE_import.meta.env.XBiến không có tiền tố bị bỏ qua hoàn toàn, không lọt vào bundle. Đọc từ .env, .env.production, .env.[mode]
Next.jsNEXT_PUBLIC_process.env.XBiến không có tiền tố chỉ tồn tại ở server (route handler, server component) và đọc được lúc runtime — xem mục bên dưới
CRAREACT_APP_process.env.XCùng cơ chế với Vite, tool cũ

Cơ chế tiền tố là hàng rào an toàn: một biến DATABASE_URL trong .env không bao giờ lọt vào bundle Vite vì thiếu VITE_. Hàng rào này bị phá khi ai đó “cho tiện” đổi tên nó thành VITE_DATABASE_URL để đọc được trong code — và đó là chính xác cách các vụ lộ khoá xảy ra. Chi tiết về mode, thứ tự ưu tiên file .env và import.meta.env ở Vite phần 8.


Build-time và runtime — chọn cái nào

Tiêu chíBuild-time (nhúng vào bundle)Runtime (tải lúc chạy)
Cách làmVITE_API_URL trong lúc npm run buildTải /config.json trước khi khởi động app
Đổi giá trịPhải build lạiChỉ cần sửa file, invalidate cache
Nhiều môi trườngMỗi môi trường một bản build riêngMột bản build dùng chung cho mọi môi trường
Tốc độ tải trangNhanh hơn (không thêm request)Chậm hơn chút (thêm một request nhỏ, có thể chạy song song)
Phù hợpGiá trị không đổi: tên app, feature flag cố định, versionAPI URL, ID phân tích, feature flag thay đổi

Cột phải thắng trong hầu hết dự án có nhiều hơn một môi trường, vì một lý do quan trọng hơn cả sự tiện lợi: cái bạn test ở staging đúng y hệt cái lên prod. Với build-time, bản staging và bản prod là hai lần build khác nhau, từ cùng commit nhưng với biến khác nhau — và “khác nhau” đủ để một lỗi chỉ xuất hiện ở prod. Với runtime, artifact là một, chỉ file config đổi.

Mẫu runtime config — một build chạy mọi môi trường

Ba file và một lệnh deploy. File config nằm ngoài bundle, upload riêng, không cache:

// public/config.json — KHÔNG nằm trong bundle, upload riêng, không cache
{
  "apiUrl": "https://api.example.com",
  "environment": "production",
  "sentryDsn": "https://xxx@sentry.io/123",
  "features": { "newCheckout": true, "betaSearch": false }
}
// src/config.ts
export interface AppConfig {
  apiUrl: string;
  environment: 'dev' | 'staging' | 'production';
  sentryDsn?: string;
  features: Record<string, boolean>;
}

let config: AppConfig | undefined;

export async function loadConfig(): Promise<AppConfig> {
  // Thêm timestamp để chắc chắn không dính cache cũ, kể cả khi ai đó quên no-store
  const res = await fetch(`/config.json?t=${Date.now()}`, {
    cache: 'no-store',
  });
  if (!res.ok) throw new Error('Không tải được cấu hình ứng dụng');
  config = (await res.json()) as AppConfig;
  return config;
}

export function getConfig(): AppConfig {
  if (!config) throw new Error('loadConfig() phải được gọi trước');
  return config;
}
// src/main.tsx — chặn render cho tới khi có config
import ReactDOM from 'react-dom/client';
import App from './App';
import { loadConfig } from './config';

loadConfig()
  .then(() => {
    ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
  })
  .catch((err) => {
    document.getElementById('root')!.innerHTML =
      '<p>Không thể khởi động ứng dụng. Vui lòng tải lại trang.</p>';
    console.error(err);
  });
# Khi deploy: config.json phải KHÔNG được cache
aws s3 cp config.prod.json s3://bucket/config.json \
  --cache-control "no-store" \
  --content-type "application/json"

Vài quyết định thiết kế trong mẫu này đáng giải thích:

  • Chặn render cho tới khi có config. Nếu để app render trước rồi mới tải config, mọi component đều phải xử lý trạng thái “chưa có config”, và bạn sẽ gặp một fetch(undefined + '/orders') ở đâu đó. Một request nhỏ trước khi mount rẻ hơn nhiều so với hàng chục if.
  • getConfig() ném lỗi thay vì trả undefined. Gọi sai thứ tự phải nổ ngay lúc dev, không im lặng tới prod.
  • no-store chứ không phải no-cache. no-cache vẫn lưu và hỏi lại server (304) — ổn với index.html (Phần 7). Nhưng config là file bạn muốn đổi được ngay, và nó chỉ vài trăm byte, nên cấm lưu hẳn cho đơn giản. Query ?t= là dây an toàn thứ hai cho trường hợp một proxy ở giữa không tôn trọng header.
  • Validate config. Thêm một lớp kiểm tra (Zod hoặc vài if thủ công) trong loadConfig: apiUrl phải bắt đầu bằng https://, environment phải thuộc ba giá trị. Một file config sai cú pháp ở staging phải làm app báo lỗi rõ ràng, không phải trắng trang.
  • Pipeline loại config.json khỏi bước sync asset (--exclude "config.json") và upload nó ở bước riêng từ config/<env>.json trong repo, rồi invalidate /config.json. Workflow đầy đủ ở Phần 13.

Với React, gói getConfig() vào một hook useConfig() hoặc context để component không import trực tiếp module config, giúp test dễ mock hơn.

Biến thể khi có server đứng trước: bơm vào HTML

Nếu SPA của bạn chạy trong container sau Nginx (Kubernetes, ECS) thay vì S3, có một cách khác: entrypoint của container đọc biến môi trường và ghi vào một thẻ script trong index.html dạng window.__CONFIG__ = {...} bằng envsubst trước khi Nginx phục vụ. Không thêm request nào, đổi biến là restart container. Cách này được mổ xẻ ở K8s phần 10; trên S3 không có tiến trình nào để chạy envsubst, nên config.json là lựa chọn.

Next.js: nửa build-time, nửa runtime

Next.js có SSR nên là trường hợp đặc biệt. Biến không có tiền tố (API_INTERNAL_URL, DATABASE_URL) chỉ tồn tại ở server và được đọc lúc runtime từ process.env — nghĩa là task definition ECS hoặc Lambda environment có tác dụng với chúng. Biến NEXT_PUBLIC_* thì vẫn bị nhúng lúc next build như Vite.

Mẫu hay dùng để tránh phải build lại mỗi môi trường: server component đọc process.env.API_URL lúc runtime và truyền xuống client component qua props, hoặc client gọi /api/config do route handler trả về. Đừng đụng vào publicRuntimeConfig cũ — nó không hoạt động với App Router.


Secret thật sự thì để đâu

Dịch vụDùng choGhi chú
SSM Parameter StoreCấu hình thường, secret đơn giảnMiễn phí ở bậc standard. Dùng loại SecureString cho giá trị nhạy cảm (mã hoá bằng KMS)
Secrets ManagerMật khẩu DB, khoá API bên thứ baCó tự động xoay vòng (rotation). Tính phí theo secret/tháng
Biến môi trường của ECS task / LambdaCách backend nhậnKhai báo tham chiếu tới Parameter Store hoặc Secrets Manager, AWS tự tiêm giá trị vào lúc chạy. Không có secret nào trong image hay repo

Ghi chú: secret chỉ tồn tại ở backend. Nếu frontend cần dùng một dịch vụ bên thứ ba có khoá bí mật (gọi OpenAI, gửi email, thanh toán), hãy tạo một endpoint proxy ở backend. Frontend gọi backend, backend giữ khoá. Frontend không bao giờ nhìn thấy khoá, và bạn còn được thêm chỗ để rate limit và kiểm tra quyền.

Cách ECS tiêm secret, trong task definition:

{
  "containerDefinitions": [
    {
      "name": "api",
      "image": "123456789012.dkr.ecr.ap-southeast-1.amazonaws.com/api:abc1234",
      "environment": [{ "name": "NODE_ENV", "value": "production" }],
      "secrets": [
        {
          "name": "DATABASE_URL",
          "valueFrom": "arn:aws:ssm:ap-southeast-1:123456789012:parameter/prod/api/DATABASE_URL"
        },
        {
          "name": "OPENAI_API_KEY",
          "valueFrom": "arn:aws:secretsmanager:ap-southeast-1:123456789012:secret:prod/openai-AbC123"
        }
      ]
    }
  ]
}

Khối environment là giá trị thường, hiện rõ trong console. Khối secrets là tham chiếu: ECS đọc giá trị từ SSM/Secrets Manager lúc khởi động task và đặt vào process.env của container. Task execution role cần quyền ssm:GetParameters / secretsmanager:GetSecretValue đúng ARN đó — thiếu quyền thì task fail ngay khi khởi động với thông báo ResourceInitializationError, không phải lỗi code. Với Lambda, biến môi trường được mã hoá at rest sẵn; secret lớn hơn hoặc cần xoay vòng thì đọc từ SSM lúc cold start và cache trong biến module để không gọi lại mỗi request.

Endpoint proxy tối giản, để thấy nó ngắn thế nào:

// backend — frontend gọi /api/ai/summarize, chưa bao giờ thấy OPENAI_API_KEY
app.post(
  '/api/ai/summarize',
  authRequired,
  rateLimit({ max: 20, windowMs: 60_000 }),
  async (req, res) => {
    const { text } = req.body;
    if (typeof text !== 'string' || text.length > 5000)
      return res.status(400).json({ error: 'bad input' });

    const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // từ ECS secrets
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'gpt-4o-mini',
        messages: [{ role: 'user', content: text }],
      }),
    });
    res.status(upstream.status).json(await upstream.json());
  }
);

Ba dòng đầu (auth, rate limit, validate) là thứ bạn không thể có nếu gọi thẳng từ trình duyệt bằng khoá nhúng trong bundle: ai lấy được khoá là dùng thoải mái trên hoá đơn của bạn.

Khi đã lỡ commit secret

Xoá dòng đó và commit lại không đủ: giá trị vẫn nằm trong lịch sử git, và bot quét GitHub tìm được trong vài phút. Thứ tự đúng:

  1. Thu hồi khoá ngay ở dịch vụ cấp nó (AWS IAM, OpenAI, Stripe…) và tạo khoá mới. Đây là bước duy nhất thật sự quan trọng; các bước sau chỉ là dọn dẹp.
  2. Đưa khoá mới vào SSM/Secrets Manager, không vào .env được commit.
  3. Xoá khỏi lịch sử bằng git filter-repo hoặc BFG nếu repo còn private và ít người; với repo public, coi như đã lộ vĩnh viễn.
  4. Thêm .env* vào .gitignore (giữ lại .env.example không có giá trị thật) và cài một scanner như gitleaks chạy ở pre-commit lẫn CI.

Với AWS access key nói riêng: đừng tạo nó cho CI/CD ngay từ đầu — Phần 13 dùng OIDC nên không có khoá nào để lộ.


Sai lầm hay gặp

  • Đặt VITE_STRIPE_SECRET_KEY vì “cần gọi Stripe từ frontend”. Stripe (và mọi dịch vụ tử tế) có khoá publishable riêng cho client; khoá secret chỉ dùng ở backend. Nhận biết: tên biến có chữ secret, private, service_role.
  • Truyền NEXT_PUBLIC_API_URL trong task definition rồi thắc mắc sao không đổi. Nó phải là --build-arg lúc docker build. Nhận biết: giá trị trong bundle là chuỗi rỗng hoặc giá trị của máy dev.
  • config.json bị cache một năm vì pipeline sync nó cùng asset. Nhận biết: sửa config, invalidate /index.html rồi vẫn thấy API URL cũ; curl -sI /config.json thấy max-age=31536000.
  • App render trước khi có config → lỗi Cannot read properties of undefined rải rác. Chặn render bằng loadConfig().then(...).
  • Dùng .env.production chứa giá trị thật trong repo “vì nó không phải secret”. API URL không phải secret, nhưng cách này khoá bạn vào build-time và mở đường cho ai đó nhét secret vào file đó sau này.
  • Log toàn bộ process.env để debug ở backend → secret nằm trong CloudWatch Logs, nơi nhiều người có quyền đọc hơn bạn nghĩ.

Thực hành — một build, ba môi trường (45 phút)

Nguồn tài liệu không có lab riêng cho chương này; bài tập sau tương ứng mục “triển khai mẫu runtime config” trong Tuần 6 của lộ trình.

1. Trong project Vite ở các lab trước, tạo public/config.json, src/config.ts, sửa src/main.tsx như mẫu trên. Hiển thị getConfig().environment và apiUrl ở góc màn hình.

2. Tạo config/dev.json, config/staging.json, config/production.json với environment và apiUrl khác nhau. Chạy npm run build một lần.

3. Upload dist/ lên bucket lab (hai lệnh sync ở Phần 4, nhớ --exclude "config.json" ở bước asset), rồi aws s3 cp config/staging.json s3://<bucket>/config.json --cache-control no-store --content-type application/json. Mở trang qua CloudFront: thấy staging.

4. Không build lại. Copy config/production.json đè lên config.json, invalidate /config.json, tải lại: thấy production. Kiểm tra curl -sI https://<domain>/config.json có Cache-Control: no-store.

5. Cố tình làm hỏng: upload config.json với JSON sai cú pháp. Tải lại trang — phải thấy thông báo “Không thể khởi động ứng dụng”, không phải trang trắng. Sửa lại.

6. Chạy hai lệnh grep ở đầu bài trên dist/assets/*.js. Xác nhận API URL không nằm trong bundle (vì giờ nó ở config), và không có chuỗi nào trông giống khoá.

Kết quả mong đợi: cùng một dist/ chạy được ở staging và production chỉ bằng cách đổi config.json; config hỏng cho lỗi rõ ràng; bundle không chứa URL hay khoá nào. Đây chính xác là tiêu chí “đổi được API URL mà không cần build lại” của Tuần 6.


Tóm tắt

  • VITE_*, NEXT_PUBLIC_*, REACT_APP_* được thay thế tĩnh lúc build và nằm nguyên văn trong bundle. Chúng là cách cấu hình, không phải cơ chế bảo mật. Lỡ đặt secret vào đó thì thu hồi và tạo khoá mới.
  • Build-time hợp với giá trị không đổi; runtime (config.json tải trước khi mount, no-store) cho một bản build đi qua mọi môi trường và staging giống hệt prod.
  • Chặn render tới khi có config, getConfig() ném lỗi khi gọi sai thứ tự, validate nội dung config, pipeline upload config riêng và invalidate /config.json.
  • Next.js: biến không tiền tố sống ở server và đọc được lúc runtime; NEXT_PUBLIC_* vẫn cần ARG lúc build.
  • Secret thật ở SSM Parameter Store (SecureString) hoặc Secrets Manager, tiêm vào ECS task qua khối secrets với valueFrom, hoặc vào Lambda lúc cold start.
  • Frontend cần dịch vụ có khoá bí mật → endpoint proxy ở backend, kèm auth, rate limit, validate.
  • Tự kiểm tra bundle bằng grep sau mỗi build và chặn deploy nếu thấy chuỗi giống khoá.

Phần tiếp theo — Phần 13 — CI/CD: GitHub Actions với OIDC và preview environment — push lên main là production tự cập nhật, không có access key nào nằm trong GitHub Secrets.

Đọc thêm