Skip to main content
← Case studies

Case study

Surplus Containers

Steel shipping containers from active ports, offered to the trade, with the inventory online and email campaigns to buyers.

Surplus Containers sells containers to the trade: dealers, haulers, storage yards and other buyers who move units in quantity. The work starts with a simple message, "we have this inventory, do you need it", and the old GoDaddy site had no way to send it or to take a reply.

I built two things behind one address. The public site shows a preview of the current inventory, gives a full table to customers who hold an access link, and takes inquiries. Behind it, a campaign system lets staff import their contacts, upload an inventory spreadsheet, compose an email around it, preview it with the unsubscribe footer in place, schedule it, and read the delivery results.

It went into use in early 2026 on the business's cPanel hosting as React apps over a PHP and MySQL API. In September 2026 I moved it to Vercel and Turso. The same React apps still run the screens, every API route was ported and replayed against requests recorded from the live PHP build, and the cPanel account stays for the mailboxes and as the way back.

What they wanted

Surplus had a GoDaddy site that handled no online work at all. They wanted a public inventory with a way for trade customers to ask about it, and a tool to reach the industry with what they have on hand.

Surplus is part of Midway Mobile Storage, where I was already the IT department. The same ownership group runs several of the businesses I build for, and I built this site and still run it.

At a glance

Client
The surplus container wing of Midway Mobile Storage, selling steel shipping containers from active ports to buyers across the industry.
Launched
Early 2026
Moved to the current platform
September 2026
Runs on
Next.js 16 route handlers on Vercel, serving the API and the page shells, The same React and Vite apps, React 19, Turso (libSQL), 21 STRICT tables, 62 API routes ported from PHP, SendGrid, with signed delivery events through a shared relay, One Vercel Cron job every five minutes, Cloudflare DNS, Vercel Web Analytics

What was built

The public site

A short public site built for buyers who already know what a 40-foot high cube is. It shows what is available, explains how to see the rest, and makes asking easy.

Inventory preview

The home page shows the top rows of the current inventory: location, container type, condition and the other columns staff choose to make public. It updates the moment a new spreadsheet is made active.

Full inventory by access link

The complete table sits behind a time-limited link that staff send to a customer or that a campaign email carries. Without a valid link the page says so plainly and points back to the contact form.

Inquiry form

Name, email, phone, company and a message. Every inquiry is saved in the admin before any email is attempted, so a mail problem never loses one.

Spam protection without a puzzle

A hidden trap field that browsers are told not to autofill, plus rate limits on the server. Customers never see a CAPTCHA.

Built for search

Each public page has its own title and canonical address, the sitemap lists only real pages, and branded error pages answer with their real status codes.

Screenshots show demonstration data on a local copy of the site, set up to show everything the design can do. No customer records appear anywhere in these pages.

Surplus Containers home page on a desktop screen with a logo, an inventory preview table of sample containers and a contact form, shown in full
The home page from top to bottom: the one-line pitch, the inventory preview from the active spreadsheet, and the inquiry form. Scroll inside the frame for the whole page.
Full inventory table listing sample containers by location, type, condition and price
The full inventory, opened from an access link, with the columns staff chose to make public. Sample data.
Full inventory page showing an error notice that a valid inventory link is required
Without a valid access link, the full inventory page refuses to load the table and says a valid link is required.
Send a Message form on the home page filled in with a sample buyer's details and question
The inquiry form filled in with sample details, before it is sent.
Surplus Containers home page on a phone with the logo, the headline and a contact prompt
The home page on a phone, opening with the pitch and a prompt to get in touch.
Full inventory on a phone, with each sample container shown as a labeled card
On a phone each container becomes its own card, with every column labeled, so a row reads without scrolling sideways.
Inquiry form on a phone filled in with sample details
The inquiry form on a phone, filled in with sample details before sending.

PageSpeed Insights

Median of at least 3 runs per device, 2026-10-06. Run it live ↗

Desktop

Performance
100 out of 100, Good
Accessibility
100 out of 100, Good
Best practices
100 out of 100, Good
SEO
100 out of 100, Good

LCP 328 ms · CLS 0 · TBT 0 ms

Phone

Performance
98 out of 100, Good
Accessibility
100 out of 100, Good
Best practices
100 out of 100, Good
SEO
100 out of 100, Good

LCP 1.5 s · CLS 0 · TBT 0 ms

What the owner runs

Behind the login

One admin at /admin for the owner, managers and sales staff. Each account carries its own set of permissions, and the server checks them again on every request.

Contacts and lists

Import the master contact list from CSV or Excel, search and filter it, and group contacts into named lists. Each address carries a delivery status, so a bounce or an unsubscribe follows it everywhere.

Inventory uploads, one active set

Upload a spreadsheet and it is kept as its own version. Exactly one version is active at a time, and staff can make an older one active again, choose which columns the public sees, and mint access links.

Campaign drafts and preview

Write a campaign from the inventory template or in a rich text editor, pick lists or individual contacts, and see the email as it will arrive. The footer with the unsubscribe link is added by the system, so no campaign can leave it out.

Scheduling and the worker

Pick a start time in Eastern time, a daily cap and a batch size. A worker sends in small batches every five minutes, stops at the caps, and pauses a campaign on its own if too many sends fail.

Results and suppression

Delivery, open, click, bounce and unsubscribe events come back from SendGrid and are tied to the campaign and the person. Hard bounces, spam reports and unsubscribes stop that address from being mailed again.

Inquiries

Every message from the public form, with a status, and a note of whether the notification email went out and why not if it did not.

Accounts

Role templates such as sales rep or inventory manager tick a set of permission boxes that can then be adjusted. A developer tier above administrator owns the system checks and cannot be removed by an administrator.

Admin campaign list with sample campaigns in draft, paused, completed and canceled states
Every campaign with its status, from drafts to completed sends. Sample data.
Admin master contacts screen with an import panel, list management and a table of sample trade contacts
Master contacts: import from CSV or Excel, search and filter, and group contacts into named lists. Sample data.
Admin inventory screen with a spreadsheet upload, earlier versions, a public column chooser and recent access links
Inventory: upload a spreadsheet as a new version, make one version active, choose the public columns and mint access links. Sample data. Scroll inside the frame for the whole page.
Create Campaign form filled in with a sample campaign, shown in full with the recipient selection and the save draft button
A new campaign in progress: name, subject, sender, caps and recipients, saved as a draft. Nothing sends from this screen. Scroll inside the frame for the whole page.
New campaign form with a live email preview beside it showing the inventory email header and first rows
The live preview beside the form, built from the active inventory as the campaign is written. The unsubscribe footer is added by the system to every campaign, below the fold here.
Campaign detail page for a sample draft with the Schedule Campaign panel and a start time entered
Scheduling a draft: a start time in Eastern time, a daily cap and a batch size. The worker takes it from there.
Campaign detail page for a completed sample campaign with delivery counts and recent logs
A completed campaign: delivery and engagement counts, and per-recipient logs with any suppression noted. Sample data.
Admin inquiries list with sample buyer inquiries and their statuses
Inquiries from the public form, each with a status and a note of whether the notification email went out. Sample data.

What it runs on

Two platforms, one site

The first version ran on shared cPanel hosting. The site now runs on the current platform, with the move recorded in the write-up.

Version 1, Early 2026

Hosting
GoDaddy cPanel shared hosting, Apache and PHP 8.3
Database
MySQL (MariaDB 10.11) on the same host
Deploys
Deploy zips built and checked locally, then uploaded
Campaign worker
A cPanel cron job every five minutes
Email
SendGrid for sending, mailboxes on cPanel
DNS
Cloudflare, in front of the cPanel host

Today, since September 2026

Hosting
Vercel, deployed on every merge to main
Database
Turso (libSQL), with constraints enforced in the schema
Campaign worker
One Vercel Cron job every five minutes, behind a secret
Files
No object storage; uploaded spreadsheets are kept in the database
Outbound email
SendGrid, every message tagged by site
Delivery events
Signed SendGrid webhook, received through a shared relay
Mailboxes
Seven mailboxes still on the cPanel host
DNS
Cloudflare; two records changed at the move, mail untouched
Analytics
Vercel Web Analytics on the public pages only
Way back
The PHP build and MySQL left in place on cPanel

What it does now

In use

  • •The owner, managers and sales staff use the admin for inventory, contacts, inquiries and campaigns.
  • •Inquiries from the public form and responses to campaigns come through.
  • •The campaign system works, and staff run campaigns less often than they could. Each one still takes several manual steps.
  • •Maintenance is mostly hands-off. I step in when something breaks or needs to change.

Next

  • Make campaigns easier to create and send, with fewer steps between a new inventory upload and an email to the right buyers.
  • Automate the routine parts of a campaign where it is safe to, so sending does not depend on someone finding the time.

“We can tell a list of buyers what just came into the yard in one sitting. The hard part was never the website, it was having a system the sales staff would actually use, and this is the first one that fits how they work.”

Owner, Surplus Containers

The business and what it needed

Surplus Containers is the surplus wing of Midway Mobile Storage. Midway Mobile Storage, in Winston-Salem, North Carolina, rents, sells and builds out shipping containers. Surplus handles the other side of the trade: steel containers sitting at active ports and yards around the country, offered to other businesses in the container trade.

I was already the IT department for Midway Mobile Storage, so when Surplus needed a website and a way to reach buyers, the work came to me. The same ownership group runs several of the businesses I build for. I built this site and I still run it.

The selling is trade outreach. Someone on the team has a spreadsheet of containers by location, type, condition and price, and the job is to put a simple message in front of the right buyers: we have this inventory, do you need it. Buyers who are interested write back or ask for the full list. That means two things have to work. The public has to be able to see what is available and ask about it, and the team has to be able to send that message to a list of trade contacts without mailing anyone who asked not to hear from them.

Put in the client's terms, Surplus had a GoDaddy site that handled no online work at all. They wanted a public inventory with a way for trade customers to inquire, and a tool to reach the industry with what they have.

What was there before

Before this build, Surplus had a basic GoDaddy site with no online operations behind it. It could not show inventory, it had no inquiry form feeding anything the team worked from, and there was nothing behind it for sending to a contact list. Whatever outreach happened, happened outside the website.

Version 1: the cPanel build

The repository began on December 29, 2025. By December 31 it had a PHP API with database migrations, a branded React front end with search basics, and an admin. The campaign sender was part of the first version from the start, because that was the reason the site existed. The site was in use in early 2026.

Version 1 ran on the business's GoDaddy cPanel hosting:

  • •A React single-page app, first built with Create React App.
  • •A PHP 8.3 API under /api.
  • •MySQL, in the form of MariaDB 10.11, on the same host.
  • •SendGrid for every outgoing email.
  • •A cPanel cron job that called the campaign worker every five minutes.
  • •Deploys as zip files, built and checked on my machine and then uploaded.
  • •Cloudflare in front for DNS.

The first weeks of January 2026 filled in what a real campaign system needs:

  • •January 2: the SendGrid event webhook was secured with signature checks, and the deploy zip gained its own checks.
  • •January 6: inventory spreadsheets could be uploaded as Excel files as well as CSV, and staff could choose which inventory columns were public.
  • •January 9: delivery events from SendGrid were ingested safely, with delivery metrics per campaign and uploads that respected suppression.
  • •January 15: per-person admin permissions and the dashboard.

Through the spring the work was hardening: campaign completion summaries, city and state handling for inventory and safer contact search in March, then fixes to tokenized inventory links in campaign emails and branded public error pages in April.

In May I moved the front end from Create React App to Vite on May 7, then split it into two separate apps on May 22: one for the public site and one for the admin. The public app stays small, and nothing from the admin ships to a visitor. The same month added the Inquiries screen, a visual rich text editor for campaign emails, system checks for the admin, and the first integration of the shared backend and admin kits I use across the family of client sites.

From July 19 every change went through a pull request. In August:

  • •PR #15 added a test suite that runs against a real database as a regression gate, and PR #16 finished moving the backend onto the shared backend kit once those tests were in place.
  • •PR #26 added SEND_EMAILS, a single switch that stops all outgoing mail.
  • •PR #28 moved the worker token out of the query string, so it is never written to an access log.
  • •PR #35 put the full inventory behind an access token by default, and raised an alert whenever a saved inquiry failed to send its notification.
  • •PR #39 fixed five pages that returned a 404 when loaded directly, and added a guard against the next one.

Why the site moved, and what changed

By September 2026 the other family sites were moving off cPanel onto Vercel and Turso, and Surplus was due. Shared hosting meant manual zip deploys and a server I could not see into. Moving it meant the same deploy model, the same checks and the same monitoring as every other site I run.

The difficulty was that this is a mailing system. A port that behaved slightly differently from the PHP build could send to the wrong people, send twice, or ignore an unsubscribe. So the move was built around one rule: record exactly what the live system does, then prove the new one does the same.

Phase 1: record the old system (PR #65)

Before writing any of the new server, I recorded the live PHP API. The capture holds 359 real requests and their answers, 268 helper-level parity cases, and 52 probes of how Apache itself answered addresses. Alongside it went the Turso schema, a study of how the old system stored times, a runbook, a plan, and a list of questions for the owner. The work was done by September 16 and merged on September 21.

Fixing the live site first (PR #75)

Recording the system closely turned up problems in it. I documented 26 production bugs, each with evidence. Sixteen were fixed on the live cPanel site before the port began, each one checked against its own recorded difference, and deployed on September 26. The worst were these.

  • •Campaigns fired early. The schedule form took a time typed in Eastern and stored it as if it were UTC, so a campaign set for seven in the morning went out at three. Some campaigns went out the moment they were scheduled. Typed times are now read as Eastern, stored as the exact UTC instant, and shown labeled ET.
  • •The shared webhook let other sites suppress Surplus customers. The SendGrid account's event webhook pointed at this site, so it was receiving delivery events for every site on the account. Events about other sites' mail landed in the Surplus database, and a bounce or unsubscribe from another site's message could stop Surplus from mailing that address. The addresses suppressed in production had gotten there that way.
  • •The maintenance switch did not cover the home page, which is the page that matters most.
  • •One malformed request could make the public contact form fail with a server error and email support about it.

Phase 2: the public site and platform (PR #76)

I created the Vercel project and the Turso database before porting the admin, so every phase ended on a real preview deployment and platform differences showed up early. One example: Next.js reserves the address /404.html, which the old site used. Phase 2 ported the request dispatcher, the inventory reads, the contact form, the page catch-all and the maintenance switch, and replayed the recorded requests both in process and against a real preview. It merged on September 27.

Phase 3: everything behind the login (PR #78)

Phase 3 ported every admin route, the campaign worker, the signed webhook, and a new developer tier for accounts. The replay answered 357 of the 359 recorded steps identically. The other two are page addresses. It merged on September 29.

Phase 4: the data move and the cron (PR #80)

Phase 4 built the data move tool, a small exporter that runs on the cPanel host, the Vercel Cron job and the production environment. It also merged on September 29.

Phase 5: the cutover

The cutover ran on September 29 at 21:39 UTC from a written runbook. 25,600 rows moved, with none refused and every foreign key resolving. The database's twelve counters were preset so new rows continue where the old ones stopped. Every stored spreadsheet was checked by digest after the move.

Then DNS. The Cloudflare zone holds 33 records. I changed two of them, the bare domain and www, and diffed the zone before and after to confirm every mail record was identical. After the switch the inventory preview matched the cPanel site's byte for byte on the same data, the first contact submission recorded its notification as sent, and the admin test email arrived.

Same-day fixes and the weeks after

A move this size surfaces things. The same day:

  • •PR #81 added the operational alerts the port was missing, and PR #89 fixed their throttle so one alert could not silence an unrelated one.
  • •PRs #85 to #87 fixed outgoing mail. SendGrid rejects a message whose plain-text part does not come first, transactional mail no longer carries a tracking pixel, and the contact notification was restored with replies going to the customer.
  • •PR #88 added a schema drift check after production turned out to lack the developer column on cutover day.

Part of that mail work was the spam score. The contact notification was scoring higher than it should with spam filters, so I rebuilt it as a whole, plain HTML document with a text part and no tracking, until it scored clean. I also found that the receiving mail server was set to discard anything that scored 5.0 or above, which could silently lose a real inquiry, and switched that rule off.

On September 30, PRs #95 to #98 held the sitemap to real pages, gave each public page its own canonical address, made status pages answer with their real status codes, added Vercel Web Analytics to the public pages, and rewrote the privacy page to name every service that handles visitor data.

The public site

The public site is deliberately small. Its visitors are buyers in the trade who already know what a 40-foot high cube or a one-trip container is.

Home page

The home page opens with the logo and one line: steel shipping containers from active ports, delivered nationwide to project sites. Under it is a short contact prompt, then the inventory preview, then the contact form.

The preview shows the top rows of the active inventory, a dozen by default. Staff choose which columns appear, so location, container type, condition and price can be public while internal notes stay private. Under the table is the time of the last upload. Because the full inventory requires an access link, the main button reads "Request full inventory access" and jumps to the form.

Full inventory

The full table lives at /inventory and needs a valid access token in the address. Staff mint those links in the admin, each expiring after three days by default and up to fourteen, and campaign emails carry their own. Without a valid link, the page says that one is required and where to find it. The rule fails closed: if the setting is missing, the token is required.

On a phone the table changes shape. Each container becomes its own card, with every column labeled down the left, so a buyer can read a row without scrolling sideways.

Inquiries

The form asks for name, email, phone, company and a message. A hidden trap field catches bots. Browser autofill once filled that field and made real people look like bots, so browsers are now told to leave it alone. The server allows each network address three submissions in ten minutes.

The inquiry is saved before any email is attempted. If the notification to the team cannot be sent, the visitor still sees that the message was received, the inquiry still appears in the admin, and the admin records why the email did not go. That order was a deliberate choice: the inquiry is the thing that matters, and email is only how the team hears about it.

The rest

Privacy and terms pages, branded pages for missing addresses, access denied, server errors and maintenance, a robots file, and a sitemap that lists only the pages a visitor can actually use. Mixed-case addresses and /index.html redirect to the canonical address. The maintenance switch takes down the public pages and leaves the admin and API up, so the team can keep working during maintenance.

The campaign system, step by step

The campaign system is standalone software that happens to sit behind the website. It follows the order a person on the team works in.

1. Import contacts

Master contacts are imported from CSV or Excel on the Contacts screen. Each contact is unique by email address and can carry a company, contact name, phone, business type, state or region, location, tags, notes and where the contact came from. The importer matches column headings regardless of case, spaces and underscores, so "Contact Name", "contact_name" and "CONTACTNAME" all land in the same place. Contacts are grouped into named lists, such as a region or a kind of buyer.

Every address also has a delivery status: active, hard bounced, dropped, blocked, reported as spam, or unsubscribed. That status belongs to the address, so it follows the person into every list and every campaign.

2. Upload inventory

An inventory spreadsheet needs four columns, location, container type, condition and price, and can carry more, such as quantity and port notes. Each upload is kept as its own version, with the original file stored in the database. Exactly one version is active at a time, and the database itself refuses a second. Staff can make an older version active again, and choose which columns are public and which appear in email.

3. Draft a campaign

A new campaign has a name, a subject with presets, a preheader, the sender's name and address, a reply-to address, the SendGrid unsubscribe group, caps, and notes. There are two ways to write it:

  • •The inventory template builds the email from the inventory at send time, so the body is always the current list.
  • •Custom mode takes an email written in the rich text editor, with a plain-text version beside it.

Recipients come from contact lists, individual contacts, or both, and the screen shows how many of them can actually be sent to. A recipients spreadsheet can be uploaded instead, and a campaign can carry its own inventory spreadsheet. Every recipient set is rebuilt inside one database transaction that enforces the campaign cap, and suppressed addresses are kept on record and never queued.

A campaign is saved as a draft. Nothing sends from this screen, and the screen says so.

4. Preview

Beside the form is a live preview of the email as it will arrive, with links disabled. The footer with the unsubscribe link is added by the system for every campaign. An author cannot leave it out, edit it away, or forget it.

5. Schedule

On the campaign's own page, staff set a start time in Eastern time, an optional daily cap and a batch size. The time is stored as the exact UTC instant and shown back labeled ET. If the campaign uses the inventory template and has no rows of its own, the active inventory is copied into the campaign at that moment, so the email reflects the list that was live when the campaign was approved. Once sending has begun, the schedule is locked.

6. The worker

Every five minutes Vercel Cron calls the worker with a secret. The worker checks two switches before doing anything. SC_SENDING_ENABLED decides whether the worker runs at all, and SEND_EMAILS decides whether any email can leave the system. Then, for each campaign whose start time has passed:

  • •If today's sends have reached the daily cap, it waits for tomorrow.
  • •It claims a small batch of queued recipients in one write transaction, so two overlapping runs can never claim the same person. A unique constraint in the schema backs that up.
  • •It sends the team one internal copy of the campaign.
  • •For each recipient, it checks the address's delivery status first. Anything other than active, or a status it cannot read, means the message is suppressed.
  • •Where the inventory is behind access links, each recipient's email is rendered with its own link.
  • •A recipient is given up after three failed attempts.
  • •If more than one in five of the last fifty attempts failed, the campaign pauses itself.
  • •When nothing is left in the queue, the campaign is marked complete and a summary goes to the team.

If the sending switch is turned off partway through a run, the claimed row goes back in the queue and the run stops. The same worker can be run by hand with a separate token, which kept the old cPanel cron's contract working during the move.

7. Suppression

SendGrid posts every delivery event back to the site, from delivered and opened to bounced and unsubscribed. Each event is checked against SendGrid's signature, de-duplicated, and tied to a campaign and a recipient.

A hard bounce marks the address and suppresses it in every campaign where it is queued. Three soft bounces in a row do the same. Drops, blocks, spam reports and unsubscribes mark and suppress the address. A later delivery never revives a suppressed address, and a group resubscribe only reverses a group unsubscribe. SendGrid's own account-level suppression applies on top.

8. Results

The campaign page shows its status, the delivery and engagement counts, and recent logs per recipient, with a plain summary of any error and the technical detail behind a disclosure. Staff can refresh delivery status, pause, resume or cancel. Events that cannot be tied to anything are kept and listed on their own screen.

What stays manual

The system works, and a campaign still needs a person at every step: building lists and importing spreadsheets, choosing the audience, composing, scheduling, pausing and reading the results. Sending a test batch of fifty or fewer before the full list is a written habit, and the code does not enforce it. That is the main reason campaigns go out less often than they could, and it is where the next round of work goes.

What the owner runs

The admin is used by the owner, managers and sales staff. Besides the campaign screens, it has:

  • •A dashboard with cards for campaigns, the inventory snapshot, contacts and lists, and a deal closer calculator: type a quoted price and get the discounted price from a stored setting, in percent or dollars off.
  • •Inquiries, with a status for each and the notification result.
  • •Settings and user management.

Accounts and permissions

There are seventeen permission keys, from viewing inquiries to deleting any campaign. Accounts come in three tiers, each containing the one below:

  • •A user has only the permissions ticked for them.
  • •An administrator has all seventeen.
  • •A developer is an administrator who also owns the system checks and the test email.

Role templates such as read-only viewer, sales rep, inventory manager and ops manager simply tick a set of boxes, which can then be adjusted. An administrator cannot edit, disable or demote the developer, and the system refuses to remove the last developer. A breakglass route exists so the developer can always get back in, and it answers as if it does not exist unless it is switched on with a token kept outside the repository. Surplus was the pilot for this tier across the family sites. Accounts that have been disabled for six months are deleted by the worker.

Sign-in uses a signed session cookie that expires after two hours idle, with throttling per address and per account. Password reset works by email.

Infrastructure

  • •Domain and DNS: surpluscontainers.com in a Cloudflare zone. The bare domain and www point at Vercel without the Cloudflare proxy, and www redirects to the bare domain.
  • •Hosting: a Vercel project on the JAMARQ Digital team, deployed from the repository on every merge to main.
  • •Server: Next.js 16 route handlers. They answer every /api call through a table of 62 routes, ported from the PHP dispatcher in the same order, and serve the built page shells of the two React apps, which draw the screens in the browser.
  • •Database: Turso (libSQL), 21 STRICT tables. Every time column is checked to be UTC, status fields are checked against their allowed values, and the rule of one active inventory upload is enforced by the database.
  • •Files: no object storage. The uploaded spreadsheets are small, so they are stored in the database, and uploads are capped at 4 MB to fit Vercel's request limit.
  • •Outbound email: SendGrid, called from one function that every send path goes through. Every message is tagged with the site's name. Open and click tracking is on for campaigns and off for transactional mail.
  • •Delivery events: a signed SendGrid webhook. The account's single webhook now points at a relay on jamarq.digital, which passes each event to the site it belongs to.
  • •Scheduled work: one Vercel Cron job every five minutes, refused without its secret.
  • •Mail: the seven mailboxes stay on the cPanel host. Moving them is a separate job with its own risks, so the cutover left every mail record alone, and the cPanel account is kept for them.
  • •Way back: the PHP build, the deploy zip and the MySQL database are still on cPanel. The old cron job is commented out and can be restored.
  • •Analytics: Vercel Web Analytics on the public pages only. The admin is not tracked.
  • •Security headers: strict transport security, a restrictive content security policy and the usual hardening headers, carried over from the old Apache configuration.

Decisions worth explaining

Record the old system before replacing it

A mailing system is the wrong place to discover a difference in production. Recording 359 real requests, and replaying them against the port in process and on a real deployment, turned "it seems to work" into a list of exact matches and two explained exceptions. It also found the bugs that were already live.

No object storage

The site had empty storage buckets set up for it and a handful of small spreadsheets to keep. Wiring up storage would have added another service, another key and another thing to break, to hold files measured in kilobytes. The spreadsheets live in the database, and the upload cap keeps them inside what Vercel accepts.

Write transactions where the old system locked rows

MySQL let the PHP worker lock rows while it claimed a batch. Turso has no row locks, so the batch claim, the cap check, the campaign lock and the password reset each run as one write transaction, and a unique constraint on the send queue backs that up if two runs ever overlap.

Rules in the schema

Times, statuses, the single active upload and "a developer must be an administrator" are enforced by the database, with a test script that runs 65 accept-and-refuse cases against it. The code still checks, and the database is the backstop when the code is wrong.

One webhook for the whole account

The webhook bug came from one client site receiving every site's delivery events. On September 24 I moved the account's webhook to a neutral relay on jamarq.digital that passes each event to its own site. Every send is tagged with its site, an event naming another site is dropped, and an untagged event is kept only if this site actually mailed that address. That freed a webhook slot and stopped Surplus from being the single point of failure for every site's delivery data.

Keep events nothing claims

The PHP build quietly dropped later delivery events for messages it could not attribute. The port stores them as unmatched and lists them on their own screen, so nothing about delivery disappears without a trace.

A developer tier

The test email and system checks touch configuration and real mail. Putting them in a tier above administrator, with guards so the last developer cannot be removed, means a well-meaning administrator cannot lock out the person who keeps the system running.

A schema check outside CI

Production's schema is compared with the schema file using a read-only token, as a runbook step. Running it in CI would have meant putting production credentials into repository secrets. Its first clean run was on October 4, 2026.

What it does now

The site has been live on Vercel and Turso since September 29, 2026. The owner, managers and sales staff use the admin for inventory, contacts, inquiries and campaigns, and inquiries and campaign responses come through.

As of early October, no campaign has been sent from the new platform yet. The worker has run every five minutes since the move, checked without a gap on October 2, and has had nothing due. Every campaign so far went out from the cPanel build. The system works, and staff run campaigns less often than they could, because each one still takes a person through every step.

The next round of work is aimed squarely at that: making a campaign easier to create and send, with fewer steps between a new inventory upload and an email to the right buyers, and automating the routine parts where it is safe to.

PageSpeed results for desktop and mobile are measured and published on this case study page, with a link to run the test live.

Ongoing support

Maintenance is mostly hands-off unless something breaks.

  • •Every change, from a dependency update to a fix, goes through the verify job on my self-hosted runners before it can merge.
  • •A weekly sweep checks every dependency for new security advisories. Since October 4 an advisory in a development-only tool is reported without blocking a deploy, and advisories in shipped code still block.
  • •Operational alerts cover server errors, worker failures and contact notifications that fail to send, throttled so a burst of one cannot drown out another, and gated so a preview deployment never pages anyone.
  • •The schema drift check runs as a runbook step whenever the production schema changes.
  • •The repository's history is complete from the first commit, with every change since July 2026 in a pull request.

Services this shows

Want something like this for your business?

Send the current site, the problem, and what should become easier. You will hear back within one business day.