# Case Study: A 12-Year-Old Joomla Store Rebuilt as a Static Site

_Published 2026-09-29 · Updated 2026-09-29 · Case Study · https://websitesupgrade.com/guides/case-study-joomla-to-static/_

> Rebuilding a small Joomla site as a generated static site worked because the requirements were simple: a dozen pages, a contact form, and a newsletter signup. The honest caveat is that the store part was deliberately not rebuilt — this was a scope reduction, not an e-commerce migration, so do not read it as proof that your WooCommerce shop can go static. First action for a similar project: archive the old site's content before you touch anything else; in this case it only existed in the Wayback Machine.
> 

simplyjems.com is a small Canadian artisan jewelry site. Its domain was registered around 2014, and for most of its life it ran as a Joomla store with VirtueMart handling the catalog. By the time the rebuild started, the store had been offline for years — the only recoverable copy of the content was a Wayback Machine archive from 2015-2016.

In five working sessions over September 18-23, 2026, the site became a generated static site: Markdown files and templates built by a Python script, deployed by GitLab CI to ordinary shared hosting, with Cloudflare in front and a single PHP file for forms. This is the account of what that actually involved — including the parts that broke — for anyone weighing the same move. The strategic case is covered separately in our [WordPress vs. static comparison](/guides/wordpress-vs-static-site/); this is the engineering record.

## What we started with

The old site was not just dated — it was gone. Joomla and VirtueMart had powered a real store, but the hosting had lapsed and the only surviving copy was the Internet Archive's snapshots. That meant the first step was not migration but archaeology: pulling the archived pages, extracting the text that still mattered (product descriptions, the about page, contact details), and accepting that the catalog itself would not come back.

That last point matters. The rebuild deliberately changed the site's scope from "store" to "brochure and editorial site." Every decision that follows looks easy partly because no shopping cart, checkout, or customer accounts had to be recreated.

## Why static plus CI

The requirements after the scope cut were modest: about a dozen pages and articles, a contact form, a newsletter signup, and near-zero maintenance appetite. The chosen stack:

| Layer | Choice | Why |
|---|---|---|
| Content | Markdown files with YAML metadata | Editable by anyone comfortable with a text file |
| Layout | Jinja2 templates + shared nav config | Header/footer written once, not per page |
| Build | `tools/build.py` (Python, four pinned dependencies) | Produces the whole site as plain files |
| Validation | `tools/check.py` | Fails the build on broken links, oversized images, bad metadata |
| Deploy | GitLab CI on push to `main` | `lftp` mirror to the web root over SFTP |
| Hosting | DreamHost shared hosting | Already paid for; static files need nothing fancy |
| Edge | Cloudflare (DNS, TLS, cache purge) | Free tier covers it |
| Forms | One `contact.php` endpoint | The only PHP on the site |

The deploy mirrors `dist/` to the web root with `--delete`, so rollback is `git revert` plus a push — whatever the bad commit added, the next deploy removes.

## The part worth stealing: mechanical enforcement

The most durable decision was putting the site's rules in `check.py` instead of in anybody's memory. Every build is verified for, among other things:

- Every image under 250 KB; article images exactly 1200x800 pixels
- Article images unique — checked by both URL and file bytes
- Internal links that resolve; exactly one title, description, and H1 per page
- Sitemap, RSS feed, and `.htaccess` coverage for every page
- Form markup contract — honeypot field and Turnstile widget present and consistent
- Article body word counts and meta description lengths

The enforcement is why the site stays fast: nothing depends on remembering a rule. When a duplicate hero image once slipped through because it had been re-encoded — same photo, different bytes, so the hash check missed it — the fix was not "be more careful" but extending the check to catch it.

## URLs and redirects: the part that protects rankings

The rebuild changed every URL on the site — from Joomla's structure and legacy `.html` pages to trailing-slash directories like `/about/`. Two mechanisms cover the old addresses:

1. **Named legacy redirects.** A `redirects.yaml` maps the URLs that still mattered — `/bracelets`, `/earrings`, `/necklaces` to `/gemstones/`; `/jewelry-for-girls` to `/about/` — into real 301 rules.
2. **Generated redirects.** The build emits a 301 for every public URL's `.html` form, so `about.html` permanently forwards to `/about/` without anyone writing a rule.

The CI pipeline then *proves* it works: a smoke test asserts the homepage and sitemap return 200 and that `/about.html` returns a 301 — and a tokenless POST to the form endpoint must return 403, which verifies the spam defenses are alive. The full playbook for this kind of URL change is our [redirect checklist](/guides/url-change-redirect-checklist/).

## Forms without a CMS

The one thing static files cannot do is receive a POST. The solution is a single PHP file on the shared host — the same PHP that was already there — handling both contact and newsletter submissions. It layers a honeypot field (bots fill it, humans never see it) and Cloudflare Turnstile verification checked server-side, then mails the contact form and appends newsletter signups to a CSV stored above the web root. Submissions work without JavaScript via a plain redirect; with it, they post via `fetch`. The reasoning behind that anti-spam stack is in our [contact form spam guide](/guides/contact-form-spam/).

## What actually went wrong

The honest bits:

- **Cloudflare blocked the image fetcher.** The Pexels API client got a 403 (error 1010) on every call — Cloudflare bans Python urllib's default user agent. Fixed by sending a browser User-Agent; a debugging hour on a one-line problem.
- **TLS mode mattered.** Cloudflare's "Flexible" SSL would have caused redirect loops against the host's own certificate; "Full" mode avoided that. The choice is recorded in the work log as deliberate, not discovered the hard way — this time.
- **A `.env` typo broke secret loading.** One line used `:` instead of `=`, silently dropping a credential. Small, but the kind of thing that eats an afternoon.
- **A duplicate image evaded the hash check** (above) — the validator got smarter.

None of these are horror stories, which is roughly the point: the failures were plumbing issues found by checks and smoke tests, not weeks of CMS archaeology.

## Was it worth it

For this site, yes — the result is a site whose entire surface area is static files plus one PHP endpoint, on entry-tier shared hosting, where "deploy" means pushing a commit and "the site got hacked through a plugin" is no longer a category of problem. The caveats are equally real: there is no admin panel, content edits mean editing files or Markdown, and the build tooling needs a developer occasionally. And to repeat the caveat from the top: the store was not migrated, it was retired. If your site takes payments, that is a different project.

If your site is similarly sized and you are weighing rebuild vs. repair, the [refresh-or-rebuild test](/guides/refresh-or-rebuild-test/) will put a score on it, and the [tools page](/tools/) has the triage wizard for diagnosing what is actually wrong today.

— Editorial team. Facts current as of 2026; we revise guides when the ground shifts.

<!-- sources:
- simplyjems repo records (owner-approved, all recorded): COMPLETED.md session log Sept 18-23 2026, PLAN.md (Joomla/VirtueMart history, ~2014 domain, Wayback recovery), .gitlab-ci.yml (lftp mirror, purge, smoke tests), tools/check.py (250 KB/1200x800/word-count rules), tools/build.py, src/static/contact.php, redirects.yaml (4 named legacy redirects)
- No Lighthouse scores, page weights, or build times were recorded in the repo, so none are claimed here.
- Cloudflare TLS modes: developers.cloudflare.com/ssl/origin-configuration/ssl-modes/ (Flexible vs Full — docs referenced in SJ COMPLETED.md session notes)
-->

## FAQ

**How long did the rebuild actually take?**

Five working sessions across September 18-23, 2026 — from DNS takeover to a CI-deployed site with seven published articles. A first article-writing pass ran alongside the engineering work.

**What happened to the old store pages?**

The VirtueMart catalog was not rebuilt — the business no longer sells online. Named legacy URLs like `/bracelets` and `/earrings` redirect to the closest current page, and every old `.html` address gets a generated 301 to its trailing-slash equivalent.

**Would this approach work for a bigger site?**

The architecture scales fine — a static build handles hundreds of pages comfortably. The constraint is editorial: content that changes daily or needs an admin panel for several editors belongs in a CMS, which is the trade-off our [WordPress comparison](/guides/wordpress-vs-static-site/) walks through.

## Related

- https://websitesupgrade.com/guides/wordpress-vs-static-site/
- https://websitesupgrade.com/guides/url-change-redirect-checklist/
- https://websitesupgrade.com/guides/leaving-joomla-migration-plan/
