Case study · An events and entertainment brand
Moving an events brand off WordPress, with one gated path to production
We moved an events and entertainment brand off WordPress onto one application whose only road to production is a test-and-audit gate, and resolved every legacy URL that search engines reported as missing.
At a glance
- Sector
- An events and entertainment brand
- Duration
- since August 2025
- Our role
- design, build, deployment and operation of the brand's web platform; product, legal and accounting decisions stay with the client
- Team
- TQM Invest, two engineers over the engagement
- Next.js
- React
- TypeScript
- Prisma
- Postgres (Neon)
- Vercel
- GitHub Actions
- Vitest
- Cloudflare
Headline results
- One gated path to production since July 2026. Every production deploy passes a dependency audit and the unit suite; lint joined the gate on September 6, 2026.
- 21 high and 3 critical dependency advisories brought to zero so the audit gate could be switched on.
- All 28 legacy URLs that search reported as not found were resolved: 24 permanent redirects and 4 deliberate 404s, held in place by tests.
The situation
When we started, the business lived on two unrelated websites. The brand ran on WordPress with WooCommerce, and event tickets were sold as shop products under URLs that had been indexed for years. A second line of business ran on a separate JavaScript app that we had built in 2025, with its own database host and its own deploy.
Neither site had versioned releases or a way to roll back, and two stacks meant two dependency trees to patch. We built one Next.js application to replace both. It went live in July 2026, the day WordPress was switched off, with the second app moved in as a section of the main site.
Two things had to hold through the move. Old ticket and event pages were still in search results, so their URLs could not simply break. And a broken release costs a ticket sale on the night, so production needed a gate that a red test run could not get past.
What we did
Made one workflow the only road to production
The hosting platform deploys the main branch on every push, in parallel with CI. That lets a failing run ship. We wanted to keep the platform's pull-request previews and take away only its production auto-deploy.
Production Git deploys are off in the platform config. One GitHub Actions workflow runs the checks and, only on main or a manual dispatch, deploys with the platform's CLI. Superseded test runs are cancelled; production deploys queue and are never cancelled half way. The pipeline went in one day before go-live. An illustration of its shape:
on:
push:
branches: [main, development]
paths: ['src/**', 'prisma/**', 'public/**', 'package*.json',
'next.config.ts', 'eslint.config.mjs', 'vercel.json',
'.github/workflows/ci.yml']
jobs:
checks:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: npx prisma generate
- run: npm run lint
- run: npm test
deploy:
needs: checks
if: github.ref == 'refs/heads/main'
concurrency: { group: deploy-production, cancel-in-progress: false }
runs-on: self-hosted
steps:
- run: npx vercel deploy --prod --yes --token="$VERCEL_TOKEN"
The audit step could not be switched on until the dependency tree was clean. It started at 21 high and 3 critical advisories in production dependencies. We brought that to zero with scoped overrides pinned by caret range, without npm audit fix --force, which at one point proposed downgrading the framework by seven major versions. Since then every production deploy has passed the audit and the unit suite, including an urgent framework security patch in September 2026.
Found the ways the gate stopped seeing changes
A gate can fail by letting something bad through, or by no longer running at all. The second kind raises no error, and we met it three times.
The paths allowlist. A large documentation folder lives in the repository, and docs-only commits should not cost a production deploy, so the workflow runs only for listed paths. A path missing from that list means a change runs no CI and never deploys. It happened twice. public/** was missing, so a search-engine verification file was merged and then returned 404 in production; we added it on August 14, 2026. Later eslint.config.mjs was missing, so changes to lint rules ran nothing; we added it on September 6, 2026. The project notes now say that the CI allowlist and the preview-skip list are both kept by hand, so any file that changes how the app is built or gated goes into both in one commit.
Lint outside the gate. Lint was not part of CI until September 6, 2026. By then a house style rule in the customer copy had drifted to 31 violations, and npm run lint had been failing on a vendored editor plugin for some time. We fixed both and made lint a gate step that day.
Runners that never started. A release pull request showed the test job failing in two seconds with an empty log. Only the check-run annotations, fetched through the API, said the job had never started, for a reason outside the repository. With production auto-deploy turned off, CI, deploys and merged-branch clean-up had all stopped. On September 26, 2026 we registered a repository-scoped self-hosted runner as a user service on a dedicated server, with the registration token piped over SSH and never printed, and the pipeline was moving again the same day.
Left WordPress without losing search
Three weeks after the site was verified in the search console, it reported 28 not-found pages. Every one was a legacy WordPress URL, mostly old ticket products.
A catch-all redirect for the old shop paths would have been one line. But some old slugs have no live equivalent, and redirecting them to a generic page turns an honest 404 into a soft 404, which search engines report as an error. A catch-all would also hide the next broken link.
We wrote an explicit map of 24 permanent redirects (event posts, ticket products, static and legal pages) and a list of 4 paths that are meant to 404, kept in code so they are not redirected later by accident. Five /wp-* paths answering 403 were left alone: that is the edge firewall blocking WordPress probes. The redirects emit an explicit 301, because the framework's permanent: true emits 308. Then we tested the map as data. An illustration of the checks:
describe("legacy redirects", () => {
const sources = new Set(REDIRECTS.map((r) => r.from));
it("never chains", () => {
for (const { from, to } of REDIRECTS) expect(sources.has(to), `${from} -> ${to}`).toBe(false);
});
it("leaves intentional 404s alone", () => {
for (const path of INTENTIONAL_404S) expect(sources.has(path), path).toBe(false);
});
it("only points at pages the sitemap lists", async () => {
const listed = (await sitemap()).map((e) => new URL(e.url).pathname);
for (const { to } of REDIRECTS) expect(listed, to).toContain(to);
});
});
The next edit that creates a chain, redirects a page we chose to retire, or points at a page the sitemap does not list now fails a test before it can deploy.
Results
| Measure | Before | After |
|---|---|---|
| Live sites and deploys | 2 (WordPress shop, second app) | 1 |
| Paths to production | Platform auto-deploy, ungated | 1: CI with audit, lint (since September 2026) and tests |
| Production dependency advisories, high / critical | 21 / 3 | 0 / 0 when the gate went on |
| Legacy URLs reported as not found | 28 | 0 unresolved: 24 redirects, 4 deliberate 404s |
| Ways the gate stopped running, found and fixed | Unknown | 3: allowlist gaps, lint missing from CI, runners that never started |
Related case studies
An event-ticketing platform
A multi-tenant ticketing platform: zero-downtime releases and a payment path tested against double charges
We build and run a multi-tenant ticketing platform whose largest release applied eight database migrations in under 20 seconds and served the old code on the new schema for about 2.5 minutes with zero errors.
Eight migrations in under 20 seconds in the September 2026 release, then about 2.5 minutes of old code on the new schema with zero errors and production answering 200 throughout, as observed in logs and monitoring.
- Next.js
- TypeScript
- PostgreSQL
- Prisma
- Stripe
Our own tooling
A validated security sweep for production web apps, without a scanner bill
We built a repeatable security sweep for the web apps we run and maintain, and it reports only what it can prove: every finding comes with a file and line or a reproduced request.
Three production web apps swept, code and live, each in under ten minutes of collection.
- gitleaks
- trivy
- semgrep
- nuclei
- Docker
Our own infrastructure
How we run our own infrastructure as code
We rebuilt the pipeline for our own Terraform estate so that a merge only ever produces a plan, and every apply, destroy check included, works on the plan file we reviewed.
Four near-duplicate CI workflows (about 1,240 lines) replaced by one caller and one reusable stack workflow, and no stack applies on merge.
- Terraform
- GitHub Actions
- AWS (OIDC)
- Cloudflare
- New Relic
Running into something like this?
Thirty minutes, no commitment. Bring the problem; we will tell you what we would check first and whether we are the right people for it.