Tích hợp CDN để serve file tĩnh cho app Laravel + Livewire

Câu hỏi nghe đơn giản: "cắm CDN cho app Laravel thì làm sao?". Trả lời kiểu thuộc lòng là "set ASSET_URL trong .env". Trả lời đó đúng một nửa và hỏng nửa còn lại, vì trong một app Livewire có tới bốn nhóm file tĩnh, mỗi nhóm sinh URL theo một cơ chế khác nhau — và ASSET_URL chỉ điều khiển được hai nhóm.

Bài này đọc code thật của project laravel-livewire (Laravel 12, Livewire 4.3, Vite, nginx blue-green) và chạy thật trong container clicon-dev-app-1 để lấy output, không đoán.

Từ khoá dùng suốt bài

  • CDN (mạng phân phối nội dung) — một tầng máy chủ cache đặt gần người dùng về mặt địa lý. Mỗi máy gọi là một edge hoặc POP (point of presence, điểm hiện diện).
  • origin (máy gốc) — server thật của bạn. Khi edge không có file, nó đi hỏi origin, gọi là cache miss; có sẵn thì là cache hit.
  • pull CDN — CDN tự lấy file từ origin khi cần. push CDN — bạn chủ động đẩy file lên trước (thường lên S3/R2).
  • content hash (băm theo nội dung) — Vite gắn chuỗi hash vào tên file, ví dụ app-CIomGrQN.js. Nội dung đổi thì tên đổi.
  • cache-busting (ép tải lại bản mới) — đổi URL để trình duyệt không dùng bản cũ trong cache.
  • immutable — giá trị trong header Cache-Control nói với trình duyệt "URL này không bao giờ đổi nội dung, đừng hỏi lại".
  • purge (xoá cache ở CDN) — bảo mọi edge quên file đang giữ.
  • CORS (chia sẻ tài nguyên khác nguồn) — luật của trình duyệt: trang ở domain A muốn đọc file ở domain B thì B phải cho phép bằng header.

1. Nó là gì / chạy thế nào

1.1. Bốn nhóm file tĩnh, bốn cơ chế sinh URL

Đây là chỗ hầu hết người ta làm sai. Cắm CDN xong, vẫn còn hai nhóm chạy thẳng vào origin mà không ai để ý.

Nhóm fileAi sinh URLĐi CDN theo ASSET_URL?
Vite build: /build/assets/app-*.js, *.css @viteVite::asset()asset()
File nằm sẵn trong public/: ảnh, favicon, svg asset('images/...')
File user upload: storage/app/public Storage::url()disks.public.url Không — key riêng trong config/filesystems.php
livewire.js, module URL, update endpoint Livewire FrontendAssets Không — phải bật riêng livewire.asset_url

Trong project này, chỗ dùng thật rất ít — đúng như một app còn gọn:

resources/views/errors/404.blade.php:34   src="{{ asset('images/404-error-illustration.svg') }}"
resources/views/components/layouts/app.blade.php:72   @vite(['resources/css/app.css', 'resources/js/app.js'])

1.2. ASSET_URL chạy ở hai pha, không phải một

Đây là điểm nhiều người hiểu sai nhất. ASSET_URL có tác dụng ở hai thời điểm khác nhau: lúc build (Node/Vite đọc) và lúc chạy (PHP đọc). Thiếu một trong hai là hỏng một nửa.

flowchart TB
  subgraph BUILD["PHA BUILD — npm run build, chạy lúc build image"]
    E1["ASSET_URL trong .env"] --> V["laravel-vite-plugin
base = ASSET_URL + '/build/'"] V --> R["Rollup ghi URL tuyệt đối
vào BÊN TRONG file build"] R --> R1["url(...) của font, ảnh trong CSS"] R --> R2["dynamic import() các chunk JS"] end subgraph RUNTIME["PHA RUNTIME — mỗi request, PHP render Blade"] E2["config('app.asset_url')"] --> U["UrlGenerator::assetRoot"] U --> A1["asset() — thẻ img, link"] U --> A2["Vite::asset() — thẻ script, link của @vite"] end BUILD -.-> RUNTIME

Bằng chứng trong code, không phải trí nhớ:

NơiDòng codeÝ nghĩa
node_modules/laravel-vite-plugin/dist/index.js:41 const assetUrl = env.ASSET_URL ?? "" Vite đọc biến lúc build
...index.js:44 base: ... resolveBase(pluginConfig, assetUrl) Chốt tiền tố cho mọi URL sinh trong file build
vendor/laravel/framework/config/app.php:62 'asset_url' => env('ASSET_URL') Key này ở config mặc định của framework
.../Bootstrap/LoadConfiguration.php:88-93 diffKeys($files) rồi $repository->set(...) Laravel 11+ merge config mặc định vào, nên vẫn có app.asset_urlconfig/app.php của project không khai báo
.../Routing/RoutingServiceProvider.php:66 truyền config['app.asset_url'] vào UrlGenerator Gốc URL của asset()
.../Foundation/Vite.php:918 ($this->assetPathResolver ?? asset(...))($path, $secure) @vite đi qua đúng asset() đó

Chạy thật trong container (dùng URL::useAssetOrigin() để giả lập ASSET_URL, vì config đang bị cache — xem Case 2 ở mục 6):

docker exec clicon-dev-app-1 php artisan tinker --execute="
  URL::useAssetOrigin('https://cdn.example.com');
  echo asset('images/404-error-illustration.svg').PHP_EOL;
  echo Vite::asset('resources/js/app.js').PHP_EOL;
"
https://cdn.example.com/images/404-error-illustration.svg
https://cdn.example.com/build/assets/app-CIomGrQN.js

Nhớ cái này: Laravel chỉ đổi gốc URL, nó không copy file nào lên CDN cả. Việc đưa file lên là chuyện của CDN (tự đi lấy — pull) hoặc của CI (đẩy lên — push). Rất nhiều người set ASSET_URL xong ngồi chờ file tự bay lên.

1.3. Livewire nằm ngoài ASSET_URL

Output thật của FrontendAssets::js([]) khi asset origin đã trỏ CDN:

<script src="http://109.123.236.64:8070/livewire-9945d67f/livewire.js?id=657d57c2"
        data-csrf=""
        data-module-url="http://109.123.236.64:8070/livewire-9945d67f"
        data-update-uri="http://109.123.236.64:8070/livewire-9945d67f/update"></script>

Ba điều phải nắm:

flowchart TB
  S["FrontendAssets::js() — chọn src cho thẻ script"] --> C1{"config('livewire.asset_url')
có set không?"} C1 -- "có" --> CDN["dùng URL đó
(vd https://cdn.example.com/vendor/livewire/livewire.min.js)"] C1 -- "không" --> C2{"public/vendor/livewire/manifest.json
đã publish chưa?"} C2 -- "rồi" --> PUB["url('vendor/livewire/livewire.min.js')
nginx serve từ disk"] C2 -- "chưa" --> ROUTE["route PHP /livewire-{hash}/livewire.js
chiếm 1 worker php-fpm mỗi lần miss"] CDN --> Q["gắn thêm ?id={hash từ dist/manifest.json}"] PUB --> Q ROUTE --> Q Q --> OUT["data-update-uri VẪN là origin — không đổi"]

Cách đưa livewire.js lên CDN — hai bước, thiếu bước nào cũng hỏng:

php artisan livewire:publish --assets   # copy dist/ -> public/vendor/livewire/
// config/livewire.php
'asset_url' => env('LIVEWIRE_ASSET_URL'),
// LIVEWIRE_ASSET_URL=https://cdn.example.com/vendor/livewire/livewire.min.js

Verify thật, sau khi set livewire.asset_url:

<script src="https://cdn.example.com/vendor/livewire/livewire.min.js?id=657d57c2"
        data-update-uri="http://109.123.236.64:8070/livewire-9945d67f/update">

Đúng như mong muốn: file JS lấy từ CDN, AJAX vẫn về origin. Chuỗi ?id=657d57c2 lấy từ dist/manifest.json, đó là cache-busting khi nâng version Livewire.

1.4. Đường đi đầy đủ của một request

sequenceDiagram
  autonumber
  participant B as Trình duyệt
  participant D as CDN edge (POP gần user)
  participant N as nginx origin
  participant P as php-fpm (blue hoặc green)

  B->>N: GET / (HTML)
  N->>P: fastcgi_pass
  P-->>B: HTML, trong đó src trỏ https://cdn.example.com/build/...
  Note over B,N: HTML KHÔNG BAO GIỜ được cache ở CDN

  B->>D: GET /build/assets/app-CIomGrQN.js
  alt cache hit
    D-->>B: trả luôn từ edge, khoảng 10-30ms
  else cache miss
    D->>N: GET cùng đường dẫn, kèm Host của app
    N-->>D: file từ /srv/assets/public
Cache-Control: public, max-age=31536000, immutable D-->>B: trả file và giữ lại cho lần sau end B->>N: POST /livewire-9945d67f/update Note over B,N: request tương tác đi thẳng origin,
CDN không cache được (POST, có state, có CSRF) N->>P: fastcgi_pass P-->>B: JSON snapshot mới

Repo này đã sẵn sàng cho CDN ở phía origin. docker/production/nginx/app-common.conf serve /build/ từ volume /srv/assets/public (giữ public/ của mọi build đã chạy, phục vụ blue-green) kèm Cache-Control: public, max-age=31536000, immutable. Đó chính là bản hợp đồng mà CDN cần — không phải sửa gì thêm để bật pull CDN.

2. Lợi ích

Không lợi ích nào là vô điều kiện. Cột bên phải mới là phần interviewer muốn nghe.

Được gìChỉ thật khi
Giảm latency — file phục vụ từ POP gần userUser phân tán địa lý. User cùng thành phố với server thì gần như không đổi gì.
Giảm tải origin — nginx/php-fpm không phải phục vụ vài chục request tĩnh mỗi lượt xem trangTrang nhiều asset, traffic cao. Trang nội bộ 50 user thì thừa.
Băng thông rẻ hơn — giá egress của CDN thường thấp hơn của VPS/cloudLượng byte đủ lớn để bù phí cố định hằng tháng.
Chống burst — traffic viral đập vào edge, không vào originĐúng lúc cần nhất. Đây là lợi ích lớn nhất nhưng khó thấy lúc bình thường.
Có sẵn TLS, HTTP/3, nén Brotli, resize ảnh tại edgeTuỳ nhà cung cấp, không phải CDN nào cũng có.

3. Tác hại / hạn chế

4. Trade-off

4.1. Pull CDN (origin là app) vs Push lên S3/R2

Pull — CDN đi lấy từ nginx originPush — CI đẩy lên bucket, CDN trỏ bucket
Việc phải thêm khi deployKhông cóThêm bước upload trong CI
RollbackAsset build cũ vẫn nằm ở origin (volume giữ mọi build) → ổnPhải giữ file cũ trên bucket — tuyệt đối đừng sync --delete
Khi origin chếtEdge còn cache thì vẫn sống, miss thì 502Asset sống độc lập với origin
Hợp vớiSetup hiện tại của repoServerless, nhiều region, muốn tách hẳn asset khỏi app

Chọn gì cho repo này: Pull. Volume /srv/assets/public đã giữ public/ của mọi build cho blue-green, tức là điều kiện khó nhất (giữ asset của build cũ) đã có sẵn. Thêm bước push chỉ tạo thêm chỗ hỏng. Chuyển sang Push khi muốn asset sống được cả lúc origin down.

4.2. Cùng domain vs subdomain riêng

Cùng domain (app.com/build/* qua proxy)Subdomain riêng (cdn.app.com)
CORSKhông cầnPhải cấu hình cho font, và cả CSP
HandshakeDùng chung connection sẵn cóThêm DNS + TCP + TLS cho host mới
CookieCookie session bị gửi kèm mọi request asset — tốn byte, và edge phải cẩn thận không cache theo cookieCookie-less, sạch
Chọn khiCookie nhỏ, ít assetCookie lớn (>2 KB) hoặc >50 asset mỗi trang

4.3. Có nên đưa livewire.js lên CDN không?

File livewire.min.js nặng 238 KB và đang do php-fpm phục vụ. Ba lựa chọn:

CáchĐượcMất
Để mặc định (route PHP) Không phải làm gì, không bao giờ lệch version Mỗi lần tải chiếm một worker php-fpm
livewire:publish --assets, để nginx serve Hết chiếm worker, vẫn cùng origin nên không CORS Phải publish lại mỗi lần nâng version Livewire
Publish + livewire.asset_url trỏ CDN Nhẹ origin nhất Thêm rủi ro lệch version qua tầng cache CDN

Cách lười nhất mà vẫn tốt: chọn dòng giữa. Publish ra public/vendor/livewire/ rồi để nginx serve. Giải quyết đúng cái đau thật (chiếm worker php-fpm) mà không rước thêm tầng cache. Chỉ lên CDN khi user thật sự phân tán toàn cầu.

5. Khó khăn khi vận hành thực tế

# header CDN đang trả
curl -sI https://cdn.example.com/build/assets/app-CIomGrQN.js \
  | grep -iE 'cache-control|age|x-cache|cf-cache|access-control'

# header origin đang trả — lệch nhau nghĩa là CDN đang override
curl -sI https://app.example.com/build/assets/app-CIomGrQN.js | grep -i cache-control

# file build có mang tiền tố CDN không (kiểm tra pha build)
grep -o 'https://cdn[^"]*' public/build/assets/*.css | head

6. Case thực tế và cách xử lý

Case 1 — Rollback blue → green: "Failed to fetch dynamically imported module"

Triệu chứng: sau khi rollback, một số user click vào trang thì thấy trang trắng, console báo lỗi 404 khi tải chunk.

Nguyên nhân: trình duyệt (hoặc CDN) còn giữ HTML/entry JS của build mới, nhưng chunk lazy-load của build mới đã biến mất khỏi origin.

sequenceDiagram
  autonumber
  participant B as Trình duyệt
  participant D as CDN
  participant N as origin sau rollback
  B->>B: còn giữ HTML của build v2 trong cache
  B->>D: GET /build/assets/cart-9xY.js (chunk của v2)
  D->>N: cache miss
  N-->>D: 404 — origin đã quay về v1
  D-->>B: 404
  B->>B: Failed to fetch dynamically imported module → trang trắng

Xử lý từng bước:

  1. Kiểm tra chunk còn ở origin không: curl -I https://app.example.com/build/assets/cart-9xY.js
  2. Nếu 404 → đừng purge CDN. Purge sẽ xoá luôn bản đang chạy tốt và tạo cache-miss storm. Sửa ở origin: giữ public/build/ của N build gần nhất trong volume chung. Repo đã đi đúng hướng (volume /srv/assets/public giữ "public/ của mọi build đã chạy") — chỉ cần chắc rằng job dọn dẹp giữ tối thiểu 2-3 build.
  3. Lá chắn phía client cho phần còn sót lại — bắt lỗi và reload đúng một lần:
    window.addEventListener('vite:preloadError', (e) => {
      e.preventDefault();
      window.location.reload();
    });
  4. Phòng dài hạn: HTML không cache ở CDN, đặt Cache-Control: no-store hoặc max-age=0, must-revalidate.

Case 2 — Đổi ASSET_URL xong mà không thấy gì đổi (đã verify)

Chạy thật trong container:

docker exec -e ASSET_URL=https://cdn.example.com clicon-dev-app-1 \
  php artisan tinker --execute="
    echo 'env='.env('ASSET_URL').PHP_EOL;
    echo 'cfg='.config('app.asset_url').PHP_EOL;
    echo 'cached='.(file_exists(base_path('bootstrap/cache/config.php'))?'yes':'no').PHP_EOL;
  "
env=https://cdn.example.com
cfg=
cached=yes

env() có giá trị nhưng config('app.asset_url') rỗng, vì bootstrap/cache/config.php đang tồn tại — config:cache đã đóng băng giá trị từ lúc build image.

Xử lý:

  1. Không sửa .env trên container đang chạy rồi mong nó ăn. Phải config:clear && config:cache trong image mới.
  2. Quan trọng hơn: ASSET_URL phải có mặt lúc npm run build, vì base của Vite chốt tại pha build. Đặt biến ở stage build của Dockerfile, không phải chỉ ở runtime env.
  3. Kiểm tra nhanh xem build có ăn biến không: grep -o 'https://cdn[^"]*' public/build/assets/*.css | head — rỗng nghĩa là build chưa thấy biến.

Case 3 — Font 404 im lặng, chữ nhảy font sau khi bật CDN subdomain

Nguyên nhân: @font-face trong CSS trỏ https://cdn.example.com/...woff2, trang thì ở app.example.com → cross-origin, thiếu Access-Control-Allow-Origin. Trình duyệt bỏ font, im lặng dùng font dự phòng.

Xử lý — thêm vào location /build/ trong docker/production/nginx/app-common.conf:

location /build/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "*";
    add_header Vary "Origin";
    access_log off;
    try_files $uri =404;
}

Bẫy nginx: add_header trong một block sẽ xoá sạch mọi add_header kế thừa từ block cha. Phải liệt kê đủ trong cùng một block. Sau khi sửa, nhớ purge riêng đường dẫn font vì header cũ (thiếu CORS) đã bị CDN cache lại rồi.

Case 4 — Livewire chết trên production sau khi nâng version

Nguyên nhân: chạy composer update livewire/livewire nhưng quên livewire:publish --assets. CDN vẫn serve livewire.min.js bản cũ, còn PHP phía server đã là bản mới → payload protocol lệch nhau, component không update được. Livewire tự in cảnh báo (usePublishedAssetsIfAvailable() so sánh hai manifest rồi console.warn) nhưng chẳng ai nhìn console lúc đang cháy.

Xử lý: đưa vào CI ngay sau composer install:

php artisan livewire:publish --assets --force

để ?id= làm việc của nó — Livewire đã tự gắn hash lấy từ dist/manifest.json. Điều kiện là CDN phải cache theo full URL kèm query string; một vài CDN mặc định strip query, phải kiểm tra.

Case 5 — Ảnh user upload không đi CDN

Nguyên nhân: asset() đổi gốc, nhưng Storage::url() thì không. Trong repo, config/filesystems.php gắn cứng vào APP_URL:

'public' => [
    'driver' => 'local',
    'root' => storage_path('app/public'),
    'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
    ...
],

Xử lý: sửa key url của disk (hoặc chuyển disk sang s3 và set AWS_URL trỏ CDN).

Đừng đặt immutable cho ảnh upload. URL ảnh upload không có content hash, user thay ảnh thì URL giữ nguyên. Dùng max-age=86400 rồi purge khi có thay đổi, hoặc thêm ?v={updated_at} vào URL để tự cache-bust.

7. Câu hỏi phụ interviewer hay hỏi tiếp

"CDN có làm Livewire nhanh hơn không?"
Rất ít. Mỗi wire:click là một POST tới /livewire-{hash}/update ở origin — CDN không cache được (POST, có state, có CSRF). CDN chỉ giúp lần tải trang đầu. Muốn nhanh phần tương tác thì phải tối ưu origin: số worker PHP-FPM, query, và dùng wire:model.blur / .live.debounce để giảm số round-trip.

"Vì sao dám để immutable một năm?"
Vì tên file mang content hash do Vite sinh. Nội dung đổi → tên đổi → URL đổi. Một URL không bao giờ mang nghĩa khác. Với file không hash thì immutable là cái bẫy tự đặt cho mình.

"Deploy xong purge cache thế nào cho đúng?"
Không purge gì cả cho /build/. Content hash đã làm cache-busting rồi; purge chỉ tốn tiền và tạo cache-miss storm dội về origin. Chỉ purge khi sửa file không có hash: favicon, robots.txt, ảnh upload.

"Tách asset sang subdomain có luôn nhanh hơn không?"
Không. Domain mới tốn thêm DNS + TCP + TLS. Với HTTP/2 dùng chung một connection, gộp chung domain thường nhanh hơn cho site nhỏ. Tách khi cookie lớn, hoặc khi muốn asset sống độc lập với origin.

"CDN của bạn có cache HTML không?"
Không. HTML mang CSRF token và nội dung riêng theo user. Cache HTML dùng chung sẽ gây 419 Page Expired hàng loạt và làm rò session giữa các user.

"Đổi APP_KEY thì sao?"
Livewire 4 sinh prefix endpoint từ APP_KEY (/livewire-9945d67f). Đổi key là đổi path. Mọi rule CDN/WAF/firewall viết cứng /livewire/* sẽ hết tác dụng — dùng pattern /livewire-*/, hoặc lấy path động từ data-update-uri.

"Làm sao biết CDN đang hoạt động?"
So header giữa edge và origin. Ở edge phải thấy Age tăng dần và X-Cache: HIT (hoặc CF-Cache-Status: HIT). Nếu Age: 0 mãi thì mọi request đang xuống origin — thường do header Set-Cookie hoặc Vary quá rộng làm CDN từ chối cache.

Chưa verify được: phần hành vi của CDN thật (tỉ lệ hit, độ trễ purge, việc CDN override header) — môi trường ở đây không có CDN để chạy thử. Mọi thứ thuộc Laravel / Livewire / nginx phía origin trong bài đều đã chạy thật trong container clicon-dev-app-1 và output được dán nguyên văn.