Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

AWS CloudFront CDN Setup for Laravel Assets

By Kokil Thapa | Last reviewed: August 2026

Serving static assets directly from your Laravel application server creates a bottleneck that degrades performance as traffic grows. A proper AWS CloudFront CDN setup for Laravel assets offloads CSS, JavaScript, images, and fonts to edge locations worldwide while keeping your origin secure. This guide walks through the complete production configuration using S3 as the origin with Origin Access Control, ensuring your private bucket remains inaccessible except through CloudFront.

I have implemented this exact architecture across multiple production Laravel applications, including legal-tech portals and eCommerce platforms where asset performance directly impacts user experience and conversion rates. For teams evaluating whether custom infrastructure makes sense versus managed alternatives, understanding these fundamentals helps inform decisions about hiring Laravel expertise or managing deployments internally. The pattern described here works identically whether you are deploying from Kathmandu or anywhere else globally.

How do you configure AWS CloudFront CDN setup for Laravel assets with S3?

The foundation of a secure CloudFront distribution is ensuring your S3 bucket never allows public access. In 2026, AWS Origin Access Control (OAC) has fully replaced the legacy Origin Access Identity (OAI) method for new distributions. OAC uses IAM policy conditions tied to the specific CloudFront distribution ID, providing tighter security boundaries than the older service principal approach.

Create the private S3 bucket

Your asset bucket must block all public access at the account and bucket level. Create it via CLI or console with Block Public Access enabled:

aws s3api create-bucket \
  --bucket my-laravel-assets-prod \
  --region ap-south-1 \
  --create-bucket-configuration LocationConstraint=ap-south-1

aws s3api put-public-access-block \
  --bucket my-laravel-assets-prod \
  --public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

Never enable static website hosting on this bucket. That feature is designed for public websites and conflicts with OAC's security model. Your assets will be served exclusively through CloudFront.

Configure the OAC bucket policy

After creating your CloudFront distribution (covered below), attach this bucket policy replacing the placeholder distribution ID:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowCloudFrontServicePrincipalReadOnly",
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::my-laravel-assets-prod/*",
            "Condition": {
                "StringEquals": {
                    "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/EDFDVBD6EXAMPLE"
                }
            }
        }
    ]
}

This policy grants read access only when requests originate from your specific CloudFront distribution. Direct S3 URL access returns 403 Forbidden even if someone discovers the bucket name. I have seen this prevent accidental data exposure on client projects where developers temporarily disabled public access blocks during debugging and forgot to re-enable them.

User BrowserGlobal EdgeCloudFront DistributionCache + WAF + CompressionOAC Signed RequestsHTTPS OnlyS3 Bucket (Private)No Public AccessOAC Policy EnforcedLaravel App ServerHTML + API Only
Request flow for AWS CloudFront CDN setup for Laravel assets: users hit edge caches, CloudFront fetches from private S3 via OAC, Laravel serves only dynamic content

Create the CloudFront distribution with OAC

Use the AWS CLI to create a distribution configured for Laravel's versioned asset structure. Save this JSON as cf-config.json:

{
    "CallerReference": "laravel-assets-prod-2026",
    "Origins": {
        "Quantity": 1,
        "Items": [{
            "Id": "S3-my-laravel-assets-prod",
            "DomainName": "my-laravel-assets-prod.s3.ap-south-1.amazonaws.com",
            "S3OriginConfig": {
                "OriginAccessIdentity": ""
            },
            "OriginAccessControlId": "YOUR_OAC_ID"
        }]
    },
    "DefaultCacheBehavior": {
        "TargetOriginId": "S3-my-laravel-assets-prod",
        "ViewerProtocolPolicy": "redirect-to-https",
        "AllowedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]},
        "CachedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]},
        "Compress": true,
        "CachePolicyId": "CACHE_POLICY_ID_FOR_VERSIONED_ASSETS",
        "ResponseHeadersPolicyId": "SECURITY_HEADERS_POLICY_ID"
    },
    "PriceClass": "PriceClass_All",
    "Enabled": true,
    "HttpVersion": "http2and3"
}

Create the OAC first using aws cloudfront create-origin-access-control, then reference its ID above. After deployment, update your Laravel .env:

ASSET_URL=https://d1234abcdef.cloudfront.net
AWS_BUCKET=my-laravel-assets-prod
AWS_DEFAULT_REGION=ap-south-1

What cache policy settings work best for Laravel Vite assets?

Laravel Vite generates content-hashed filenames like app-a1b2c3d4.js. When file contents change, the hash changes, creating a new URL. This immutability lets you set aggressive caching without risking stale assets. Getting this wrong is one of the most common mistakes I encounter when auditing production setups.

Create a custom cache policy

Do not use the managed CachingOptimized policy. It includes query strings in the cache key, which wastes cache space since Laravel's hashed assets never use meaningful query parameters. Create a custom policy:

  • TTL: Minimum 1 year, Default 1 year, Maximum 1 year
  • Cache Key Parameters: None (ignore query strings and headers)
  • Compression: Enable Brotli and Gzip
  • Accept-Encoding Header: Include in cache key (required for compression negotiation)

This configuration means every unique path gets cached indefinitely at edge locations. When you deploy new assets with different hashes, old files simply stop being requested and eventually expire from cache naturally.

Handle non-versioned files separately

Files like favicon.ico, robots.txt, or PWA manifests don't receive content hashes. These need shorter TTLs. Create a second cache behavior in your distribution matching paths like /favicon.ico or /*.txt with a TTL of 1 day, ordered before the default behavior. Alternatively, move these files to your Laravel public directory and serve them from your application server if they change frequently.

Incoming Asset RequestHas Content Hash in Filename?YesNoVersioned Asset PolicyTTL: 1 Year ImmutableIgnore Query StringsBrotli + Gzip EnabledNon-Versioned PolicyTTL: 1 Day MaxValidate on Each RequestPath Pattern Match RequiredExample: /build/app-a1b2c3.jsExample: /favicon.ico, /manifest.json
Cache policy routing: versioned Laravel Vite assets get immutable year-long caching while non-versioned files use short TTLs with validation

How do you integrate CloudFront with Laravel Vite builds?

Vite's build process and Laravel's filesystem abstraction must align with your CloudFront configuration. Misalignment here causes broken assets after deployment or forces unnecessary cache invalidations.

Configure Vite for CDN output

In your vite.config.js, ensure the base path matches what CloudFront expects:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
    build: {
        manifest: true,
        outDir: 'public/build',
    },
});

You do not need to set base in Vite config when using Laravel's ASSET_URL. Laravel's @vite directive automatically prepends the asset URL from your environment. Setting both causes double-prefixing errors that break asset loading.

Upload assets during deployment

After running npm run build, sync the public/build directory to S3. Using Deployer 7, which I use for most production Laravel deployments including several sister sites on shared EC2 infrastructure:

task('deploy:assets', function () {
    runLocally('cd {{release_path}} && npm ci && npm run build');
    runLocally('aws s3 sync public/build s3://my-laravel-assets-prod/build --delete --cache-control "public,max-age=31536000,immutable"');
});

The --delete flag removes old hashed files that are no longer referenced. The explicit Cache-Control header reinforces the CloudFront cache policy at the origin level, providing defense in depth. For teams comparing deployment approaches, my article on CI/CD pipeline setup covers alternative strategies.

Verify the integration

After deployment, inspect your page source. Asset tags should reference your CloudFront domain:

<link rel="preload" as="style" href="https://d1234abcdef.cloudfront.net/build/assets/app-a1b2c3d4.css" />
<script type="module" src="https://d1234abcdef.cloudfront.net/build/assets/app-e5f6g7h8.js"></script>

If URLs still point to your application domain, verify that ASSET_URL is set correctly in the deployed environment and that you cleared Laravel's config cache with php artisan config:cache.

How does CloudFront compare to other CDN options for Laravel?

Choosing the right CDN depends on your traffic patterns, budget, and operational capacity. Having deployed Laravel applications across multiple providers for clients in Nepal and internationally, I have practical experience with the trade-offs each option presents.

CriteriaAWS CloudFrontCloudflareBunnyCDN
Setup ComplexityHigh (IAM, OAC, policies)Low (DNS proxy)Medium (pull zone)
Private S3 SupportNative OACR2 or authenticated pullAuthenticated pull only
Free Tier1 TB/month, 10M requestsGenerous free plan$1 trial credit
Nepal Edge PresenceKolkata + MumbaiKathmandu PoPMumbai + Delhi
Invalidation CostFirst 1,000/month freeInstant purge includedIncluded
Best ForAWS-native, high-securityFull-site accelerationBudget-conscious, simple assets

For Laravel applications already on AWS infrastructure, CloudFront provides the tightest integration and lowest latency between S3 and edge. If you are serving a full site (not just assets) and want DDoS protection plus DNS management, Cloudflare's proxy mode simplifies operations significantly. BunnyCDN offers excellent price-to-performance for pure asset delivery when budget constraints matter more than ecosystem integration.

On legal-tech portals handling sensitive document workflows, I consistently choose CloudFront with OAC because the security boundary is auditable and compliant. For marketing sites or directories where assets are less sensitive, Cloudflare's ease of use often wins. Understanding these trade-offs helps when planning website development budgets that include ongoing infrastructure costs.

Nepal UsersKathmandu / PokharaCloudFrontKolkata ~30msCloudflareKTM PoP ~5msBunnyCDNMumbai ~50msBest: AWS IntegrationPrivate S3 + OACHighest SecurityBest: Lowest LatencyLocal KTM EdgeSimplest SetupBest: Budget AssetsLow Cost per GBGood Enough Speed
Edge proximity comparison for Nepal-based Laravel users: Cloudflare offers lowest latency via Kathmandu PoP, CloudFront balances security with acceptable regional performance, BunnyCDN provides budget-friendly delivery via India

How do you troubleshoot common CloudFront and Laravel asset issues?

Even with correct configuration, production issues arise. These are the problems I encounter most frequently when maintaining Laravel applications with CloudFront.

Assets return 403 after deployment

This usually indicates an OAC misconfiguration. Verify these in order:

  1. The bucket policy references the correct distribution ARN (not a wildcard)
  2. OAC is attached to the distribution's origin settings
  3. The bucket has Block Public Access enabled (required for OAC)
  4. You waited 5-10 minutes after policy changes for propagation

Check CloudWatch logs for the distribution. The sc-status field shows origin response codes. A 403 from S3 with a 403 to viewer confirms permission issues rather than missing files.

Old assets persist after redeployment

If users see outdated CSS or JS despite new hashes, check these:

  • Build output committed: Ensure npm run build ran before S3 sync and the manifest updated
  • Laravel cache cleared: Run php artisan config:cache and php artisan view:cache after deploy
  • Browser cache: Hard refresh or test in incognito to rule out local caching
  • Wrong manifest: Verify the deployed public/build/manifest.json contains new hashes

Never rely on CloudFront invalidations for routine deployments. They cost money, take time, and indicate a broken asset versioning strategy. With proper Vite hashing, invalidations should only occur for emergency rollbacks.

CORS errors on fonts or WebGL assets

Browsers enforce CORS for certain asset types even when served from the same CDN. Attach a Response Headers Policy to your distribution including:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD
Access-Control-Max-Age: 31536000

For stricter security, replace * with your application domain. This is especially relevant for legal-tech portals where document viewers may load PDF.js workers or signature pad libraries from the CDN.

Finalizing Your AWS CloudFront CDN Setup for Laravel Assets

A correctly configured AWS CloudFront CDN setup for Laravel assets delivers measurable performance improvements while maintaining security boundaries that protect your origin storage. The combination of private S3 buckets with OAC, immutable cache policies for versioned Vite outputs, and automated deployment synchronization creates a system that scales without manual intervention. Start with the architecture outlined here, validate each layer independently, and monitor CloudWatch metrics during your first production week to catch edge cases specific to your traffic patterns.

If you need help implementing this setup or optimizing an existing Laravel deployment, reach out to discuss your project requirements.

Frequently Asked Questions

It configures a global content delivery network to cache and serve your Laravel application's static files like CSS, JavaScript, and images from edge locations, reducing origin server load and improving page load times worldwide.

The free tier includes 1TB monthly transfer. Beyond that, expect roughly USD 0.085 per GB. For most Nepal-based SME sites, monthly bills stay under NPR 1,500 (~USD 11) unless serving heavy video or high-traffic media.

Always use CloudFront for production. Direct S3 URLs lack caching, have higher latency, and expose bucket structure. CloudFront adds SSL, compression, geo-restriction, and significantly faster global delivery for Laravel asset manifests.

Set ASSET_URL in your .env file to your CloudFront distribution domain like https://d1234abcdef.cloudfront.net. Laravel's asset() helper automatically prefixes this URL. Ensure you run npm run build after deployment so Vite generates correct hashed filenames matching your S3 upload.

Not if you use Vite or Mix with content hashing. Since filenames change on every build (e.g., app-a1b2c3.js), old cached files become irrelevant naturally. Only invalidate when changing non-hashed files like robots.txt, favicon.ico, or root index.html. Invalidation costs USD 0.005 per path after the first 1,000 free monthly requests.

Create an Origin Access Control or use a custom header secret between CloudFront and S3. Configure your S3 bucket policy to deny all requests except those containing the specific header value CloudFront sends. This prevents users from bypassing the CDN and accessing S3 directly, which could increase costs and expose private assets.

Yes. Laravel Vapor provisions CloudFront automatically during deployment. With Forge, you manually create the distribution and set ASSET_URL. On self-managed Ubuntu servers using Deployer 7, I typically script the S3 sync and environment variable update within the deploy task to ensure assets and config stay synchronized across releases.

Check three things: S3 bucket block public access setting must be enabled but allow CloudFront OAC, verify the CloudFront distribution's origin request policy includes required headers, and confirm your S3 bucket policy explicitly grants s3:GetObject to the CloudFront service principal. Missing any one causes silent 403 errors at the edge.

Enable both in the CloudFront distribution cache behavior settings under Compression. You do not need to pre-compress files in S3; CloudFront compresses dynamically at the edge. Verify response headers show Content-Encoding: br or gzip. Text-based Laravel assets like CSS and JS typically shrink 60-80%, dramatically improving Core Web Vitals scores.

Set default TTL to 1 year (31536000 seconds) for hashed assets since filenames change on rebuild. For non-hashed files, use 24 hours or less. Configure this in CloudFront cache behaviors using path patterns like /build/assets/ for long cache and / for shorter defaults. Long TTLs maximize edge hit ratios and reduce origin fetches.

Inspect X-Cache response headers: Miss indicates origin fetch, Hit means served from edge. Enable CloudFront real-time logs or standard access logs to analyze miss reasons. Common causes include missing cache key parameters, varying Accept-Encoding headers, or query strings not whitelisted in cache policy. Use curl -I to test individual asset responses quickly.

Generally no. CloudFront excels at immutable static assets. Dynamic Blade-rendered HTML contains user-specific data and CSRF tokens that break with aggressive caching. If you need full-page caching, use Laravel's built-in cache middleware or a dedicated reverse proxy like Varnish. Reserve CloudFront strictly for /build/, /images/, /fonts/, and similar static paths.

CloudFront offers deepest AWS integration and lowest latency to S3 origins. BunnyCDN often provides simpler setup and lower pricing for pure CDN needs outside AWS ecosystem. On projects already using S3 for media storage, CloudFront avoids egress fees. For standalone Laravel apps on DigitalOcean or Hetzner, BunnyCDN may offer better price-to-performance ratio.

Yes, and it must be requested in us-east-1 region regardless of your app's primary region. CloudFront only accepts certificates from ACM us-east-1 or imported IAM certs. Request wildcard cert for flexibility. Validation takes minutes via DNS. Without valid SSL, CloudFront returns 502 errors. Let's Encrypt certs cannot be used directly with CloudFront distributions.

Add Access-Control-Allow-Origin header in CloudFront response headers policy, not S3 bucket CORS config. Set value to your Laravel app domain or for public fonts. Apply policy to font path pattern like /fonts/. Browsers block cross-origin font loading silently without proper CORS headers, causing invisible fallback fonts and layout shifts affecting CLS metrics.

Share this article

Quick Contact Options
Choose how you want to connect me: