AWS S3 for Laravel File Storage Complete Setup

Khimananda Oli 8 min read Cloud
AWS S3 for Laravel File Storage Complete Setup

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.

IAM UserScoped Policys3:PutObjects3:GetObjectLaravel AppStorage::disk('s3')Signed URLsS3 BucketPrivate ACLServer-Side EncryptionCloudFrontOAC + Signed URLs
Secure AWS S3 for Laravel file storage architecture with private bucket, scoped IAM, and CloudFront delivery

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.

BrowserGET /avatar/user-42Laravel ControllerStorage::temporaryUrl()Expires: 5 minutesS3 BucketValidate SignatureCloudFrontCache + Sign1. Request2. Redirect3. Serve
Signed URL workflow for private AWS S3 Laravel file storage with optional CloudFront acceleration

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.

CriteriaLocal StorageAWS S3
ScalabilityLimited by server disk; requires manual migrationVirtually unlimited; automatic scaling
AvailabilitySingle point of failure unless replicated99.99% SLA with cross-AZ redundancy
Cost (1TB/month)Included in VPS (~$20-50)~$23 storage + request fees
Backup ComplexityManual rsync/cron requiredVersioning + lifecycle rules built-in
CDN IntegrationRequires separate origin setupNative CloudFront integration
ComplianceYour responsibility entirelySOC2/ISO27001 certified infrastructure
Development FrictionZero setupRequires 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.

  1. Using root account credentials: Always create dedicated IAM users with minimal permissions. Root keys grant full account access and cannot be scoped.
  2. Forgetting CORS configuration: Direct browser uploads via presigned POST require explicit CORS rules on the bucket. Without them, requests fail silently in JavaScript.
  3. Hardcoding bucket names: Use environment variables exclusively. Hardcoded values break staging/production parity and complicate disaster recovery.
  4. Ignoring multipart upload thresholds: Files over 100MB require multipart upload logic. Laravel handles this automatically, but custom uploaders often don't.
  5. 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.
  6. 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.

Frequently Asked Questions

Install league/flysystem-aws-s3-v3 via Composer. This package provides the S3 adapter for Laravel's filesystem abstraction layer and supports AWS SDK v4 features.

Use IAM instance profiles on EC2 or IRSA on EKS instead of static keys. For local development, store credentials in .env but never commit them to version control.

Check your IAM policy allows s3:PutObject on the specific bucket prefix. Verify the bucket policy does not explicitly deny access and that CORS rules permit your application origin.

Yes, S3 costs approximately $0.023 per GB monthly versus $0.10+ per GB on block storage. Offloading media reduces server disk I/O and eliminates backup complexity for user uploads.

Set 'visibility' => 'private' in config/filesystems.php under the s3 disk. This ensures all uploads use authenticated URLs unless you explicitly override visibility during storage operations.

Yes, configure the CloudFront key pair ID and private key in your S3 disk config. Laravel automatically generates signed URLs using these credentials when calling temporaryUrl on private files.

Large files without multipart uploads cause timeouts. Configure chunked uploads using tus-php or enable StreamWrapper for files exceeding 100MB to prevent memory exhaustion and connection failures.

Use MinIO or LocalStack as drop-in S3-compatible replacements. Point your Laravel S3 endpoint to localhost:9000 and use dummy credentials for offline development and CI pipeline testing.

Enable versioning only if users frequently overwrite critical documents. It increases storage costs significantly but provides accidental deletion protection and audit trails for compliance requirements.

Configure S3 Lifecycle policies to move infrequently accessed files to Glacier after 90 days. Laravel continues accessing these files transparently, though retrieval latency increases to minutes or hours.

S3 sometimes serves incorrect Content-Type headers for uploaded files. Always specify contentType explicitly when storing files to ensure browsers render images and PDFs correctly instead of downloading them.

Use php artisan storage:link temporarily, then run a custom command iterating Storage::disk('local')->files() and copying each to the S3 disk while updating database path references.

No, each exists() call makes an API request. Cache metadata in Redis or DynamoDB for high-traffic applications to reduce S3 GET requests and lower monthly API costs.

Single PUT operations support up to 5GB. Configure multipart uploads in flysystem settings to handle files up to 5TB, which covers virtually all web application use cases.

Enable AWS SDK debug logging by setting 'debug' => true in the S3 disk options. Review CloudWatch logs and VPC Flow Logs to identify network or permission bottlenecks.