Quay lại

Tự Động Deploy Next.js Static Site với Jenkins, S3 và CloudFront (OAC) Chuyên mục Devops    2025-05-15    1 Lượt xem    0 Lượt thích    comment-3 Created with Sketch Beta. 0 Bình luận

Trong bài viết này, mình sẽ chia sẻ chi tiết quy trình tự động hóa deploy một ứng dụng Next.js Static Export lên S3 + CloudFront thông qua Jenkins Pipeline. Bài viết sẽ giải thích rõ từng bước và highlight những điểm cực kỳ quan trọng mà bạn nên lưu ý.

Ở bài trước đó mình cũng đã có 1 bài về Hướng Dẫn Triển Khai Hệ Thống Phân Phối Nội Dung Với S3 + CloudFront + ACM + OAC + Route 53 Nếu bạn nào chưa biết thì đọc qua bài này trước đọc bài viết này để hiểu rõ hơn nhé!


1. Tổng Quan Kiến Trúc

  • Next.js app build bằng output: "export" → thư mục out/

  • Jenkins Pipeline tự động hóa:

    • Phân tích mã với SonarQube

    • Build + deploy thư mục out/ lên S3

    • Invalidate cache trên CloudFront

  • Không bật Static Website Hosting của S3

  • Sử dụng CloudFront OAC (Origin Access Control) để bảo mật bucket

  • Dùng CloudFront Function để handle đường dẫn clean URLs


2. Cấu hình next.config.js

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactStrictMode: false,
  devIndicators: false,
  output: "export",
  images: {
    unoptimized: true, // bắt buộc với static export
  },
  trailingSlash: false, // quan trọng, xem giải thích bên dưới
};

export default nextConfig;

🔍 trailingSlash: false vs true

Option Cấu trúc thư mục khi build (out/) URL cần truy cập
trailingSlash: true out/signin/index.html /signin/
trailingSlash: false out/signin.html /signin

Nếu bạn để true thì khi truy cập /signin sẽ 404, vì CloudFront tìm signin thay vì signin/index.html. Ngược lại, nếu để false thì /signin/ cũng sẽ 404, vì signin/ không tồn tại trong S3.

👉 Lời khuyên: Sử dụng trailingSlash: false và kết hợp CloudFront Function để rewrite đường dẫn chính xác.


3. CloudFront Function: Rewrite URL chuẩn SEO

✅ Mục tiêu:

  • Rewrite /signin/signin.html

  • Rewrite /setting-user /setting-user.html

  • Redirect /signin/ /signin

✅ Function Code:

function handler(event) {
  var request = event.request;
  var uri = request.uri;

  // Nếu URL có slash cuối → redirect về không slash
  if (uri !== '/' && uri.endsWith('/')) {
    return {
      statusCode: 301,
      statusDescription: 'Moved Permanently',
      headers: {
        location: { value: uri.slice(0, -1) }
      }
    };
  }

  // Nếu không có phần mở rộng → thêm .html
  if (!uri.includes('.') && !uri.endsWith('/')) {
    request.uri += '.html';
  }

  return request;
}

🧠 Tại sao cần function này?

CloudFront không hiểu rewrite như Next.js server. Khi static export, nó chỉ thấy file .html. Nếu bạn truy cập /signin, nó không tự động map thành signin.html. Function này sẽ giúp giải quyết triệt để vấn đề.

⚠️ Đừng quên: Gắn function vào Viewer Request của CloudFront Distribution.


4. Jenkins Pipeline đầy đủ

pipeline {
    agent any

    environment {
        AWS_REGION = "ap-northeast-1"
        TEAMS_WEBHOOK_URL = ''
        SONARSERVER = 'sonarserver'
        SONARSCANNER = 'sonarscanner'
        registryCredential = ''
        S3_BUCKET_NAME = ''
        CLOUDFRONT_ID = ''
    }

    stages {
        stage('Checkout Code') {
            steps {
                checkout scm
            }
        }

        stage('SonarQube Analysis') {
            environment {
                scannerHome = tool "${SONARSCANNER}"
            }
            steps {
                withSonarQubeEnv("${SONARSERVER}") {
                    script {
                        env.SONAR_SERVER_URL = env.SONAR_HOST_URL
                        env.SONAR_TOKEN = env.SONAR_AUTH_TOKEN
                    }
                    sh '''${scannerHome}/bin/sonar-scanner \
                        -Dsonar.projectKey=liveapp_cms \
                        -Dsonar.projectName=LiveAppCMS \
                        -Dsonar.projectVersion=1.0 \
                        -Dsonar.sources=. \
                        -Dsonar.exclusions="**/migrations/**, **/node_modules/**, **/test/**" \
                        -Dsonar.working.directory=.sonar'''
                }
            }
        }

        stage('Wait for SonarQube') {
            steps {
                timeout(time: 15, unit: 'MINUTES') {
                    script {
                        def qualityGate = waitForQualityGate()
                        if (qualityGate.status != 'OK') {
                            sendTeamsNotification("Quality Gate failed: ${qualityGate.status}")
                            error "Quality Gate failed: ${qualityGate.status}"
                        }
                    }
                }
            }
        }

        stage('Install Dependencies') {
            tools {
                nodejs 'nodejs_v20'
            }
            steps {
                sh 'npm install'
            }
        }

        stage('Build Next.js Static Site') {
            steps {
                sh 'npm run build'
            }
        }

        stage('Deploy to S3') {
            steps {
                withAWS(credentials: 'awscreds', region: "${AWS_REGION}") {
                    sh "aws s3 sync out/ s3://${S3_BUCKET_NAME} --delete"
                }
            }
        }

        stage('Invalidate CloudFront Cache') {
            steps {
                withAWS(credentials: 'awscreds', region: "${AWS_REGION}") {
                    sh '''
                    aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_ID} --paths "/*"
                    '''
                }
            }
        }
    }

    post {
        success {
            sendTeamsNotification("✅ Deployment successful! 🎉")
        }
    }
}

def sendTeamsNotification(String message) {
    def sanitizedMessage = message
        .replaceAll("\\\\", "\\\\\\\\")
        .replaceAll("\"", "\\\\\"")
        .replaceAll("\n", "\\\\n")
    
    def payload = """
    {
        "@type": "MessageCard",
        "@context": "https://schema.org/extensions",
        "themeColor": "ff0000",
        "summary": "Jenkins Build Notification",
        "sections": [{
            "activityTitle": "CMS Jenkins Pipeline Notification",
            "text": "${sanitizedMessage}"
        }]
    }
    """

    sh "curl -H 'Content-Type: application/json' -d '${payload}' '${TEAMS_WEBHOOK_URL}'"
}

5. Xử lý lỗi 403/404 trên CloudFront

Vì bạn không bật Static Website Hosting, nên CloudFront khi gặp lỗi sẽ trả về XML thô rất xấu. Bạn nên cấu hình:

  • Custom Error Page cho 403/404 → trỏ về /404.html trong S3

  • Trong CloudFront → chọn Customize Error ResponseResponse Page Path: /404.html, Response Code: 200


6. Lưu ý quan trọng

  • Invalidate CloudFront Cache sau mỗi deploy để người dùng không thấy file cũ

  • Không để images.optimize = true trong next.config.js khi dùng output: export

  • Dữ liệu dynamic hoặc API cần build thành static trước khi export hoặc dùng các giải pháp SSR khác


✅ Kết luận

Việc kết hợp Next.js Static Export, S3, CloudFront OAC và Jenkins cho CI/CD không hề đơn giản nếu bạn không hiểu rõ cách hoạt động của URL mapping và cấu trúc file sau build. Việc sử dụng CloudFront Function chính là chìa khóa quan trọng để giữ cho hệ thống hoạt động mượt mà, chuẩn SEO và bảo mật tốt hơn.

Nếu bạn muốn source demo hoặc hỗ trợ setup CI/CD tương tự, hãy để lại bình luận hoặc liên hệ qua blog nhé!

Bình luận (0)

Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough

Bài viết liên quan

Learning English Everyday