Deploy Nuxt.js + FastAPI lên AWS ECS (Phần 1) — Dockerize, ECR, ECS Fargate và CI/CD — hoatq.dev

cat blog/.md

Deploy Nuxt.js + FastAPI lên AWS ECS (Phần 1) — Dockerize, ECR, ECS Fargate và CI/CD

date: tags: aws, ecs, nuxtjs, fastapi, github-actions, devops, docker

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ú ý:

  • cpumemory: 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ừa
  • healthCheck: ECS sẽ tự restart container nếu health check fail — đảm bảo service luôn healthy
  • logConfiguration: Đẩ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_URL trong 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_ID
  • AWS_SECRET_ACCESS_KEY
  • AWS_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-filter detect 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.

// reactions



hoatq@dev : ~/blog $