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 S3 for Laravel File Storage Complete Setup

By Kokil Thapa | Last reviewed: September 2026

Your Laravel app outgrew local disk. Uploads fill the server, backups get painful, and horizontal scaling needs shared storage. An AWS S3 for Laravel file storage complete setup moves uploads, exports, and media to durable object storage while your app stays stateless. Laravel 13 ships with Flysystem 3 and the league/flysystem-aws-s3-v3 adapter, so S3 is a first-class disk—not a bolt-on. This guide walks through IAM, environment config, uploads, private downloads, and the production mistakes I see on real client projects. For broader context on mixing disks, see our guide on Laravel file uploads with S3, R2, and local storage.

How do you configure AWS S3 for Laravel file storage?

S3 setup in Laravel has four layers: AWS bucket, IAM credentials, Laravel disk config, and application code. Skip any layer and uploads fail with vague 403 errors at 2 a.m.

Start in the AWS console. Create a bucket in a region close to your users or EC2 instance. For Nepal-based apps hosted in ap-south-1 (Mumbai), that region cuts latency versus us-east-1. Enable block public access unless every object must be world-readable. Versioning helps when someone overwrites a legal document or product image by mistake.

Install the S3 Flysystem adapter

Laravel 13 requires PHP 8.3 or higher. The S3 adapter is not bundled by default. Install it with Composer 2.10:

composer require league/flysystem-aws-s3-v3 "^3.0" --with-all-dependencies
php artisan about

Confirm PHP 8.3+ and Laravel 13.x in the output. If you still run Laravel 12 on PHP 8.2, the same package and config apply; only the framework minimum differs.

Configure the s3 disk in config/filesystems.php

Laravel ships a default s3 disk stub. Open config/filesystems.php and verify it reads environment variables:

'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'url' => env('AWS_URL'),
        'endpoint' => env('AWS_ENDPOINT'),
        'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
        'throw' => false,
        'report' => false,
    ],
],

Set 'throw' => true during initial setup. Silent failures hide credential typos. Switch back to false in production if you prefer soft failures with logging.

Set environment variables

Add these keys to .env and your deployment secrets store:

FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=your-app-uploads
AWS_URL=https://your-app-uploads.s3.ap-south-1.amazonaws.com

Never commit .env to Git. On Deployer or GitLab CI pipelines I maintain, these values live in shared .env on the server. The same pattern works across sister legal-tech sites on shared EC2 infrastructure.

Laravel S3 Storage ArchitectureLaravel AppController / JobStorage FacadeFlysystem 3AWS S3 BucketObjects + ACLsDatabase stores path onlyusers.avatar = uploads/2026/09/photo.jpgBinary lives in S3, not MySQL
AWS S3 for Laravel file storage: the app stores paths in the database and bytes in the bucket.

Verify with a smoke test

Run a quick Tinker check before wiring forms:

php artisan tinker
Storage::disk('s3')->put('smoke-test.txt', 'hello from laravel');
Storage::disk('s3')->exists('smoke-test.txt');
Storage::disk('s3')->delete('smoke-test.txt');

If put returns false or throws, fix IAM or region before touching upload controllers. I have debugged hours of Form Request logic when the root cause was a wrong bucket name.

What IAM permissions does Laravel need for S3?

Overly broad IAM keys are a security incident waiting to happen. Scoped policies limit blast radius if credentials leak from a compromised server or CI log.

Create a dedicated IAM user or role

For EC2-hosted Laravel apps, attach an IAM role to the instance and omit access keys entirely. The SDK picks up temporary credentials automatically. For shared hosting or local dev, create an IAM user with programmatic access only.

Attach a policy like this, replacing the bucket name and account ID:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:DeleteObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::your-app-uploads",
        "arn:aws:s3:::your-app-uploads/*"
      ]
    }
  ]
}

Add s3:PutObjectAcl only if your app sets per-object ACLs. Most Laravel apps rely on bucket policies instead. Official AWS IAM documentation covers policy syntax and condition keys for IP or VPC restrictions.

Enable CORS for direct browser uploads

If Vue or Livewire uploads straight to S3 via pre-signed POST, configure bucket CORS:

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
    "AllowedOrigins": ["https://yourdomain.com"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Tighten AllowedOrigins to your production domain. Wildcard origins belong in local MinIO testing, not live buckets. Our MinIO self-hosted S3 storage guide covers the same CORS pattern for dev environments.

S3 Setup Workflow1. IAM User2. S3 Bucket3. .env Keys4. Smoke TestProduction ChecklistBlock public access unless neededEnable versioning for document appsSet lifecycle rules for temp uploadsUse IAM roles on EC2, not long-lived keys
Four-step AWS S3 for Laravel file storage complete setup workflow from IAM through verification.

How do you upload and retrieve files with Laravel's Storage facade?

Once the disk works, wire uploads through Form Requests and store only the relative path in your database. Never persist full S3 URLs—they break when you change buckets or add CloudFront.

Controller upload pattern

public function store(StoreAvatarRequest $request): RedirectResponse
{
    $path = $request->file('avatar')->store('avatars/'.date('Y/m'), 's3');

    $request->user()->update(['avatar_path' => $path]);

    return back()->with('status', 'Avatar uploaded.');
}

The store() method generates a unique filename and returns a path like avatars/2026/09/abc123.jpg. Save that string in MySQL 9.7 or PostgreSQL 18. The ORM never holds binary data.

Validation in a Form Request

public function rules(): array
{
    return [
        'avatar' => ['required', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
        'document' => ['required', 'file', 'max:10240', 'mimes:pdf'],
    ];
}

Validate on the server even when the UI shows client-side checks. On legal-tech portals I have built, PDF uploads for notary or court-marriage workflows must reject executables regardless of browser behaviour.

Queue large file processing

Resize images or scan PDFs in a queued job after upload. S3 decouples storage from compute, so your worker can pull the object, process it, and write a derivative back:

ProcessUploadedDocument::dispatch($path)->onQueue('media');

Pair this with Redis 8.10 queues as described in our Laravel queues with Redis production setup guide. The web request stays fast; heavy work runs async.

Multi-disk strategy for migrations

During a move from local to S3, keep both disks active. A custom artisan command can copy legacy files without downtime:

if (Storage::disk('local')->exists($legacyPath)) {
    $contents = Storage::disk('local')->get($legacyPath);
    Storage::disk('s3')->put($legacyPath, $contents);
}

Website migration projects often need this incremental copy. Our website migration service in Nepal handles exactly this class of storage cutover.

How do you serve S3 files publicly or privately in Laravel?

Access control is where S3 setups diverge. Public product images need different rules than client contracts or passport scans.

Public files via Storage::url()

For public-read objects, call:

$url = Storage::disk('s3')->url($user->avatar_path);

Ensure the bucket policy or object ACL allows anonymous s3:GetObject. Block public access settings override ACLs, so a public policy on a blocked bucket still returns 403.

Private files via temporary signed URLs

Client portals—like those with document sharing for law firms—must never expose permanent URLs. Generate time-limited links instead:

$url = Storage::disk('s3')->temporaryUrl(
    $document->path,
    now()->addMinutes(15)
);

Fifteen minutes is enough for a browser download. Adjust expiry per workflow. Signed URLs work without making the bucket public.

CloudFront for performance

Direct S3 URLs work but add latency for global users. Put CloudFront in front of the bucket and set AWS_URL to the distribution domain. Our AWS CloudFront CDN setup for Laravel assets article covers cache headers and origin access control.

Access patternMethodBucket settingBest for
Public catalog imagesStorage::url()Public read policy or CloudFronteCommerce product photos
Private client documentstemporaryUrl()Block all public accessLegal portals, KYC uploads
Direct browser uploadPre-signed POST URLCORS + scoped IAMLarge video or scan uploads
App-internal onlyStorage::get() in jobsPrivate bucket, VPC endpoint optionalPDF generation, thumbnails
Public vs Private S3 AccessPublic URLStorage::url()Permanent linkProduct imagesSigned URLtemporaryUrl()Expires in minutesLegal documentsRule: store path in DB, not full URLGenerate URLs at render timeSwitch CDN or bucket without data migration
Public Storage::url() versus private temporaryUrl() patterns in Laravel S3 file delivery.

What are common S3 setup mistakes in Laravel production?

S3 works locally then fails after deploy. These issues recur across production Laravel applications I maintain.

Wrong region or bucket name

A typo in AWS_DEFAULT_REGION produces signature mismatch errors. The region must match the bucket exactly. Cross-region replication adds complexity you probably do not need on a Rs 5,000/month (~USD 37) VPS or small EC2 setup.

php artisan storage:link only affects the local public disk. S3 apps do not need it for remote files. Developers run it out of habit, see a symlink, and assume S3 is wired. It is not.

Credentials on the server filesystem

Long-lived keys in a world-readable .env backup are dangerous. Prefer IAM roles on Laravel hosted on AWS EC2 with RDS and S3. Rotate keys if they ever appear in logs. Our Linux system administration service covers hardening and permission audits.

Forgetting config cache after deploy

After changing disk settings, run:

php artisan config:clear
php artisan config:cache

Stale cached config keeps pointing at the local disk. I have seen this on zero-downtime Deployer releases where config:cache ran before the new .env symlink swapped.

No lifecycle rules for temp files

Chunk uploads, exports, and failed job artifacts accumulate. Add an S3 lifecycle rule to delete objects under tmp/ after seven days. Storage costs stay predictable. At roughly USD 0.023 per GB-month for standard storage, a forgotten export folder adds up over years.

Spatie Media Library configuration

Many Laravel apps use Spatie Media Library for avatars and attachments. Set the default disk in config/media-library.php:

'disk_name' => env('MEDIA_DISK', 's3'),

On Mijar Law Associates client portal work, media records point to private S3 objects while the UI serves signed URLs per request. The same pattern fits any document-heavy app.

Production S3 GotchasWrong RegionSignature errorsStale ConfigLocal disk usedPublic BucketData leak riskFix ChecklistMatch region to bucketconfig:clear after deployBlock public accessUse IAM roles on EC2MonitoringCloudWatch S3 metricsAlert on 403 spikesLog Storage exceptionsAudit bucket policies
Common AWS S3 for Laravel file storage production failures and the fixes that resolve them.

Testing and local development

Use MinIO or LocalStack locally with AWS_ENDPOINT pointed at your dev server. Keep the same disk name (s3) so code paths match production. A JSON formatter tool helps inspect pre-signed POST responses during CORS debugging.

For full-stack apps combining S3, EC2, and RDS, read deploying Laravel on AWS EC2 with RDS. Enterprise builds with heavy document workflows benefit from enterprise application development in Nepal where storage architecture is planned upfront.

Technical SEO also depends on fast image delivery. Pair S3 with CloudFront and follow SEO for Laravel sites complete setup so Core Web Vitals stay green. Payment receipt PDFs stored in S3 tie into Laravel payment integrations patterns on eCommerce builds like Quick And Easy Nepalese Grocery.

Ongoing ops—lifecycle tuning, key rotation, cost review—fits support and maintenance for Laravel apps in Nepal. Speed work after CDN setup links to speed optimization service. API apps exposing upload endpoints should follow Laravel API best practices for auth and rate limits.

Server baseline hardening before S3 credentials land on a box is covered in our Ubuntu server setup guide. For hosting decisions comparing providers, see AWS vs DigitalOcean vs Hetzner for Laravel hosting. Custom builds start at custom software development in Nepal. More background on Kokil's stack is on the about me page.

Official references: the Laravel 13 filesystem documentation covers drivers and testing fakes. AWS publishes S3 user guide details on policies, CORS, and lifecycle rules. The Flysystem AWS S3 v3 adapter repository documents edge-case configuration options.

Key Takeaways

  • Install league/flysystem-aws-s3-v3, set FILESYSTEM_DISK=s3, and smoke-test with Tinker before wiring controllers.
  • Scope IAM to one bucket with Put, Get, Delete, and List actions; prefer EC2 IAM roles over long-lived keys.
  • Store relative paths in the database and generate URLs at runtime—never persist full S3 URLs in MySQL columns.
  • Use temporaryUrl() for private documents; reserve public URLs or CloudFront for catalog and marketing assets.
  • Run config:clear after deploy, add lifecycle rules for temp prefixes, and set throw => true while debugging.
  • Pair S3 with queued processing and CDN caching for production-grade Laravel file storage at scale.

People Also Ask

Does Laravel 13 support AWS S3 out of the box?

Laravel 13 includes Flysystem 3 integration and a preconfigured s3 disk in config/filesystems.php. You must install the league/flysystem-aws-s3-v3 package separately and provide AWS credentials via .env. No core code changes are required beyond choosing the disk in your upload logic.

Can I use S3 for local development without AWS charges?

Yes. Run MinIO or LocalStack locally and set AWS_ENDPOINT to your dev server URL. Keep the disk name as s3 so the same Storage::disk('s3') calls work in every environment. MinIO speaks the S3 API and costs nothing on your laptop.

How much does AWS S3 storage cost for a typical Laravel app?

Standard S3 storage runs about USD 0.023 per GB-month in ap-south-1, plus per-request fees. A small Laravel app with a few gigabytes of uploads typically costs under USD 5 (roughly Rs 665) monthly. CloudFront data transfer adds separate line items. Lifecycle rules on temp folders keep bills flat as traffic grows.

Should I store files on S3 or the local server disk?

Choose S3 when you run multiple app servers, handle large uploads, or need durable backups without filling EC2 volumes. Local disk suits single-server prototypes and tiny file counts. Most production Laravel apps move to S3 once horizontal scaling or document volume enters the picture.

Ship durable file storage on your next Laravel release

An AWS S3 for Laravel file storage complete setup turns uploads from a server liability into managed infrastructure. Wire IAM, config, and signed URLs once; your app scales without rsync scripts or full disks. If you want help planning storage for a client portal, eCommerce catalog, or EC2 deployment, contact us to review your bucket policy, migration path, and CDN layer before go-live.

Frequently Asked Questions

Laravel 13 includes Flysystem 3 and a preconfigured s3 disk in config/filesystems.php, but you must install league/flysystem-aws-s3-v3 separately and supply AWS credentials in .env.

Yes. Run MinIO or LocalStack locally, point AWS_ENDPOINT at your dev server, and keep the disk name s3 so Storage::disk('s3') works unchanged across environments.

Standard S3 storage runs roughly USD 0.023 per GB-month. Forgotten export or tmp folders add up over years without lifecycle rules.

Install league/flysystem-aws-s3-v3 with Composer 2.10: composer require league/flysystem-aws-s3-v3 "^3.0" --with-all-dependencies. Laravel 13 ships Flysystem 3 integration, but the S3 adapter is not bundled by default. Confirm PHP 8.3+ and Laravel 13.x with php artisan about before wiring uploads. Laravel 12 on PHP 8.2 uses the same package and disk configuration.

The setup has four layers: create an AWS bucket in a region close to users (ap-south-1 for Nepal apps on Mumbai EC2), configure IAM credentials, set the s3 disk in config/filesystems.php to read AWS env vars, and set FILESYSTEM_DISK=s3 plus AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, and AWS_URL in .env. Enable block public access unless objects must be world-readable. Set throw to true during initial setup to surface credential typos, then switch to false in production if you prefer soft failures with logging.

Add FILESYSTEM_DISK=s3, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, and AWS_URL to .env and your deployment secrets store. Optional keys include AWS_ENDPOINT for MinIO or LocalStack and AWS_USE_PATH_STYLE_ENDPOINT for path-style endpoints. Never commit .env to Git; on Deployer or GitLab CI pipelines, these values live in the shared server .env. After changing disk settings on deploy, run php artisan config:clear and php artisan config:cache so stale cached config does not keep pointing at local disk.

Scope a dedicated IAM user or EC2 instance role to one bucket with s3:PutObject, s3:GetObject, s3:DeleteObject, and s3:ListBucket on both the bucket ARN and bucket objects ARN. Add s3:PutObjectAcl only if your app sets per-object ACLs; most Laravel apps rely on bucket policies instead. For EC2-hosted apps, attach an IAM role and omit access keys—the SDK picks up temporary credentials automatically. Overly broad keys are a security incident waiting to happen if credentials leak from a compromised server or CI log.

Run a smoke test in Tinker before touching controllers: Storage::disk('s3')->put('smoke-test.txt', 'hello from laravel'), then exists() and delete() on the same key. If put returns false or throws, fix IAM or region first. I have debugged hours of Form Request logic when the root cause was a wrong bucket name or mismatched AWS_DEFAULT_REGION producing signature errors.

Wire uploads through Form Requests and call $request->file('avatar')->store('avatars/'.date('Y/m'), 's3'), which returns a relative path like avatars/2026/09/abc123.jpg. Save that string in MySQL 9.7 or PostgreSQL 18—never binary data in the ORM. Validate on the server with rules for image, max size, and mimes even when the UI shows client-side checks. Queue large processing such as image resize or PDF scan with ProcessUploadedDocument::dispatch($path)->onQueue('media') and Redis 8.10 so the web request stays fast.

Store only the relative path returned by store(), never persist full S3 URLs in database columns. URLs break when you change buckets, switch regions, or add CloudFront in front of the origin. Generate URLs at runtime with Storage::disk('s3')->url($path) for public objects or temporaryUrl() for private ones. The app stores paths in the database and bytes in the bucket—that separation keeps migrations and CDN cutovers straightforward.

Use Storage::disk('s3')->temporaryUrl($document->path, now()->addMinutes(15)) for client portals, KYC uploads, and legal documents. Fifteen minutes is enough for a browser download; adjust expiry per workflow. Signed URLs work without making the bucket public. On document-heavy apps like law-firm client portals, media records point to private S3 objects while the UI serves a fresh signed URL per request. Never expose permanent URLs for contracts or passport scans.

Storage::url() returns a permanent public URL suitable for catalog images and eCommerce product photos when the bucket policy or object ACL allows anonymous s3:GetObject. temporaryUrl() generates a time-limited signed link for private buckets with block public access enabled. Block public access settings override ACLs, so a public policy on a blocked bucket still returns 403. For global performance on public assets, put CloudFront in front of the bucket and set AWS_URL to the distribution domain.

No. php artisan storage:link only affects the local public disk symlink. S3 apps do not need it for remote files. Developers run it out of habit, see a symlink, and assume S3 is wired—it is not. Remote objects are served via Storage::url(), temporaryUrl(), pre-signed POST URLs for direct browser uploads, or Storage::get() inside queued jobs for app-internal processing like PDF generation and thumbnails.

If Vue or Livewire uploads straight to S3 via pre-signed POST, add bucket CORS allowing GET, PUT, POST, DELETE, and HEAD from your production domain in AllowedOrigins. Expose ETag in ExposeHeaders and set MaxAgeSeconds to 3000. Tighten AllowedOrigins to https://yourdomain.com—wildcard origins belong in local MinIO testing, not live buckets. Pair scoped IAM with CORS so the browser can complete the upload without exposing broad bucket permissions.

Wrong AWS_DEFAULT_REGION or bucket name causes signature mismatch errors—the region must match the bucket exactly. Stale config:cache after deploy keeps FILESYSTEM_DISK on local until you run config:clear. Long-lived keys in world-readable .env backups are dangerous; prefer IAM roles on EC2. Skipping lifecycle rules lets tmp uploads and failed job artifacts accumulate at roughly USD 0.023 per GB-month. For Spatie Media Library apps, set disk_name to env('MEDIA_DISK', 's3') in config/media-library.php or attachments silently land on the wrong disk.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: