NguyenHN
← Về trang chủ
DevOps

Triển khai NestJS + Next.js + React Admin on AWS ECS Fargate

·15 phút đọc
Chia sẻ:

Đây là ghi chú đầy đủ từ một dự án demo cá nhân: 3 service tách biệt hoàn toàn — NestJS (API, có database), Next.js (landing page, server-side render) và React Admin (SPA quản trị) — nhưng dùng chung 1 hạ tầng AWS thay vì mỗi cái một cụm riêng. Các ID tài khoản/resource thật trong bài đã được thay bằng placeholder.

  • NestJS 10 + TypeScript + TypeORM + PostgreSQL
  • Next.js 15 (App Router, Server Components) — SSR
  • React 18 + Vite + Material UI + Redux Toolkit — SPA
  • ECS Fargate (serverless container) + CodeDeploy Blue/Green
  • CloudFront + S3 cho phần frontend tĩnh
  • GitHub Actions cho CI/CD, mỗi service 1 repo riêng

Tổng quan resource AWS

ResourceMục đích
ECR Repository ×2Lưu Docker image của NestJS và Next.js
Security Group ×3alb-sg (internet-facing) → ecs-sg (chỉ nhận từ ALB) → rds-sg (chỉ nhận từ ECS + IP local)
RDS PostgreSQLDatabase riêng cho NestJS, đặt ở private subnet
ECS ClusterFargate, dùng chung cho cả NestJS và Next.js
Task Definition ×2Mỗi service 1 definition, tự tạo revision mới khi đổi config
ALB + Target Group ×41 ALB dùng chung, route theo path; 4 Target Group cho 2 cặp Blue/Green
ECS Service ×2Deployment Controller: CODE_DEPLOY
CodeDeploy App + Deployment Group ×2Blue/Green deployment cho từng service
S3 BucketStatic hosting cho React Admin build
CloudFront ×2#1 phục vụ React Admin (origin S3); #2 làm proxy HTTPS đứng trước ALB
IAM User riêng cho CI/CDKhông dùng admin credentials cho GitHub Actions

Phase 1 — NestJS Backend

Khởi tạo project bằng NestJS CLI. NestJS dùng module system — mỗi feature là 1 module độc lập (AuthModule, UsersModule...), khác với kiểu monolithic của Laravel hay annotation-heavy của Spring Boot.

npm i -g @nestjs/cli
nest new nestjs-backend

Cấu hình môi trường tách khỏi code bằng @nestjs/config. Trên ECS Fargate, biến env được inject qua Task Definition — không deploy file .env trực tiếp.

# .env (local dev only — gitignored)
NODE_ENV=development
PORT=8080
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=your_password
DB_NAME=nestjs_backend
JWT_SECRET=your_jwt_secret
JWT_EXPIRES_IN=7d
CORS_ORIGIN=*

Dockerfile multi-stage build cho production — chỉ copy dist/ và node_modules production vào image cuối, không mang theo devDependencies hay source TypeScript.

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 8080
CMD ["node", "dist/main"]

Local dev workflow nhanh hơn nếu chỉ chạy database trong Docker, còn app chạy trực tiếp bằng npm run start:dev để có hot-reload — không cần rebuild image mỗi lần sửa code.

docker-compose up -d postgres
npm run start:dev

Kết nối database qua TypeORM. Khác Laravel (1 process duy nhất), NestJS có 2 process riêng biệt: app chạy thật, và TypeORM CLI dùng cho migration — cần file data-source.ts riêng cho CLI đọc.

npm run migration:generate src/database/migrations/InitSchema
npm run migration:run

DB_HOST khác nhau tuỳ nơi chạy: localhost khi chạy migration ngoài container, postgres khi chạy trong container (tên service trong docker-compose), và endpoint RDS thật khi chạy trên ECS production.

Auth module dùng JWT (passport-jwt) với UsersModule inject TypeORM Repository trực tiếp, không tạo lớp Repository trung gian. Một điểm dễ vấp: vì ALB forward nguyên path gốc (không tự strip /api), NestJS phải set app.setGlobalPrefix('api') trong main.ts để mọi route — kể cả /health — đều có tiền tố /api, khớp với Listener Rule path-pattern /api/* của ALB.

Phase 2 — Next.js Landing Page

Next.js 15 dùng App Router — Server Components là mặc định, component không cần "use client" sẽ render trên server, không gửi JS xuống browser. Khác biệt lớn nhất với SPA truyền thống: server gọi API ngay trên server, browser nhận HTML đã render sẵn, không thấy network request nào trong DevTools — request chỉ hiện trong log server.

async function getServerStatus() {
  const apiUrl = process.env.API_URL ?? "http://localhost:3000";
  const res = await fetch(`${apiUrl}/health`, { next: { revalidate: 30 } });
  if (!res.ok) return null;
  return res.json();
}

export default async function Home() {
  const status = await getServerStatus();
  return <ServerStatus status={status} />;
}

Phân biệt 2 loại biến môi trường: API_URL không có tiền tố NEXT_PUBLIC_ nên chỉ tồn tại phía server, không lộ ra browser. NEXT_PUBLIC_ADMIN_URL có tiền tố đó nên được bundle vào JS chạy phía client.

Docker build dùng standalone output để giảm image size — chỉ copy đúng file cần để chạy production, không mang theo toàn bộ node_modules.

// next.config.ts
const nextConfig = { output: 'standalone' };
export default nextConfig;

Lưu ý quan trọng nhất ở phase này: NEXT_PUBLIC_ADMIN_URL được bake cứng vào JS bundle ngay lúc build (docker build --build-arg), không phải lúc container chạy. Sửa Environment trong Task Definition rồi update Service không có tác dụng gì — bắt buộc build lại image với đúng --build-arg, push lại registry, rồi trigger deployment mới.

Phase 3 — React Admin (SPA)

React + Vite + TypeScript + Material UI — đây là SPA thật sự, khác Next.js SSR ở chỗ browser tải JS bundle rồi render UI, gọi API qua axios. Mọi request đều thấy trong DevTools Network.

npm create vite@latest react-admin -- --template react-ts
npm install @mui/material @emotion/react @emotion/styled
npm install axios react-router-dom @reduxjs/toolkit react-redux

Vite dùng tiền tố VITE_ cho biến public — biến không có tiền tố này sẽ không được bundle vào JS, khác quy ước NEXT_PUBLIC_ của Next.js nhưng cùng bản chất build-time.

VITE_API_URL=https://<cloudfront-be-proxy-domain> npm run build
aws s3 sync dist/ s3://<site-bucket> --delete
aws cloudfront create-invalidation --distribution-id <distribution-id> --paths "/*"

SPA routing cần cấu hình Error Pages trên CloudFront: 403/404 → trả về /index.html với HTTP 200. Thiếu bước này, user refresh ở một route con (ví dụ /products) sẽ nhận lỗi 403 thay vì đúng trang.

Phase 4 — Hạ tầng AWS

Networking

Dựng 4 subnet trong VPC có sẵn: 2 public (cho ALB) + 2 private (cho ECS task và RDS), trải trên 2 Availability Zone. Điểm khác biệt giữa public/private không nằm ở tên hay việc có gán IP public hay không — mà ở route table gắn vào subnet đó: route trỏ Internet Gateway (2 chiều) là public, route trỏ NAT Gateway (chỉ ra được, không vào thẳng được) là private.

SubnetRoute table trỏ tới
public-subnet-1a / 1bInternet Gateway
private-subnet-1a / 1bNAT Gateway

NAT Gateway là khoản chi phí networking đáng kể nhất (tính theo giờ chạy + theo GB traffic) — chỉ cần đặt 1 cái ở 1 AZ, dùng chung cho cả 2 private subnet là đủ với quy mô demo, không cần 1 NAT/AZ trừ khi yêu cầu HA cao hơn.

Security Groups

NameInboundMục đích
alb-sg:80, :443, :8080 từ 0.0.0.0/0ALB nhận traffic internet
ecs-sg:8080, :3000 từ alb-sgECS container chỉ nhận từ ALB
rds-sg:5432 từ ecs-sg + My IPDatabase chỉ nhận từ ECS + local dev

RDS PostgreSQL

Chỉ NestJS cần database, chạy ở private subnet đúng chuẩn bảo mật nên không public access trực tiếp. Để chạy lệnh CREATE DATABASE ban đầu qua client GUI (Navicat...), cách làm là dựng tạm 1 EC2 làm Bastion Host ở public subnet (SG chỉ mở SSH từ IP của mình), thêm rule cho rds-sg chấp nhận port 5432 từ SG của bastion, dùng SSH Tunnel để kết nối, xong việc thì terminate bastion ngay và gỡ rule đã thêm.

Một điểm dễ mắc lỗi: AWS Console không cho đổi DB Subnet Group sau khi RDS đã tạo (báo lỗi dù chọn group nào cùng VPC) — nên phải chọn đúng ngay từ đầu.

ECS Cluster, Task Definition, ALB path-based routing

1 cluster Fargate chứa cả 2 service, mỗi service có Task Definition riêng (mỗi lần sửa config tạo revision mới, không sửa revision cũ). Điểm hay nhất ở đây: dùng 1 ALB dùng chung cho cả NestJS và Next.js, phân biệt bằng path thay vì port — 2 Listener (: 80 production, :8080 test cho CodeDeploy) mỗi cái có default action + 1 rule theo path.

ListenerDefault actionRule (path)
HTTP:80 (production)forward → nextjs-lp-tg-1/api/* → nestjs-backend-tg-1
HTTP:8080 (test)forward → nextjs-lp-tg-2/api/* → nestjs-backend-tg-2

ECS Service tạo qua CLI (Console không hỗ trợ đầy đủ deployment controller CODE_DEPLOY). Subnet phải là private, assignPublicIp=DISABLED — task chạy private subnet không cần IP public, ra Internet để pull image ECR đã có NAT Gateway lo.

CodeDeploy Blue/Green

Mỗi service có 1 IAM Role dùng chung (AWSCodeDeployRoleForECS), 1 Application, 1 Deployment Group riêng. Vì dùng path-based routing, cả 2 service chia sẻ chung 2 Listener — CodeDeploy chỉ swap đúng target group được khai trong Deployment Group của từng service, không ảnh hưởng rule của service kia trên cùng Listener.

aws deploy create-deployment-group \
  --application-name nestjs-backend-app \
  --deployment-group-name nestjs-backend-dg \
  --deployment-config-name CodeDeployDefault.ECSAllAtOnce \
  --service-role-arn arn:aws:iam::<account-id>:role/CodeDeployRoleForECS \
  --ecs-services clusterName=<cluster>,serviceName=nestjs-backend-service \
  --deployment-style "deploymentType=BLUE_GREEN,deploymentOption=WITH_TRAFFIC_CONTROL" \
  --blue-green-deployment-configuration "terminateBlueInstancesOnDeploymentSuccess={action=TERMINATE,terminationWaitTimeInMinutes=5}"

Để test cơ chế Blue/Green thật sự (không chỉ test việc chuyển đổi), phải tạo Task Definition revision mới trước — dùng lại y hệt revision đang chạy thì Task Set Green sẽ giống hệt Blue.

CloudFront + S3 cho React Admin, và proxy HTTPS cho API

CloudFront #1 đứng trước S3 (Origin Access Control, chặn truy cập S3 trực tiếp), phục vụ static build của React Admin. Vấn đề phát sinh: React Admin chạy HTTPS qua CloudFront nhưng gọi thẳng ALB (HTTP) → trình duyệt chặn với lỗi Mixed Content.

Trước:  FE (HTTPS) → ALB (HTTP)                          ❌ bị block
Sau:    FE (HTTPS) → CloudFront #2 (HTTPS) → ALB (HTTP)   ✅

Giải pháp không cần mua domain riêng: đặt thêm 1 CloudFront distribution nữa làm proxy HTTPS đứng trước ALB (Origin type: Other, Cache policy: CachingDisabled vì API không nên cache, Origin request policy: AllViewer để forward đủ headers/query string). CloudFront #2 tự động phục vụ cả 2 service vì ALB route theo path, không phải theo port.

Secrets Manager thay cho biến môi trường plaintext

Ban đầu DB_PASSWORD/JWT_SECRET để trực tiếp trong Environment của Task Definition — vi phạm nguyên tắc không hard-code credentials, vì bất kỳ ai có quyền ecs:DescribeTaskDefinition cũng đọc được plaintext. Chuyển 3 biến nhạy cảm sang field Secrets: Task Definition chỉ chứa ARN, giá trị thật được ECS Agent lấy lúc container khởi động.

Secrets:
  - Name: DB_PASSWORD
    ValueFrom: arn:aws:secretsmanager:<region>:<account-id>:secret:nestjs-backend/db-credentials-xxxxxx:password::

Managed policy mặc định AmazonECSTaskExecutionRolePolicy không có sẵn quyền secretsmanager:GetSecretValue — phải tự thêm inline policy giới hạn đúng ARN của 2 secret, không dùng Resource: "*".

Một gotcha đáng nhớ: xoá secret qua Console/CLI mặc định chỉ lên lịch xoá (recovery window 30 ngày), tên secret vẫn bị giữ chỗ. Deploy lại với secret cùng tên trong lúc đang chờ xoá sẽ lỗi "already scheduled for deletion" — phải force-delete-without-recovery mới tạo lại được ngay.

Phase 5 — CI/CD với GitHub Actions

Mỗi service 1 repository riêng thay vì monorepo — push backend không trigger pipeline frontend và ngược lại, secrets quản lý độc lập theo từng repo.

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}
      - id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
      - run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
      - uses: aws-actions/amazon-ecs-render-task-definition@v1
        with: { task-definition: task-definition.json, container-name: nestjs-backend, image: ... }
      - uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          codedeploy-application: nestjs-backend-app
          codedeploy-deployment-group: nestjs-backend-dg

Repo React Admin đơn giản hơn — không build Docker image, chỉ build tĩnh rồi sync S3 + invalidate CloudFront.

- run: npm ci
- run: npm run build
  env: { VITE_API_URL: ${{ secrets.VITE_API_URL }} }
- run: aws s3 sync dist/ s3://${{ env.S3_BUCKET }} --delete
- run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"

Tạo riêng 1 IAM User cho CI/CD (github-actions-deploy) thay vì dùng admin credentials — chỉ attach đúng policy cần: ECR, ECS, CodeDeploy, S3, CloudFront. Credentials chỉ lưu trong GitHub Secrets, không hard-code vào code.

Gotchas hay gặp nhất

LỗiNguyên nhânFix
Link admin không hoạt động (href rỗng)NEXT_PUBLIC_* bị bake cứng lúc build, sửa Environment ở Task Definition không có tác dụngBuild lại image với đúng --build-arg, push, trigger deployment mới
Mixed ContentSPA HTTPS gọi thẳng ALB HTTPThêm CloudFront proxy HTTPS đứng trước ALB
Secret "already scheduled for deletion"Xoá secret trước đó chỉ lên lịch (30 ngày), tên vẫn giữ chỗforce-delete-without-recovery trước khi tạo lại cùng tên
403 khi docker push lên ECRToken đăng nhập Docker vào ECR hết hạn sau 12 tiếngChạy lại aws ecr get-login-password | docker login...
ExpiredToken khi gọi AWS CLIBiến môi trường AWS_SESSION_TOKEN cũ còn tồn tại, CLI ưu tiên env var hơn ~/.aws/credentialsunset 3 biến AWS_* rồi thử lại aws sts get-caller-identity
Cannot stop ECS serviceService đang scale > 0update-service --desired-count 0 trước khi xoá

Thứ tự xoá khi dọn dẹp resource

  • ECS Service — scale desired-count về 0 trước, rồi mới delete
  • CodeDeploy — Deployment Group trước, Application sau
  • ECS Cluster
  • Load Balancer, rồi Target Group
  • CloudFront — Disable trước, đợi rồi mới Delete được
  • S3 Bucket — xoá object trước, rồi mới xoá bucket rỗng
  • RDS Database
  • ECR Repository
  • Security Group — theo thứ tự phụ thuộc ngược: rds-sg → ecs-sg → alb-sg
  • Secrets Manager — force-delete-without-recovery nếu muốn tái sử dụng tên ngay

Domain + SSL (Route 53 + ACM + CloudFront)

Setup domain/SSL làm thủ công qua Console, không đưa vào cùng 1 file định nghĩa hạ tầng với phần còn lại — vì ACM certificate cho CloudFront bắt buộc nằm ở us-east-1, trong khi hạ tầng chính (ECS, RDS, ALB) chạy ở region khác, không gộp chung được trong 1 lần deploy.

Alias record (Route 53) nên dùng thay vì CNAME thường khi trỏ vào tài nguyên AWS như CloudFront/ALB — miễn phí query và dùng được cho root/apex domain, trong khi CNAME thường tính phí theo lượt query và không dùng được cho root domain.

Chi phí cần nhớ: Hosted Zone tốn khoảng $0.5/tháng dù không có traffic — nếu chỉ test tạm thời, xoá Hosted Zone sau khi xong (giữ nguyên domain registration), cần dùng lại thì tạo mới và update lại NS record. Domain registration thì không refund được, chỉ tắt Auto renew để không bị charge chu kỳ tiếp theo.