Skip to content
Sultan Kautsar

Technical note

What I Learned Migrating WordPress Media to S3

Field notes on preserving WordPress attachment behavior while moving media into object storage safely.

Author
By Sultan Kautsar
Published
Updated

import Link from "next/link";

A WordPress media migration can look like a storage task: copy the upload tree into S3, change a URL, and remove the old files. That model is incomplete. WordPress does not treat an image as one file with one address. It treats it as an attachment record connected to an original, generated variants, metadata, and runtime rules that decide where the asset is read from.

The practical lesson was to treat the work as a state migration across the database, storage, and delivery layers. A file existing in object storage proves only that a copy succeeded. It does not prove that WordPress understands the object, generates the right URLs, or can keep handling new uploads after cutover.

{"The first artifact should be a model, not a script"}

Before moving anything, I map how the installation currently resolves an attachment. That means checking WordPress behavior through its media APIs, not inferring it from a directory listing. Themes, plugins, and configuration can alter upload locations, register additional image sizes, or filter attachment URLs. A migration designed around default assumptions can therefore be correct for a clean installation and wrong for the site being moved.

Database state

The attachment post is the starting point. Its _wp_attached_file value identifies the current attached or full-size file. It is usually relative to the active uploads base, but WordPress also supports stored absolute paths. The migration should resolve that distinction through get_attached_file() rather than joining path segments unconditionally. The value is not always the originally uploaded image: large-image scaling and some conversions can make a generated file the active full size while metadata retains an original_image reference. If an installation uses custom upload paths, old conventions, or filters around wp_upload_dir(), treating the value as a standard year-and-month key can put an object in the wrong place.

For images, _wp_attachment_metadata describes more of the attachment family. It records information about the active full size and generated sizes, including the filenames WordPress expects for those variants. The original image should be resolved through WordPress media APIs such as wp_get_original_image_path() rather than inferred from one filename. Editor restore files referenced by _wp_attachment_backup_sizes may also need preservation. WordPress persists this data through its metadata APIs, so it should be read and updated as structured application data rather than edited as an arbitrary serialized string.

Storage state

The filesystem inventory must include the active full-size file, preserved original where present, generated image sizes, and any editor backups that must remain restorable. A theme or plugin may have registered variants that are not obvious from the current settings, while older attachments may reflect rules that no longer exist. Attachment metadata is useful evidence, but it still needs to be compared with actual files. If a referenced variant is missing, the migration needs an explicit decision: regenerate it from a valid source when safe, or record it as an existing gap. A copy job should not silently convert an old inconsistency into a claimed success.

Delivery state

Object presence and offload state are separate facts. A WordPress-aware storage integration may keep its own metadata for the provider, object key, or delivery URL, then filter attachment functions at runtime. An object copied outside that integration can exist perfectly in S3 while WordPress continues to emit a local URL. Conversely, changing URLs before the object family is complete can make the database look migrated while pages serve broken variants.

I now make this contract explicit: which component owns the object key, which metadata marks an attachment as offloaded, and which function produces the public URL. This makes a storage migration a piece of system integration work: understand ownership and data flow before automating the transition.

I treated each batch as an experiment

A full-library run is a poor first test because it mixes discovery, transfer, metadata changes, and delivery behavior into one result. I prefer a representative batch that includes ordinary images, generated variants, non-image attachments, unusual filenames, and media created under older upload rules. The point is not volume. It is to expose each path the migration must preserve.

  • Capture the attachment metadata and resolved URLs before changing it.
  • Build object keys from WordPress's logical attachment paths rather than from assumptions about a server directory.
  • Verify the full-size file, preserved original, editor backups, and every expected variant at the destination before committing offload metadata.
  • Exercise the normal application path: media-library views, rendered content, responsive image URLs, and a fresh upload.
  • Record failures as retryable states instead of letting a batch appear wholly complete.

This sequence separates transfer correctness from application correctness. It also makes batch expansion evidence-based. A successful object listing is useful, but a request through the site's normal delivery path is the stronger check because it exercises URL generation, access rules, and the expected variant key together.

{"Application integration beats filesystem cleverness"}

Mounting object storage so it resembles a local directory can reduce the visible code change, but it moves complexity into filesystem semantics. Object storage is not a POSIX filesystem. Listing, renaming, caching, and consistency behavior can differ from what WordPress and image-processing tools expect. The arrangement can also make it unclear whether the application or the mount owns retries, failures, and cleanup.

Where possible, I prefer a WordPress-aware integration that participates in the upload lifecycle, uses local temporary files when processing requires them, stores remote state deliberately, and generates URLs through application hooks. If custom code is necessary, it should still honor WordPress media APIs and the chosen integration's metadata contract. A filesystem illusion is not simpler if nobody can explain its failure modes.

{"Cleanup is a migration phase, not the finish button"}

The migration should be safe to rerun. Stable object keys, destination checks, and explicit per-attachment states prevent retries from creating duplicate work or advancing metadata ahead of storage. Metadata changes should happen only after destination verification, and a rerun should converge on the same state. That is more valuable than a script that is fast only when every operation succeeds on its first attempt.

Rollback needs the same attention as cutover. Preserve the original files while validation is active, retain a recoverable copy of every metadata value the process changes, and make the delivery switch reversible. Rollback should restore the previous resolution path without depending on immediate deletion from S3. Cleanup comes later, after application checks, storage reconciliation, and normal operational use agree that the migrated state is complete.

The durable lesson

Migrating WordPress media to S3 is not just copying files. It is preserving an attachment contract while changing where bytes live and how URLs are produced. Once attachment behavior, generated sizes, custom paths, offload metadata, and rollback are treated as parts of one system, the work becomes easier to inspect, retry, verify, and reverse without guesswork.