cat blog/.md
Deploy Nuxt.js + FastAPI lên AWS ECS (Phần 1) — Dockerize, ECR, ECS Fargate và CI/CD
Dự án của bạn đang chạy ổn trên một con VPS. Traffic tăng, bạn scale vertical — nâng RAM, thêm CPU. Rồi đến lúc một con server không đủ nữa. Bạn cần auto-scaling, zero-downtime deployment, health check tự động, và khả năng rollback trong 30 giây.
Đó là lúc bạn cần container orchestration — và AWS ECS là lựa chọn “vừa đủ phức tạp” giữa việc tự manage Docker Compose trên EC2 và all-in Kubernetes.
Series 2 phần này sẽ hướng dẫn deploy một hệ thống full-stack Nuxt.js + FastAPI lên ECS Fargate, CI/CD hoàn toàn tự động bằng GitHub Actions, kèm theo CloudFront, Route 53, SSL và WAF để sẵn sàng production.
Phần 1 (bài này): Dockerize, push ECR, setup ECS, CI/CD pipeline Phần 2: Route 53, CloudFront, ACM (SSL/HTTPS), WAF, và hoàn thiện hệ thống
Kiến trúc tổng quan
Trước khi vào chi tiết, hãy nhìn bức tranh toàn cảnh:
GitHub Push → GitHub Actions → Build Docker Images
↓
Push to ECR (Elastic Container Registry)
↓
Update ECS Task Definition
↓
ECS Fargate deploys new containers
↓
ALB (Application Load Balancer)
↙ ↘
Nuxt.js (frontend) FastAPI (backend)
Port 3000 Port 8000
Các thành phần chính:
- ECR: Registry chứa Docker images (giống Docker Hub nhưng private, nằm trong AWS)
- ECS Fargate: Chạy container mà không cần quản lý EC2 instances — bạn chỉ định CPU/RAM, AWS lo phần còn lại
- ALB: Load balancer phân phối traffic, route
/api/*sang FastAPI, còn lại sang Nuxt.js - GitHub Actions: Build image, push ECR, trigger deploy — tất cả tự động
Bước 1: Dockerize cả hai services
Nuxt.js Frontend
# frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/package.json .
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
USER node
CMD ["node", ".output/server/index.mjs"]
Nuxt 3 build ra thư mục .output với Nitro server — chỉ cần copy folder đó sang production stage. Image cuối chỉ khoảng ~150MB.
FastAPI Backend
# backend/Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY ./app ./app
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
RUN useradd -r appuser && chown -R appuser /app
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Multi-stage build cho Python — bỏ lại pip cache và build tools ở stage đầu.
Bước 2: Tạo ECR Repositories
Bạn cần 2 repositories trên ECR — một cho frontend, một cho backend:
# Tạo ECR repositories
aws ecr create-repository --repository-name myapp/frontend --region ap-southeast-1
aws ecr create-repository --repository-name myapp/backend --region ap-southeast-1
# Kết quả trả về repositoryUri, VD:
# 123456789012.dkr.ecr.ap-southeast-1.amazonaws.com/myapp/frontend
# 123456789012.dkr.ecr.ap-southeast-1.amazonaws.com/myapp/backend
Lưu lại repositoryUri — sẽ dùng trong GitHub Actions workflow.
Thêm lifecycle policy để ECR tự dọn images cũ, tránh tốn tiền lưu trữ:
aws ecr put-lifecycle-policy \
--repository-name myapp/frontend \
--lifecycle-policy-text '{
"rules": [{
"rulePriority": 1,
"description": "Keep last 10 images",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 10
},
"action": { "type": "expire" }
}]
}'
Bước 3: Setup ECS Cluster và Task Definitions
Tạo ECS Cluster
aws ecs create-cluster --cluster-name myapp-cluster --region ap-southeast-1
Với Fargate, cluster chỉ là logical grouping — không có EC2 instance nào được tạo.
Task Definition
Task definition mô tả containers sẽ chạy trong một “task” (tương tự pod trong Kubernetes). Tạo file task-definition.json:
{
"family": "myapp",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "frontend",
"image": "ACCOUNT_ID.dkr.ecr.ap-southeast-1.amazonaws.com/myapp/frontend:latest",
"portMappings": [
{ "containerPort": 3000, "protocol": "tcp" }
],
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/myapp/frontend",
"awslogs-region": "ap-southeast-1",
"awslogs-stream-prefix": "ecs"
}
},
"healthCheck": {
"command": ["CMD-SHELL", "wget -qO- http://localhost:3000/ || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
}
},
{
"name": "backend",
"image": "ACCOUNT_ID.dkr.ecr.ap-southeast-1.amazonaws.com/myapp/backend:latest",
"portMappings": [
{ "containerPort": 8000, "protocol": "tcp" }
],
"essential": true,
"environment": [
{ "name": "DATABASE_URL", "value": "postgresql://..." },
{ "name": "CORS_ORIGINS", "value": "https://yourdomain.com" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/myapp/backend",
"awslogs-region": "ap-southeast-1",
"awslogs-stream-prefix": "ecs"
}
},
"healthCheck": {
"command": ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\" || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 30
}
}
]
}
Một vài điểm cần chú ý:
cpuvàmemory: Fargate tính theo task, không theo container. 512 CPU units = 0.5 vCPU, 1024 MB RAM — đủ cho hầu hết ứng dụng nhỏ-vừahealthCheck: ECS sẽ tự restart container nếu health check fail — đảm bảo service luôn healthylogConfiguration: Đẩy logs về CloudWatch — không cần SSH vào container để xem log- Secrets: Nên dùng AWS Secrets Manager thay vì hardcode
DATABASE_URLtrong environment (sẽ nói thêm ở phần Tips)
Register task definition:
aws ecs register-task-definition --cli-input-json file://task-definition.json
Tạo ECS Service
Service đảm bảo luôn có đúng số lượng tasks đang chạy, kết hợp với ALB để route traffic:
aws ecs create-service \
--cluster myapp-cluster \
--service-name myapp-service \
--task-definition myapp \
--desired-count 2 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={
subnets=[subnet-xxx,subnet-yyy],
securityGroups=[sg-xxx],
assignPublicIp=ENABLED
}" \
--load-balancers "[
{
\"targetGroupArn\": \"arn:aws:elasticloadbalancing:...:targetgroup/frontend-tg/xxx\",
\"containerName\": \"frontend\",
\"containerPort\": 3000
}
]" \
--deployment-configuration "minimumHealthyPercent=50,maximumPercent=200"
desired-count: 2 nghĩa là luôn có 2 tasks chạy — nếu 1 task chết, ECS tự tạo task mới. minimumHealthyPercent=50 cho phép rolling deployment: ECS sẽ dừng 1 task cũ, khởi động 1 task mới, đảm bảo luôn có ít nhất 1 task healthy.
Bước 4: ALB — Route traffic đến đúng service
Application Load Balancer phân phối traffic dựa trên path:
# Rule 1: /api/* → Backend (FastAPI) target group
aws elbv2 create-rule \
--listener-arn arn:aws:elasticloadbalancing:...:listener/xxx \
--priority 10 \
--conditions '[{"Field":"path-pattern","Values":["/api/*"]}]' \
--actions '[{"Type":"forward","TargetGroupArn":"arn:...backend-tg..."}]'
# Rule 2: Default → Frontend (Nuxt.js) target group
# (default action của listener đã set về frontend-tg)
Request flow: yourdomain.com/api/users → ALB → FastAPI container. yourdomain.com/dashboard → ALB → Nuxt.js container. Clean và simple.
Bước 5: GitHub Actions — CI/CD tự động
Đây là phần hay nhất. Mỗi khi push code lên main, GitHub Actions sẽ tự động build images, push lên ECR, và update ECS service.
Setup AWS credentials
Tạo IAM user (hoặc tốt hơn: dùng OIDC) với permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecs:UpdateService",
"ecs:DescribeServices",
"ecs:RegisterTaskDefinition",
"ecs:DescribeTaskDefinition",
"iam:PassRole"
],
"Resource": "*"
}
]
}
Thêm vào GitHub Secrets:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_REGION(VD:ap-southeast-1)AWS_ACCOUNT_ID
Workflow file
# .github/workflows/deploy-ecs.yml
name: Deploy to ECS
on:
push:
branches: [main]
paths:
- 'frontend/**'
- 'backend/**'
- '.github/workflows/deploy-ecs.yml'
env:
AWS_REGION: ap-southeast-1
ECR_FRONTEND: myapp/frontend
ECR_BACKEND: myapp/backend
ECS_CLUSTER: myapp-cluster
ECS_SERVICE: myapp-service
TASK_FAMILY: myapp
permissions:
id-token: write # Cần cho OIDC
contents: read
jobs:
# Job 1: Detect thay đổi ở service nào
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
frontend:
- 'frontend/**'
backend:
- 'backend/**'
# Job 2: Build và push Frontend image
build-frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
outputs:
image: ${{ steps.build.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
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 }}
- name: Login to ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
id: build
env:
ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_FRONTEND:$IMAGE_TAG \
-t $ECR_REGISTRY/$ECR_FRONTEND:latest \
./frontend
docker push $ECR_REGISTRY/$ECR_FRONTEND:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_FRONTEND:latest
echo "image=$ECR_REGISTRY/$ECR_FRONTEND:$IMAGE_TAG" >> $GITHUB_OUTPUT
# Job 3: Build và push Backend image
build-backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
outputs:
image: ${{ steps.build.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
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 }}
- name: Login to ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
id: build
env:
ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_BACKEND:$IMAGE_TAG \
-t $ECR_REGISTRY/$ECR_BACKEND:latest \
./backend
docker push $ECR_REGISTRY/$ECR_BACKEND:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_BACKEND:latest
echo "image=$ECR_REGISTRY/$ECR_BACKEND:$IMAGE_TAG" >> $GITHUB_OUTPUT
# Job 4: Update ECS Task Definition và deploy
deploy:
needs: [build-frontend, build-backend]
if: always() && (needs.build-frontend.result == 'success' || needs.build-backend.result == 'success')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
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 }}
- name: Get current task definition
run: |
aws ecs describe-task-definition \
--task-definition $TASK_FAMILY \
--query 'taskDefinition' > task-def.json
# Loại bỏ các fields không cần khi register lại
jq 'del(.taskDefinitionArn, .revision, .status,
.requiresAttributes, .compatibilities,
.registeredAt, .registeredBy)' task-def.json > new-task-def.json
- name: Update frontend image in task definition
if: needs.build-frontend.outputs.image != ''
run: |
jq --arg IMG "${{ needs.build-frontend.outputs.image }}" \
'(.containerDefinitions[] | select(.name == "frontend")).image = $IMG' \
new-task-def.json > tmp.json && mv tmp.json new-task-def.json
- name: Update backend image in task definition
if: needs.build-backend.outputs.image != ''
run: |
jq --arg IMG "${{ needs.build-backend.outputs.image }}" \
'(.containerDefinitions[] | select(.name == "backend")).image = $IMG' \
new-task-def.json > tmp.json && mv tmp.json new-task-def.json
- name: Register new task definition
id: task-def
run: |
TASK_DEF_ARN=$(aws ecs register-task-definition \
--cli-input-json file://new-task-def.json \
--query 'taskDefinition.taskDefinitionArn' \
--output text)
echo "arn=$TASK_DEF_ARN" >> $GITHUB_OUTPUT
- name: Deploy to ECS
run: |
aws ecs update-service \
--cluster $ECS_CLUSTER \
--service $ECS_SERVICE \
--task-definition ${{ steps.task-def.outputs.arn }} \
--force-new-deployment
- name: Wait for deployment to stabilize
run: |
aws ecs wait services-stable \
--cluster $ECS_CLUSTER \
--services $ECS_SERVICE
echo "Deployment completed successfully!"
Workflow này có mấy điểm hay:
- Chỉ build service thay đổi: Dùng
dorny/paths-filterdetect xem frontend hay backend thay đổi — không build lại cả hai mỗi lần push - Build song song: Frontend và backend build đồng thời — giảm một nửa thời gian
- Update task definition tự động: Lấy task definition hiện tại, thay image mới, register lại — không cần sửa file JSON thủ công
- Wait for stable:
aws ecs wait services-stableđợi cho đến khi deployment hoàn tất — nếu fail, workflow sẽ báo lỗi
Tips nhanh
1. Dùng AWS Secrets Manager cho sensitive data
Đừng hardcode database URL hay API keys trong task definition:
{
"name": "backend",
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:ap-southeast-1:ACCOUNT_ID:secret:myapp/db-url"
},
{
"name": "JWT_SECRET",
"valueFrom": "arn:aws:secretsmanager:ap-southeast-1:ACCOUNT_ID:secret:myapp/jwt-secret"
}
]
}
ECS sẽ inject secrets vào container environment lúc runtime — không bao giờ xuất hiện trong task definition hay logs.
2. OIDC thay vì Access Keys
Thay vì lưu AWS_ACCESS_KEY_ID trong GitHub Secrets (có thể bị leak), dùng OpenID Connect:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-role
aws-region: ap-southeast-1
Không có long-lived credentials — GitHub Actions nhận temporary token mỗi lần chạy.
3. Rollback nhanh khi deploy lỗi
# Xem các revisions của task definition
aws ecs list-task-definitions --family-prefix myapp --sort DESC
# Rollback về revision trước
aws ecs update-service \
--cluster myapp-cluster \
--service myapp-service \
--task-definition myapp:42 \
--force-new-deployment
Rollback mất khoảng 30 giây — ECS sẽ drain connections cũ và route traffic sang containers mới. Đây là một trong những lợi thế lớn nhất so với deploy thủ công.
Kết Phần 1
Đến đây, bạn đã có một hệ thống hoạt động: containers chạy trên ECS Fargate, CI/CD tự động qua GitHub Actions, ALB phân phối traffic giữa frontend và backend. Push code là deploy — không cần SSH, không cần thao tác thủ công.
Nhưng hệ thống chưa “production-ready”. Users vẫn đang truy cập qua ALB domain xấu xí kiểu myapp-alb-123456.ap-southeast-1.elb.amazonaws.com. Chưa có HTTPS, chưa có CDN, chưa có WAF bảo vệ khỏi tấn công.
Phần 2 sẽ hoàn thiện tất cả: Route 53 (DNS), ACM (SSL/HTTPS miễn phí), CloudFront (CDN toàn cầu), WAF (tường lửa), auto-scaling, monitoring — biến hệ thống này thành production-ready thực sự.
Đọc tiếp Phần 2: Route 53, CloudFront, SSL và WAF — hoàn thiện hệ thống production-ready.