log in
consulting hosting industries the daily tools about contact

S3 Lifecycle Policies Won't Break Your URLs (If You're Careful)

Tiering old user uploads to Glacier sounds free — until your app tries to serve a file and gets a 403. Here's how I do it without breaking anything.

I've been burned by this twice. S3 lifecycle policies look like free money — move old objects to cheaper storage, save 70% on storage costs, done. The problem is your app doesn't know the file moved, and neither does the user's browser when it follows a pre-signed URL to something that's now archived.

Here's how I actually do this without waking up to a support ticket at 2am.

What the Problem Actually Is

S3 storage classes are invisible at the URL level. An object in STANDARD and the same object in GLACIER_IR (Glacier Instant Retrieval) live at the same key, return the same URL, and look identical from the outside — right up until they don't.

The catch: if you move something to GLACIER or DEEP_ARCHIVE (not Instant Retrieval), the object is no longer directly readable. You have to issue a restore request, wait hours or days depending on the tier, and then download it. Your pre-signed URL will return a 403 InvalidObjectState until restoration completes. If your app generates that URL on the fly and hands it to a user, you've just served them a broken link.

Glacier Instant Retrieval is the exception — objects are available immediately, same as Standard, but cost about 68% less. That's the tier I use for most user-upload aging. The deep tiers are for true cold archives where you can tolerate a 12-hour retrieval window and your app knows how to handle that gracefully.

I set this up last year for a print management client. They accumulate artwork files — PSDs, PDFs, high-res TIFFs — and users occasionally need to re-download something from three years ago, but the access pattern drops off a cliff after 90 days. We were paying for gigabytes of Standard storage on files that hadn't been touched in years.

The Lifecycle Policy

First, the policy itself. I do this in Terraform, but the AWS console wizard works fine too. Here's the JSON if you're applying it via the SDK or console:

{
  "Rules": [
    {
      "ID": "tier-user-uploads",
      "Status": "Enabled",
      "Filter": {
        "Prefix": "uploads/"
      },
      "Transitions": [
        {
          "Days": 90,
          "StorageClass": "STANDARD_IA"
        },
        {
          "Days": 365,
          "StorageClass": "GLACIER_IR"
        }
      ]
    }
  ]
}

I stage it: Standard → Standard-IA at 90 days, Standard-IA → Glacier IR at 365 days. Standard-IA has a 128KB minimum object size charge and a 30-day minimum storage charge, so don't throw tiny files at it. Anything under ~100KB I leave in Standard or skip IA entirely and jump straight to Glacier IR.

Tracking Storage Class in Your App

Here's where most tutorials stop and where the real work starts. Your app needs to know what storage class an object is in before it tries to serve it.

The naive approach — just generate a pre-signed URL and return it — works until something is in deep Glacier. Then it blows up silently.

I add a storage_class column to the uploads table and keep it current via a background job that checks S3 metadata. In Laravel:

<?php

namespace App\Jobs;

use App\Models\UserUpload;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;

class SyncUploadStorageClass implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public UserUpload $upload) {}

    public function handle(): void
    {
        $client = Storage::disk('s3')->getClient();

        $result = $client->headObject([
            'Bucket' => config('filesystems.disks.s3.bucket'),
            'Key'    => $this->upload->s3_key,
        ]);

        $this->upload->update([
            'storage_class'   => $result['StorageClass'] ?? 'STANDARD',
            'restore_status'  => $result['Restore'] ?? null,
            'storage_synced_at' => now(),
        ]);
    }
}

I run this on a schedule — daily for anything older than 60 days, weekly for files over a year old. The headObject call is cheap (a GET-equivalent request, fractions of a cent).

Serving Files Safely

Now the controller that actually hands a URL to the user:

<?php

namespace App\Http\Controllers;

use App\Models\UserUpload;
use App\Jobs\InitiateGlacierRestore;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class UploadDownloadController extends Controller
{
    public function download(Request $request, UserUpload $upload)
    {
        $this->authorize('download', $upload);

        // Deep archive tiers require a restore before download.
        // GLACIER_IR is fine — treat it like Standard.
        $blockedClasses = ['GLACIER', 'DEEP_ARCHIVE'];

        if (in_array($upload->storage_class, $blockedClasses)) {
            // Check if a restore is already in progress
            if ($this->restoreInProgress($upload->restore_status)) {
                return response()->json([
                    'status'  => 'restoring',
                    'message' => 'This file is being retrieved from cold storage. Check back in a few hours.',
                ], 202);
            }

            if ($this->restoreComplete($upload->restore_status)) {
                // Fall through — serve the restored copy below
            } else {
                // Kick off a restore and tell the user
                InitiateGlacierRestore::dispatch($upload);

                return response()->json([
                    'status'  => 'queued',
                    'message' => 'Retrieval from cold storage initiated. You will receive an email when the file is ready.',
                ], 202);
            }
        }

        $url = Storage::disk('s3')->temporaryUrl(
            $upload->s3_key,
            now()->addMinutes(15),
            ['ResponseContentDisposition' => 'attachment; filename="' . $upload->original_filename . '"']
        );

        return response()->json(['url' => $url]);
    }

    private function restoreInProgress(?string $restoreHeader): bool
    {
        // S3 Restore header looks like: ongoing-request="true"
        return $restoreHeader !== null && str_contains($restoreHeader, 'ongoing-request="true"');
    }

    private function restoreComplete(?string $restoreHeader): bool
    {
        // Completed restore: ongoing-request="false", expiry-date="..."
        return $restoreHeader !== null
            && str_contains($restoreHeader, 'ongoing-request="false"')
            && str_contains($restoreHeader, 'expiry-date');
    }
}

The Restore header from headObject is a string like ongoing-request="false", expiry-date="Fri, 01 Aug 2025 00:00:00 GMT" — it's not parsed for you, so you're doing string matching. Not elegant, but that's what AWS gives you.

The Gotchas That Will Bite You

Minimum storage duration charges. Standard-IA and Glacier IR both have a 90-day minimum. If you archive something and then delete it on day 45, you're billed for the full 90. For user-generated content where users can delete their own files, I either exclude those objects from tiering entirely or accept the overage as a rounding error.

The 128KB floor on Standard-IA. I mentioned this above but it's worth repeating. If you have a lot of small files — thumbnails, JSON exports, CSV reports — Standard-IA will cost you more than Standard. Profile your object size distribution before you write the policy.

PUT overwrite resets storage class. If anything in your app overwrites an S3 object (same key, new upload), the storage class resets to Standard and the lifecycle clock restarts. Worth knowing if you have a versioning or overwrite pattern.

Pre-signed URLs and Glacier IR latency. Glacier IR is advertised as milliseconds. In my experience it's typically fast, but I've seen occasional cold-start latency on the first byte of a large file — 2-4 seconds. For a 400MB TIFF that's not a disaster, but don't use Glacier IR for objects that need to stream at low latency for video playback or real-time previews.

Versioned buckets need lifecycle rules for non-current versions too. If you have versioning enabled (and you should for user uploads), lifecycle policies only apply to current versions by default. You need a separate NoncurrentVersionTransitions rule or you'll be paying for Standard storage on every previous version forever.

When I'd Reach for This

This is worth the setup work when you have a bucket where:

  • Total storage is over ~50GB and growing
  • Access patterns have a clear long tail (most downloads happen within 90 days of upload)
  • Users can tolerate a small UI affordance for "this file is in cold storage"

For my print client, the first month after enabling this policy cut their S3 bill by about 40%. The bucket had four years of accumulated artwork at that point. Not dramatic on a small bucket, but they were at 800GB and climbing.

I'd skip lifecycle tiering for buckets that serve application assets, public CDN content, or anything that needs sub-100ms consistent access. That's what CloudFront and Standard are for. I'd also skip it if your object sizes are uniformly small — the minimum size charges will eat your savings.


Lifecycle policies are genuinely one of the easier wins in cloud cost control, but the AWS docs bury the operational implications. The storage class transition is silent — nothing in the SDK throws an error, nothing in your app logs changes, and the first time a user hits a 403 on a Glacier object you'll spend an hour debugging something that should have been handled on day one. Build the awareness into your data model up front and it's a non-issue.

Related

Need help shipping something like this? Get in touch.