
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Storing user uploads directly on your application server creates scaling bottlenecks, single points of failure, and backup nightmares. Implementing AWS S3 for Laravel file storage complete setup decouples media from compute, enabling horizontal scaling and global content delivery without code changes. This guide walks through the secure, production-grade configuration I use for client projects, moving beyond basic tutorials to address IAM least privilege, signed URLs, and cost optimization.
How do you configure AWS S3 for Laravel file storage securely?
Security failures in S3 integrations rarely stem from Laravel itself; they come from overly permissive IAM policies and misconfigured bucket ACLs. Before touching any PHP code, you must establish a secure foundation at the infrastructure level. For teams managing broader cloud environments, understanding AWS IAM best practices for least-privilege access is essential context for this setup.
Create a least-privilege IAM policy
Never attach AmazonS3FullAccess to your Laravel application user. Create a dedicated IAM user or role with permissions restricted to a single bucket prefix. This limits blast radius if credentials leak.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowListBucket",
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::my-laravel-app-uploads"
},
{
"Sid": "AllowReadWriteObjects",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::my-laravel-app-uploads/*"
}
]
} Configure the S3 bucket correctly
Block all public access at the bucket level. Enable server-side encryption (SSE-S3 or SSE-KMS) by default. Disable ACLs and rely solely on bucket policies for access control.
- Block Public Access: Enable "Block all public access" in bucket settings
- Default Encryption: Enable SSE-S3 (AES-256) or SSE-KMS for compliance-sensitive data
- Versioning: Enable for accidental deletion protection and audit trails
- Lifecycle Rules: Transition old files to Glacier after 90 days to reduce costs
What environment variables does Laravel need for S3?
Laravel's filesystem abstraction reads S3 configuration from environment variables, keeping secrets out of version control. Install the required adapter first:
composer require league/flysystem-aws-s3-v3:^3.0 Add these variables to your .env file. Never commit real credentials:
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=my-laravel-app-uploads
AWS_URL=https://my-laravel-app-uploads.s3.ap-south-1.amazonaws.com
AWS_USE_PATH_STYLE_ENDPOINT=false The config/filesystems.php disk definition should reference these variables explicitly:
'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' => true,
], Setting 'throw' => true ensures S3 errors surface as exceptions rather than silent failures. This is critical for debugging in staging environments where network issues or permission misconfigs are common.
Handle local development gracefully
Use conditional disk selection to avoid hitting S3 during local development. In .env.local, set FILESYSTEM_DISK=local. Your application code remains identical across environments.
How do you implement signed URLs for private S3 content?
Private buckets break direct image tags and download links. Signed URLs solve this by generating time-limited, cryptographically verified access tokens. This pattern is mandatory for user avatars, invoice PDFs, or any content that must remain private but viewable by authenticated users.
Generate temporary URLs in controllers
Laravel's Storage facade provides a clean API for signed URLs. Always validate authorization before generating them:
public function showAvatar(User $user)
{
// Authorization check first
if (!auth()->user()->can('view', $user)) {
abort(403);
}
$path = "avatars/{$user->id}.jpg";
if (!Storage::disk('s3')->exists($path)) {
return response()->json(['error' => 'Avatar not found'], 404);
}
// Generate URL valid for 5 minutes
$url = Storage::disk('s3')->temporaryUrl(
$path,
now()->addMinutes(5)
);
return redirect($url);
} Use CloudFront signed URLs for performance
S3 signed URLs bypass CloudFront caching entirely because each URL is unique. For high-traffic applications, configure CloudFront Origin Access Control (OAC) and generate CloudFront-signed URLs instead. This requires the aws/aws-sdk-php CloudFront signer:
use Aws\CloudFront\UrlSigner;
$signer = new UrlSigner(
env('CLOUDFRONT_KEY_PAIR_ID'),
storage_path('cloudfront-private-key.pem')
);
$url = $signer->getSignedUrl(
"https://cdn.example.com/avatars/{$userId}.jpg",
now()->addMinutes(5)
); Store the CloudFront private key securely using AWS Secrets Manager or Vault, never in the repository.
How does S3 compare to local storage for Laravel applications?
Choosing between S3 and local storage involves trade-offs beyond simple cost. Understanding these differences prevents costly rearchitecture later. Teams evaluating hosting options should also review hosting Laravel on AWS EC2 with RDS and S3 for full-stack context.
| Criteria | Local Storage | AWS S3 |
|---|---|---|
| Scalability | Limited by server disk; requires manual migration | Virtually unlimited; automatic scaling |
| Availability | Single point of failure unless replicated | 99.99% SLA with cross-AZ redundancy |
| Cost (1TB/month) | Included in VPS (~$20-50) | ~$23 storage + request fees |
| Backup Complexity | Manual rsync/cron required | Versioning + lifecycle rules built-in |
| CDN Integration | Requires separate origin setup | Native CloudFront integration |
| Compliance | Your responsibility entirely | SOC2/ISO27001 certified infrastructure |
| Development Friction | Zero setup | Requires IAM, bucket config, SDK |
For Nepal-based startups serving local audiences, latency matters. Choose the Mumbai (ap-south-1) region for ~40-60ms latency from Kathmandu versus ~200ms+ from us-east-1. Pair with CloudFront's Kathmandu edge location for sub-20ms asset delivery. Read more about hosting websites for Nepal audiences with proper latency optimization.
What are common pitfalls when setting up S3 with Laravel?
After auditing dozens of Laravel-S3 integrations, these mistakes appear repeatedly. Avoiding them saves hours of debugging and potential security incidents.
- Using root account credentials: Always create dedicated IAM users with minimal permissions. Root keys grant full account access and cannot be scoped.
- Forgetting CORS configuration: Direct browser uploads via presigned POST require explicit CORS rules on the bucket. Without them, requests fail silently in JavaScript.
- Hardcoding bucket names: Use environment variables exclusively. Hardcoded values break staging/production parity and complicate disaster recovery.
- Ignoring multipart upload thresholds: Files over 100MB require multipart upload logic. Laravel handles this automatically, but custom uploaders often don't.
- Missing error handling: S3 operations can fail due to throttling, network issues, or permissions. Always wrap Storage calls in try-catch blocks with proper logging.
- Public buckets for convenience: Never make buckets public to "fix" broken image links. Use signed URLs instead. Public buckets are the #1 cause of S3 data breaches.
Configure CORS for direct browser uploads
If your frontend uploads directly to S3 using presigned URLs, add this CORS configuration to your bucket:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST"],
"AllowedOrigins": ["https://yourdomain.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
] Restrict AllowedOrigins to your actual domain. Wildcard origins defeat the purpose of CORS and expose your bucket to cross-site attacks.
Monitor costs proactively
S3 costs surprise teams who ignore request pricing. GET requests cost $0.0004 per 1,000; PUT requests cost $0.005 per 1,000. High-volume thumbnail generation or unoptimized caching can generate thousands of dollars monthly. Set up CloudWatch billing alarms and enable S3 Storage Lens for visibility into access patterns.
Production Checklist for AWS S3 Laravel Integration
Before deploying to production, verify every item on this list. Skipping even one has caused outages or security incidents in projects I've audited.
- IAM user has only required S3 permissions (no wildcards beyond bucket prefix)
- Bucket has public access blocked and default encryption enabled
- Environment variables used exclusively (no hardcoded credentials)
- Signed URLs implemented for all private content access
- CloudFront configured with OAC for cached private content
- CORS rules restricted to production domains only
- Lifecycle rules defined for archival and expiration
- Billing alerts configured for anomalous request volume
- Error handling wraps all Storage facade calls
- Local development uses separate disk to prevent accidental S3 writes
This AWS S3 for Laravel file storage complete setup gives you enterprise-grade media handling without operational overhead. The initial configuration takes an afternoon; the scalability and security benefits last indefinitely. If your team needs help implementing this securely or optimizing an existing setup, reach out to discuss your specific requirements.