Stop Bundling Assets di Docker Image: Static Assets dengan S3 + Cloudflare CDN
April 30, 2026
Kelola static assets dengan AWS S3, Cloudflare CDN, dan GitHub Actions — lengkap dengan integrasi Vue, Nuxt, React, dan Next.js.
Selama ini, cara paling umum serve static assets (CSS, JavaScript, fonts, images) di aplikasi containerized adalah: bundle semuanya ke dalam Docker image, lalu serve via Nginx. Simple, tapi ada beberapa masalah yang mulai terasa seiring aplikasi berkembang.
Image size membengkak. Assets yang seharusnya jarang berubah (vendor libraries, fonts) ikut rebuild setiap ada perubahan kode. Cache tidak optimal. Assets di-serve dari app server yang mungkin tidak punya CDN. Deploy lebih lambat karena setiap deploy harus push image baru yang berisi assets lama + kode baru.
Solusinya: pisahkan static assets dari application code. Simpan di S3, serve via Cloudflare CDN, dan otomatisasi uploadnya lewat CI/CD pipeline.
Arsitektur Overview
Flow-nya sederhana:
codeCopyBrowser
└── Cloudflare CDN (cache layer)
└── AWS S3 (origin storage)
App Server (terpisah)
└── hanya serve HTML + API
└── referensi assets via URL ke Cloudflare
Peran masing-masing komponen:
- AWS S3 — object storage untuk semua static assets. Murah, reliable, dan bisa di-configure sebagai static origin.
- Cloudflare — CDN yang sit di depan S3. Assets di-cache di edge nodes Cloudflare di seluruh dunia, jadi latency rendah untuk user di manapun.
- GitHub Actions — otomatisasi upload assets ke S3 setiap kali ada perubahan, tanpa perlu manual.
Setup AWS S3
Buat Bucket
Naming convention yang aku pakai: {company}-static-{environment}. Contoh: mycompany-static-production dan mycompany-static-staging.
Pilih region yang dekat dengan mayoritas user — untuk Indonesia, ap-southeast-1 (Singapore) pilihan yang paling umum.
Folder Structure
Struktur folder diorganisir per project dan environment:
codeCopymycompany-static-production/
├── project-a/
│ ├── v1.0.0/ # versioned assets (immutable)
│ │ ├── main.a1b2c3.js
│ │ ├── main.d4e5f6.css
│ │ └── vendor.g7h8i9.js
│ └── manifest.json # pointer ke versi terbaru
├── project-b/
│ ├── v2.1.0/
│ └── manifest.json
└── shared/
└── fonts/ # shared assets antar project
Bucket Policy
S3 bucket perlu bisa diakses publik oleh Cloudflare. Tapi jangan buka ke semua IP — restrict hanya ke Cloudflare IP ranges:
jsonCopy{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudflareOnly",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::mycompany-static-production/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": [
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22"
]
}
}
}
]
}
Catatan: Cloudflare IP ranges bisa berubah. Cek versi terbaru di cloudflare.com/ips.
CORS Configuration
Karena domain app berbeda dengan domain assets, S3 perlu CORS config:
jsonCopy[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedOrigins": [
"https://app.example.com",
"https://staging.example.com"
],
"ExposeHeaders": [],
"MaxAgeSeconds": 86400
}
]
Hindari AllowedOrigins: ["*"] di production — eksplisit daftarkan domain yang boleh akses.
Setup Cloudflare
DNS — CNAME ke S3
Di Cloudflare DNS, tambahkan CNAME record:
codeCopyType: CNAME
Name: assets # → assets.example.com
Target: mycompany-static-production.s3.ap-southeast-1.amazonaws.com
Proxy: ✅ Proxied (orange cloud)
Dengan proxy aktif, semua request ke assets.example.com lewat Cloudflare — caching, DDoS protection, dan edge delivery aktif.
Cache Rules
Ini bagian paling penting. Strategi cache dibagi dua berdasarkan tipe file:
Rule 1 — Versioned/hashed assets (immutable):
codeCopyIf URL path matches: /*/v*/*
OR filename matches regex: \.[a-f0-9]{6,}\.(js|css|woff2?)$
Then:
Cache Level: Cache Everything
Edge Cache TTL: 1 year
Browser Cache TTL: 1 year
Rule 2 — manifest.json dan file non-hashed:
codeCopyIf URL path ends with: manifest.json
Then:
Cache Level: Cache Everything
Edge Cache TTL: 5 minutes
Browser Cache TTL: no-store
Kenapa manifest.json TTL pendek? Karena ini file yang jadi “pointer” ke asset versi terbaru. Kalau di-cache lama, aplikasi bisa baca manifest lama dan load asset yang salah.
Cache Invalidation (Opsional)
Kalau sudah konsisten pakai content hashing, cache invalidation Cloudflare praktis tidak diperlukan — setiap deploy menghasilkan filename baru, jadi Cloudflare otomatis fetch dari S3. Yang perlu di-purge hanya manifest.json, itupun sudah di-handle oleh TTL pendek di atas.
Kalau tetap ingin purge manual via API (misalnya untuk manifest.json setelah deploy):
bashCopycurl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {cf_api_token}" \
-H "Content-Type: application/json" \
--data '{
"files": [
"https://assets.example.com/project-a/manifest.json"
]
}'
Content Hashing & Versioning Strategy
Ini fondasi dari seluruh sistem ini. Kenapa content hash lebih baik dari query string (?v=1.2.3)?
| Pendekatan | Contoh | Cache behavior |
|---|---|---|
| No versioning | main.js |
Browser bisa cache selamanya, sulit di-bust |
| Query string | main.js?v=1.2.3 |
Beberapa proxy/CDN abaikan query string |
| Content hash | main.a1b2c3.js |
Filename berbeda = cache miss otomatis ✅ |
Sebagian besar build tool modern sudah generate content hash secara default. Untuk Vite:
javascriptCopy// vite.config.js
export default {
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
}
manifest.json
Setelah build, generate manifest.json yang memetakan nama file asli ke nama file yang sudah di-hash:
jsonCopy{
"version": "1.0.0",
"buildTime": "2026-04-20T08:00:00Z",
"files": {
"main.js": "assets/main.a1b2c3.js",
"main.css": "assets/main.d4e5f6.css",
"vendor.js": "assets/vendor.g7h8i9.js"
},
"baseUrl": "https://assets.example.com/project-a/"
}
Aplikasi baca manifest ini saat build atau runtime untuk tahu URL asset yang benar.
CI/CD Pipeline dengan GitHub Actions
IAM User untuk CI/CD
Buat IAM user khusus dengan permission minimal:
jsonCopy{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:PutObjectAcl",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::mycompany-static-production",
"arn:aws:s3:::mycompany-static-production/project-a/*"
]
}
]
}
Scope per project — CI/CD untuk project-a tidak bisa touch folder project-b.
Workflow
yamlCopyname: Deploy Static Assets
on:
push:
branches: [main]
paths:
- 'src/**'
- 'public/**'
- 'package.json'
env:
AWS_REGION: ap-southeast-1
S3_BUCKET: mycompany-static-production
PROJECT: project-a
ASSETS_VERSION: ${{ github.sha }}
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
jobs:
deploy-assets:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build assets
run: npm run build
env:
VITE_ASSETS_BASE_URL: https://assets.example.com/${{ env.PROJECT }}/
- 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 }}
# Upload versioned/hashed assets — cache aggressively
- name: Upload hashed assets to S3
run: |
aws s3 sync dist/assets/ \
s3://${{ env.S3_BUCKET }}/${{ env.PROJECT }}/v${{ env.ASSETS_VERSION }}/assets/ \
--cache-control "public, max-age=31536000, immutable" \
--content-encoding identity \
--exclude "*.map" \
--no-progress
# Upload manifest — no cache
- name: Upload manifest.json to S3
run: |
aws s3 cp dist/manifest.json \
s3://${{ env.S3_BUCKET }}/${{ env.PROJECT }}/manifest.json \
--cache-control "no-store, must-revalidate" \
--content-type "application/json" \
--no-progress
# Optional: purge manifest.json dari Cloudflare cache
- name: Purge manifest.json from Cloudflare (optional)
run: |
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/${{ env.CF_ZONE_ID }}/purge_cache" \
-H "Authorization: Bearer ${{ env.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{
"files": ["https://assets.example.com/${{ env.PROJECT }}/manifest.json"]
}'
Tentang
--deleteflag: Jangan pakai--deletedis3 syncuntuk versioned assets — file lama masih dibutuhkan oleh user yang sedang aktif menggunakan aplikasi. Buat scheduled cleanup script tersendiri yang hapus folder lebih dari N hari.
Integrasi di Frontend
Ini bagian yang paling relevan untuk tim frontend. Domain app (app.example.com) berbeda dengan domain assets (assets.example.com), jadi ada beberapa hal yang perlu dikonfigurasi.
Vue (Vite)
javascriptCopy// vite.config.js
export default defineConfig({
base: process.env.VITE_ASSETS_BASE_URL || '/',
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
})
bashCopy# .env.production
VITE_ASSETS_BASE_URL=https://assets.example.com/project-a/v${COMMIT_SHA}/
Vite akan otomatis prefix semua asset URL dengan base. Tidak ada perubahan di kode Vue — semua import tetap seperti biasa.
Nuxt
javascriptCopy// nuxt.config.ts
export default defineNuxtConfig({
app: {
cdnURL: process.env.NUXT_APP_CDN_URL,
},
})
bashCopy# .env.production
NUXT_APP_CDN_URL=https://assets.example.com/project-a/
cdnURL di Nuxt akan prefix semua asset yang di-generate (JS, CSS, images dari public/) dengan URL tersebut. Untuk assets yang di-reference secara dinamis:
vueCopy<script setup>
const cdnBase = useRuntimeConfig().app.cdnURL
const logoUrl = `${cdnBase}images/logo.png`
</script>
React (Vite)
Identik dengan Vue — cukup set base di vite.config.js:
javascriptCopy// vite.config.js
export default defineConfig({
base: import.meta.env.VITE_ASSETS_BASE_URL || '/',
})
bashCopy# .env.production
VITE_ASSETS_BASE_URL=https://assets.example.com/project-a/
Untuk referensi asset dinamis di dalam komponen:
jsxCopyconst assetBase = import.meta.env.VITE_ASSETS_BASE_URL || ''
function Avatar({ filename }) {
return <img src={`${assetBase}images/${filename}`} alt="avatar" />
}
Next.js
Next.js punya config khusus untuk ini:
javascriptCopy// next.config.js
module.exports = {
assetPrefix: process.env.NEXT_PUBLIC_ASSETS_URL || '',
}
bashCopy# .env.production
NEXT_PUBLIC_ASSETS_URL=https://assets.example.com/project-a
assetPrefix akan prefix semua assets yang di-serve Next.js (/_next/static/). Untuk assets di folder public/ atau yang di-reference secara manual:
jsxCopyconst assetsBase = process.env.NEXT_PUBLIC_ASSETS_URL || ''
export default function Page() {
return (
<img
src={`${assetsBase}/images/hero.png`}
alt="hero"
/>
)
}
Catatan Next.js:
assetPrefixhanya berlaku untuk assets yang di-bundle (_next/static). File di folderpublic/tetap di-serve dari domain app — perlu di-handle manual kalau mau ikut di-CDN-kan.
Generic / Common App
Untuk aplikasi yang tidak pakai framework modern, atau vanilla JS:
javascriptCopy// config.js — baca dari environment atau window global
const CONFIG = {
assetsBase: window.__ASSETS_BASE_URL__ || 'https://assets.example.com/project-a/',
}
// Inject saat server render HTML (bisa dari Nginx sub_filter atau app server)
// <script>window.__ASSETS_BASE_URL__ = "https://assets.example.com/project-a/";</script>
// Penggunaan
function loadScript(filename) {
const script = document.createElement('script')
script.src = `${CONFIG.assetsBase}${filename}`
document.head.appendChild(script)
}
Atau via manifest.json fetch:
javascriptCopyasync function initApp() {
const manifest = await fetch('https://assets.example.com/project-a/manifest.json')
.then(r => r.json())
// Load assets berdasarkan manifest
const mainScript = manifest.files['main.js']
loadScript(`${manifest.baseUrl}${mainScript}`)
}
CORS di Sisi Aplikasi
Untuk assets seperti fonts yang di-load via CSS (@font-face), browser enforce CORS. Pastikan request punya header yang benar:
htmlCopy<!-- Untuk font preload, tambahkan crossorigin -->
<link
rel="preload"
href="https://assets.example.com/shared/fonts/inter.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
Dan di kode fetch kalau perlu load asset secara programmatic:
javascriptCopyfetch('https://assets.example.com/project-a/manifest.json', {
mode: 'cors',
credentials: 'omit', // assets adalah public resource
})
Gotcha & Lesson Learned
Content-Type header wajib benar saat upload.
aws s3 sync biasanya detect content-type otomatis, tapi kadang salah — terutama untuk .woff2 dan .map files. Kalau salah, browser bisa refuse untuk load font atau JavaScript.
bashCopy# Eksplisit set content-type untuk font
aws s3 cp fonts/ s3://bucket/shared/fonts/ \
--recursive \
--content-type "font/woff2" \
--include "*.woff2"
Jangan pakai --delete sembarangan.
aws s3 sync --delete akan hapus file di S3 yang tidak ada di local. Berbahaya kalau user masih aktif menggunakan versi lama. Buat retention script terpisah:
bashCopy# Hapus folder assets yang lebih dari 30 hari
aws s3 ls s3://mycompany-static-production/project-a/ \
| awk '{print $2}' \
| while read folder; do
# cek umur folder, hapus kalau sudah tua
done
Cloudflare cache propagation tidak instan. Setelah purge, ada delay ~5–30 detik sebelum semua edge node terupdate. Jangan ekspektasi purge langsung efektif — ini normal.
S3 Static Website Hosting vs Direct S3 URL.
Aku pilih tidak pakai S3 Static Website Hosting karena tidak support HTTPS secara native. Pakai direct S3 URL (s3.amazonaws.com) + Cloudflare sebagai HTTPS terminator — lebih clean.
Penutup
Memisahkan static assets dari Docker image bukan hanya soal performa — ini soal arsitektur yang lebih bersih. Image Docker lebih kecil, deploy lebih cepat, cache lebih optimal, dan assets bisa di-update tanpa harus redeploy aplikasi.
Setup ini memang butuh effort di awal, tapi setelah jalan, hampir zero maintenance. Build tool modern sudah handle content hashing, GitHub Actions handle upload, Cloudflare handle delivery — semua otomatis.
Referensi:
