— Article — № 108

108 —Migration

Joomla 3 image library to S3: a 22GB migration log

How we moved a 22GB Joomla 3 image library to S3 over one weekend, without breaking a single article reference or noticing a flicker in the access log.

Overhead desk: graph-paper migration log, manila folder, ink directory tree to S3 bucket, index cards, brass plate, pen, wax seal.
Hero · staged still№ 108

The Loom arrived at 23:41 on a Tuesday. Twelve minutes of an agency lead screen-sharing a Joomla 3.10 admin in dim light. The site was a regional news publisher with about 14,000 articles, 22GB of images under /images/, and a hosting bill that had quietly tripled over four years. Their last backup had failed for the eighth night in a row. The shared host gave up halfway through the tarball.

The brief was straightforward: move the entire image library to S3, keep every article's <img> tag working, do it without taking the site down. The publisher had a developer relationship from 2014 they did not want to revive.

This is the log of what we did, in the order we did it.

Why /images/ broke at 22GB

The site sat on a single Hetzner VPS with a 40GB SSD. PHP 7.4, MariaDB 10.3, Joomla 3.10.12. The image library lived at /var/www/html/images/, the default Joomla location. Around 18GB of it was article hero photos at full DSLR resolution, never resized, going back to 2015. Another 3GB was inline images/stories/ content. The remainder was thumbnails, generated badly, in several competing folders.

The breaking point was not disk. It was inode pressure and the backup window. find /var/www/html/images -type f | wc -l returned 487,213. The nightly tar was running into the next morning and locking files mid-stream. The customer's hosting provider had also started throttling outbound traffic above 200GB per month, which the image hot-set was now exceeding on its own.

S3 plus a CDN solves all three problems. The hard part is that Joomla 3, like most legacy CMSes, treats image paths as opaque strings in article content. There is no attachments table. There is no canonical media ID. An <img src="images/2018/04/header.jpg"> written in 2018 is, in 2026, a fragile literal that the editor never knew was fragile.

Mapping every reference

Before touching anything, we wanted a count. The audit queries are the same ones you would run from mysql -uroot. The reference surfaces in a Joomla 3 install live in four places:

-- Articles
SELECT id, title FROM jos_content
WHERE introtext LIKE '%src="images/%'
   OR fulltext  LIKE '%src="images/%'
   OR images    LIKE '%images/%';

-- Custom HTML modules
SELECT id, title FROM jos_modules
WHERE content LIKE '%src="images/%';

-- Menu params (intro images on category menus)
SELECT id, title FROM jos_menu
WHERE params LIKE '%images/%';

-- K2 if installed
SELECT id, title FROM jos_k2_items
WHERE introtext LIKE '%images/%'
   OR fulltext  LIKE '%images/%'
   OR image_caption LIKE '%images/%';

The publisher had no K2, but did have JCE editor custom fields and a sidebar of static modules with hand-pasted gallery markup. The audit returned 38,902 distinct references across four tables. Roughly 6 percent of articles used relative paths without the images/ prefix, like src="2017/05/photo.jpg", which Joomla resolved against a per-category base path set in a template override nobody had documented.

We logged the path of every reference and the table it lived in. That CSV was the only artifact we trusted for the rest of the migration.

The Apache proxy that bought us a weekend

The next decision was the most useful one we made. Instead of rewriting the database first and then syncing to S3, we did it the other way around: we put Apache in front of S3 as a transparent proxy, so that https://site.example/images/whatever.jpg would silently read from the bucket regardless of what the database said. This decoupled the file move from the reference rewrite. We could sync first, verify the proxy worked, and only then go near the SQL.

The .htaccess block we added at the document root:

RewriteEngine On

# Serve /images/ from S3 via CloudFront, transparently.
RewriteCond %{REQUEST_URI} ^/images/
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
RewriteRule ^images/(.*)$ https://d2x9k1example.cloudfront.net/images/$1 [P,L]

# Cache the proxied responses for a day at the edge.
<IfModule mod_headers.c>
  Header set Cache-Control "public, max-age=86400" "expr=%{REQUEST_URI} =~ m#^/images/#"
</IfModule>

The !-f condition is the important one. It means: if the file still exists on disk, serve it from disk and skip the proxy. If it does not, proxy from CloudFront. That gave us a safe rollout. We could delete files from the VPS in batches and watch nothing break.

mod_proxy and mod_proxy_http needed to be enabled, which on Apache 2.4 is one line in the host config. The Apache mod_rewrite documentation covers the [P] flag and its interaction with RewriteRule substitution, which is where most production mistakes happen.

Syncing 22GB without rsync drift

With the proxy in place, the sync became boring, which is what you want. We used aws s3 sync rather than rclone because the source was a single mount with no exotic permissions. Two passes:

# First pass: bulk copy, ignore modifications.
aws s3 sync /var/www/html/images/ s3://example-cdn/images/ \
  --size-only \
  --storage-class STANDARD_IA \
  --acl public-read \
  --exclude "*.tmp" --exclude ".DS_Store"

# Second pass, 36 hours later: catch the drift.
aws s3 sync /var/www/html/images/ s3://example-cdn/images/ \
  --size-only \
  --acl public-read

The first pass took 11 hours over the publisher's 100Mbit uplink, which is what you would predict for 22GB given TLS overhead and the long tail of tiny thumbnails. We ran it under tmux and watched it from a phone. The second pass took eight minutes and moved 312 files, exactly the articles the editorial team had touched during the day.

We verified the bucket count against the filesystem count with a single command per side:

find /var/www/html/images -type f ! -name '.DS_Store' | wc -l
aws s3 ls --recursive s3://example-cdn/images/ | wc -l

The numbers matched to within four files, which were .tmp upload remnants we had explicitly excluded. Good enough.

The cutover SQL

This is the part that scares people, and it should. You are going to run UPDATE statements against four tables on a live database. The publisher's editorial workflow was Monday through Friday, so we did the cutover at 02:00 on a Sunday with a full mysqldump taken at 01:55.

The actual rewrites:

-- Inline content in articles.
UPDATE jos_content
SET introtext = REPLACE(introtext,
      'src="images/',
      'src="https://cdn.example.com/images/'),
    fulltext  = REPLACE(fulltext,
      'src="images/',
      'src="https://cdn.example.com/images/');

-- The JSON field on jos_content.images (slashes are escaped in storage).
UPDATE jos_content
SET images = REPLACE(images,
      'images\/',
      'https:\/\/cdn.example.com\/images\/')
WHERE images LIKE '%images\/%';

-- Custom HTML modules.
UPDATE jos_modules
SET content = REPLACE(content,
      'src="images/',
      'src="https://cdn.example.com/images/')
WHERE content LIKE '%src="images/%';

Two things to notice. The jos_content.images column is a JSON blob with escaped forward slashes, so the REPLACE has to match images\/ not images/. Joomla's JRegistry serialiser emits double-escaped slashes on insert, but the actual stored value uses single backslashes. Run SELECT images FROM jos_content WHERE id = X against a known article first and copy the literal bytes.

The second is the 6 percent of articles with bare relative paths. We handled those with a targeted query that matched the per-category base path the template override had been silently prepending, then rewrote those specific patterns. There were 47 distinct base paths. The CSV from the audit phase was what told us which 47.

After the UPDATEs ran (12 seconds total against an indexed table), we ran a paranoid count:

SELECT COUNT(*) FROM jos_content
WHERE introtext LIKE '%src="images/%'
   OR fulltext  LIKE '%src="images/%';
-- Expected: 0

It returned 3. Three articles had image tags inside a <noscript> block that the editor had hand-written with a space before the src attribute (src ="images/...). We patched those manually and moved on.

Edge cases that bit us anyway

The Apache proxy had saved us from most disasters, but two edge cases still needed attention.

Custom field types

The publisher used a forked version of JCE's image field type for a related-photo sidebar. It stored its value as a serialised PHP array, not as raw HTML. That meant REPLACE was unsafe: changing the string length would invalidate the serialise prefix and PHP would refuse to unserialise the row. We wrote a one-off PHP script that read each row, unserialised, replaced the path, re-serialised, and wrote back. The script ran in 90 seconds against roughly 4,000 rows and produced a per-row diff log we kept for the receipt.

Template overrides

The override at templates/publisher/html/com_content/article/default.php had a hardcoded fallback that prepended /images/articles/ to any image found in the article's images JSON. After the migration that fallback was producing https://cdn.example.com/images/articles/https://cdn.example.com/... for a handful of articles. We removed the fallback (it had been added in 2017 for a reason nobody remembered) and ran a 50-article spot check across category pages. The full list of override conventions is documented in the Joomla 3 layout overrides guide, and template overrides are the most common place a migration like this falls over silently.

Watching the access log after cutover

The access log was the only telemetry we trusted to confirm the rewrite had landed. We added a small marker to the Apache rewrite, a response header X-Image-Origin set to disk when the on-disk file was served and cdn when the proxy fired. From Sunday morning we tailed the log with grep ' disk ' and watched the rate fall.

By Sunday afternoon the disk branch had dropped from a baseline of roughly 14 requests per second to a steady 0.3, almost all of them favicons and a forgotten /images/logo.png that a template fragment was loading as an absolute path. CloudFront's cache-hit ratio climbed from zero to 71 percent over the first day and settled at 94 percent by Wednesday.

We had been ready to revert the SQL block if the disk rate stayed flat, the rollback being a single REPLACE in the opposite direction on the same four tables. We did not need it. The numbers told us the proxy was doing the work and the SQL had reached the rows it needed to reach.

What we kept on disk for six weeks

We did not delete /var/www/html/images/ after the cutover. The proxy condition still served from disk if the file existed, so the on-disk copy remained the silent backstop. After six weeks of no images/ requests appearing in the access log that hit the disk branch, we ran a final aws s3 sync and rm -rf images/ in the same maintenance window.

The site's monthly outbound bandwidth dropped from 240GB on the host to 18GB on the host plus 220GB on CloudFront, billed at roughly one-eighth the rate. The backup window dropped from six hours to nineteen minutes. The publisher noticed none of it, which was the goal.

When we built Pier we ran into this exact shape of migration more than once, where the bulk copy was the easy part and the long tail of references inside serialised columns, custom fields, and template overrides ate the weekend. The way we ended up handling it was by running these audit queries inside the MySQL editor against a snapshot, with the version history of every rewrite so the operator sees the before-and-after of each UPDATE before it touches the live row.

If you have a legacy site groaning under its /images/ directory, the smallest thing you could do today is run the audit query against jos_content and jos_modules and write the result to a CSV. Once you know the shape of the references, the migration becomes a sequence. Until then, it is a guess.

— Questions —

Will the Apache proxy add measurable latency to image requests?

On cache-miss requests, yes: Apache fetches from CloudFront and re-emits. We measured roughly 40ms added on cold paths, and single-digit milliseconds on warm paths once the edge cached the file.

Why not rewrite the SQL first and skip the proxy entirely?

A bad UPDATE on 38,000 references can break every article at once. The proxy gave us a quiet rollback path: revert the SQL and the on-disk fallback still served the images.

Does the same approach work for Joomla 4 or 5?

The principle is identical. The jos_content.images column moved to a JSON type and the htaccess block is unchanged. K2 is unsupported on Joomla 4, so audit any K2 fork separately.

What about images uploaded after the cutover?

We patched the media manager to upload directly to S3 via a small plugin hooking the onAfterMediaUpload event. Articles authored after cutover never touched the host filesystem.