DEV Community

Cover image for How I Moved a Live Laravel SaaS to a New Server With 2 Minutes of Downtime
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

How I Moved a Live Laravel SaaS to a New Server With 2 Minutes of Downtime

Originally published at hafiz.dev


Last Thursday morning, I moved a production Laravel SaaS with paying customers from one server to another. The maintenance page was up for 1 minute and 55 seconds. I know the exact number because I checked the nginx access log on the new box afterwards: old server frozen at 09:19:59 UTC, first live request served by the new one at 09:21:54. The whole server migration, planning included, took one morning.

The app is Prompt Optimizer, a prompt optimization tool with a free tier doing tens of thousands of optimizations a month and a paid tier billing real money through Stripe. So this wasn't a hobby project where downtime means nobody notices. A botched cutover here means failed checkouts and a Chrome extension that stops working for people who paid for it.

This post is the full playbook: why I moved, how the cutover stayed under two minutes, and more importantly, the four things that almost broke silently. Because the rsync commands are the easy part. The dangerous part is everything that lives outside your app directory.

Why I moved at all

The old server was a 1GB DigitalOcean droplet hosting several apps at once. It worked fine until the app grew, and then it didn't.

Two incidents forced the decision. First, PHP-FPM worker starvation: my optimize endpoint calls an AI provider that can take up to 45 seconds to respond, and with pm.max_children = 5, five slow AI calls meant the entire site stopped answering. Health checks stayed green the whole time, which made it worse. Second, an earlier out-of-memory crash had already forced me to ban every-minute cron jobs on that box, which quietly meant my Laravel scheduler never ran in production. More on that later, because it hid a broken feature for weeks.

You can patch around a resource ceiling for a while. Raise a worker count here, cache a query there. But when the fixes start fighting each other, the honest answer is a bigger box. I went with a small Hetzner server with 4GB of RAM and two vCPUs, which I compared against the managed options in Laravel Cloud vs Forge vs a plain VPS. For a solo developer running multiple small apps, the VPS still wins on cost by a wide margin.

The unfair advantage: Cloudflare was already in front

Here's the thing that made a two-minute cutover possible at all: the domain was already proxied through Cloudflare.

When your DNS is proxied, visitors never connect to your server's IP directly. They connect to Cloudflare's edge, and Cloudflare connects to whatever origin IP you've configured. Changing that origin is a dashboard edit that takes effect in seconds. No TTL waiting, no propagation anxiety, no "some users see the old server for six hours" nonsense. The classic migration problem simply doesn't exist.

If your production domain is not behind a proxy like this, set that up weeks before you migrate, not the day of. I covered the base setup in How I Hardened My VPS in One Afternoon, and the same stack carried this migration: Cloudflare in front, Tailscale between the boxes for private server-to-server transfers.

For TLS on the new origin I used a Cloudflare origin certificate instead of certbot. It's valid for 15 years, only Cloudflare needs to trust it, and there's no renewal cron to migrate. One less moving part.

Preparing the new server while the old one serves traffic

Everything in this phase happened while the site ran normally. Zero risk, no time pressure.

The new box got a dedicated PHP-FPM pool with 12 workers, sized so those 45-second AI calls can't starve anyone again. The queue worker became a proper systemd service instead of a supervisor config. Nginx got the vhost with the origin cert. Then I synced the whole deployed app tree over Tailscale: 227MB of code and vendor directory, about 20 seconds.

The database needed more thought. The app runs on SQLite in production (yes, really, 123MB serving a six-figure monthly request count without complaint). You can't just copy a SQLite file that's being written to, because you might catch it mid-transaction. SQLite has a clean answer: VACUUM INTO, available since 3.27, which writes a consistent snapshot to a new file without blocking writers.

// One consistent snapshot, no write freeze, no downtime
DB::statement("VACUUM INTO '/tmp/snapshot.sqlite'");
Enter fullscreen mode Exit fullscreen mode

That snapshot went to the new server as a rehearsal database. And then came the step I'd argue is the single most valuable trick in this whole post.

Rehearse through the real edge with a probe subdomain

Testing the new server with curl --resolve proves your nginx config works. It does not prove that Cloudflare's edge can talk to your new origin. SSL mode mismatches and origin cert problems only show up on that hop, and the classic failure is discovering them after you flip DNS, live, while your site throws 526 errors.

So before touching the real records, I added one DNS entry: neworigin.mydomain.com, proxied, pointing at the new server's IP. The origin cert was a wildcard, so it covered the probe subdomain for free. Then I opened it in a browser and ran a full user flow against the rehearsal database, through Cloudflare's actual edge, TLS handshake and all.

It worked. Which meant the cutover would change exactly one variable: the origin IP behind a path already proven end to end. That's what makes a migration boring, and boring is the goal.

The cutover: 1 minute 55 seconds

Here's the full sequence. My part was scripted; the human part was clicking two DNS records.

  1. Stop the queue worker on the old server, then php artisan down. Writers stopped first, then the maintenance page. The order matters: a worker that keeps processing jobs during your final sync is how you lose data.
  2. Take a fresh VACUUM INTO snapshot. Writes are frozen now, so it's the final, complete state.
  3. Rsync the snapshot plus the storage delta to the new server. The pre-sync days earlier meant this final pass moved almost nothing.
  4. On the new box: php artisan config:cache, fix file ownership, start the queue worker, install the crontab. The full cache command list is in the Laravel Artisan commands reference.
  5. Flip the two A records in Cloudflare to the new origin.
  6. Verify on the live domain: health endpoint, homepage, pricing, one real optimization, and a live Stripe checkout session to confirm billing quotes the right amount.

View the interactive diagram on hafiz.dev

The old server stayed exactly as it was, frozen in maintenance mode. That's the rollback plan, and it's beautifully simple: flip the two records back, run php artisan up, restart the worker. The point of no return is the first real payment on the new box, because after that, rolling back means losing data.

The four things that almost broke

This is the part I wish someone had written before I started. Every one of these was invisible in my planning and got caught by testing rather than foresight.

The maintenance flag stowed away in the rsync

Right after the final sync, the new server started answering 503. Confusing, until I realized why: php artisan down creates a flag file at storage/framework/down, and my final rsync had faithfully copied it to the new server. The new box wasn't broken. It was dutifully in maintenance mode because I'd shipped the maintenance flag along with the data. One php artisan up on the new server fixed it, and cost about 30 seconds of the downtime window.

The SSR service that existed nowhere in the app tree

The app uses Inertia SSR, which runs as a separate node process managed by a systemd unit. That unit isn't in the git repo. It's not in the crontab. It's not in the supervisor config. Its name didn't even contain the app's name. My server inventory missed it completely, and the site ran client-side-only for about 40 minutes before a deploy script warning surfaced it. For an app where most traffic comes from organic search, silently losing server-side rendering is a real SEO problem.

The lesson: list every systemd service on the old box and ask what each one does. An app's runtime can include services you forgot you created.

The backup that would have failed silently every night at 2:30

The database backup script ships snapshots offsite to Cloudflare R2 on a cron. After the migration I ran it by hand once instead of waiting for the schedule. Exit code 127. The new server didn't have the sqlite3 CLI installed, and the cron line ends in >> /dev/null 2>&1, so the nightly run would have failed silently forever. I'd have discovered it the day I actually needed a backup, which is the one day you can't afford to.

Run every cron job by hand once on the new machine. Crons that discard output don't fail loudly. They just stop existing.

The midnight log rotation trap

Laravel's daily log driver creates a fresh log file at the first write after midnight. Whoever writes first owns the file. If that's ever root (a stray artisan command over SSH is enough), the web user can't write to it, and every request starts failing at midnight while you sleep. I'd been bitten by this before, so this time I set a default ACL on the log directory that makes any new file writable by the web user no matter who creates it:

setfacl -R -d -m u:www-data:rwX storage/logs
Enter fullscreen mode Exit fullscreen mode

Then I proved it: created a file as root, appended to it as www-data, watched it succeed. The first unattended night passed clean.

What I'd tell you to do differently

Honestly? Not much about the process. But I hold two opinions after this that I didn't hold as strongly before.

First, rehearse through the real path, not a simulation of it. The probe subdomain took two minutes to set up and removed the only scary unknown in the plan. Every migration guide tells you to test the new server. Almost none tell you to test the edge-to-origin hop, and that's the one that bites.

Second, the boring deploy stack held up. My deploy is a bash script doing a local build and an rsync. No containers, no orchestration. There are sharper tools, and I've compared some of them in Scotty vs Laravel Envoy, but a deploy you fully understand beats a sophisticated one you half understand, especially at 11 in the morning with customers on the site. The same deploy script also caught the missing SSR service, which is a strong argument for running a no-change deploy as a post-migration test.

And one bonus: moving to a box that could afford an every-minute schedule:runcron revealed that some of my scheduled tasks had never actually run on the old server. Nothing was broken in the code. The entries existed in the scheduler, but no cron was firing them. Run php artisan schedule:list on your new box and check every line against what you believed was running.

FAQ

Do I need to lower DNS TTLs before migrating?

Not if your domain is proxied through Cloudflare or a similar edge. Visitors connect to the edge, not your origin, so changing the origin IP takes effect in seconds regardless of TTL. If your DNS points directly at your server, then yes, drop TTLs to 60 seconds at least a day before.

How do you copy a SQLite database that's in use?

VACUUM INTO writes a consistent snapshot without blocking writers, which is perfect for rehearsal copies while the site runs. For the final sync, stop your writers first (queue worker, then maintenance mode), take one last snapshot, and ship that. Never plain-copy a SQLite file under active writes.

Why not just use Laravel Forge or Laravel Cloud?

Both are good answers for teams that value their time over their invoice. I run several small apps on one box, I already had the hardened VPS setup, and the economics of a fixed-price server win at my scale. The trade is that every one of the gotchas in this post becomes your job. That trade is worth it to me. It might not be to you.

How long should the old server stay available?

I froze mine for a week: maintenance mode on, workers stopped, crons commented out, nothing deleted. Rollback is a DNS flip away for the whole window. Decommission only after the new box has survived real traffic, a real payment, and at least one full backup cycle.

What breaks most often after a server move?

Things that live outside your app directory: systemd services, cron entries, CLI packages your scripts assume exist, credentials in /root, log rotation permissions. Your code survives the rsync fine. The runtime around it is what gets forgotten.

The checklist is the takeaway

The migration took a morning, the downtime took 1 minute and 55 seconds, and the four near-misses took a healthy dose of paranoia to catch. If you're planning the same move, steal the sequence: prepare everything while the old server runs, rehearse through the real edge with a probe subdomain, freeze writers before the final sync, and then run every cron and service by hand once on the other side.

View the interactive component on hafiz.dev

Top comments (0)