Think You Very Much An Invitation To Think

Moving Sendy From Namecheap to DigitalOcean (And How Much of It Claude Did)

My email list runs on Sendy, a self-hosted PHP app that sends through Amazon SES. It’s been sitting on Namecheap shared hosting at karolgajda.com/members for years. Next to it on the same account was an old WordPress install that has had malware in it for about a year. (Fun!) I sandboxed it as best I could on shared hosting, but “as best I could on shared hosting” isn’t saying much.

That was the reason I’d been meaning to leave for a while. The push to finally do it came on migration day itself, when a brand new PHP vulnerability was announced and, of course, Namecheap was running an affected version.

So I moved Sendy to a $6/month DigitalOcean Droplet and moved DNS to Cloudflare’s free plan. This site and a redirect from gajda.com came along too, but those are static files and took about ten minutes. Sendy was the real job. And instead of spending a weekend on Stack Overflow, I had Claude (Anthropic’s AI, running in Claude Code on my laptop) do most of it.

I’m not a sysadmin. I can follow instructions, but I don’t know Apache config by heart and I don’t want to. So this post is two things: what it actually takes to move Sendy without breaking anything, and how the work split between me and the AI.

Why moving Sendy is scary

Moving a static website is easy. Copy the files, point DNS, done. If something’s off for an hour, nobody cares.

Moving an email list is different, because a mistake hits real people’s inboxes:

  • The URL can’t change. Every email I’ve ever sent has an unsubscribe link pointing at karolgajda.com/members. Move Sendy to a different URL and all of those break.
  • Campaigns can go out twice. Sendy sends scheduled campaigns and autoresponders from cron jobs. If the old server and the new server both run them, even briefly, subscribers get duplicates.
  • Subscribers can get lost. Anyone who subscribes or unsubscribes on the old server after you copy the database is missing from the new one. A lost unsubscribe is the kind of thing that gets you in trouble.
  • Bounces and complaints can stop arriving. Sendy learns about bounces and spam complaints through Amazon SNS. If that breaks, you keep emailing dead addresses and your SES reputation slowly tanks.
  • Deliverability lives in DNS. The DKIM and SPF records that make SES email land in the inbox have to come across perfectly when you switch DNS providers.

Everything below is basically a plan to avoid those five things.

My rules

Before anything started I gave Claude a short list of rules:

  • Ask me before any DNS change, anything destructive, or any write on the Namecheap server.
  • Never delete anything on Namecheap. I’ll cancel hosting myself after a week of everything working.
  • Remind me to pause scheduled campaigns before the final cutover, and re-sync the database if anything changed after the backup.
  • Don’t print passwords or secrets in your output. Store new credentials in a local file and tell me where.
  • Explain each step briefly. I’m not a sysadmin.

The fixed requirements: Sendy stays at exactly https://karolgajda.com/members, sending stays on Amazon SES, and the WordPress install does not come along. The root of karolgajda.com just becomes a “coming soon” page.

The plan

Nine steps:

  1. Discovery
  2. Backup
  3. Set up the new server
  4. Restore Sendy
  5. Cron jobs
  6. Test the new server before anyone else can see it
  7. DNS on Cloudflare
  8. Real SSL certificates
  9. Cutover and verification

Here’s each one, and who did what.

1. Discovery

Claude: Logged into Namecheap over SSH (read-only) and mapped out everything about Sendy: where it lived, its config file, the database, the PHP and MySQL settings, the cron jobs, the DNS records, and how email was set up.

It found that karol@karolgajda.com, the address Sendy sends from, is just a Namecheap email forwarder. If I’d cancelled hosting without moving that, my “from” address would have quietly stopped receiving replies.

Then it compared every Sendy file against the official Sendy 7.1.5 download, file by file. With malware on the same account, I wanted to know exactly which files were Sendy’s and which weren’t. Answer: everything matched except a couple of small anti-spam customizations of mine. So the new server got a clean Sendy install from the official zip, with only my vetted files copied over. Nothing from Namecheap ran on the new server without being checked first.

Me: Namecheap had SSH shell access turned off on my account, so I flipped it on in cPanel. I also added Claude’s SSH key in cPanel and answered a bunch of “is this yours?” questions.

2. Backup

Claude: Exported the Sendy database and downloaded it, plus the Sendy files and the DNS zones, to a folder on my laptop. Then it actually verified the backup: it counted the rows in the export file and compared them to the live database, table by table (11,580 subscribers, 174 campaigns, and so on), and saved checksums for everything.

Me: Nothing. I watched.

3. Setting up the server

Me: Created a DigitalOcean API token and gave it to Claude. I asked which Droplet size to pick and it said the $6/month 1GB one was plenty for Sendy, since SES does the actual sending. I live in North Carolina, so it went in New York (NYC3). I said yes to daily backups.

Claude: Created the Droplet through the API, then set up the whole server:

  • Ubuntu 24.04 with automatic security updates
  • A firewall that only allows SSH, HTTP, and HTTPS
  • SSH by key only, no passwords
  • Apache with PHP 8.3 running as PHP-FPM, on the version Ubuntu had already patched for that day’s vulnerability
  • MariaDB, tuned for a 1GB server and only reachable from the server itself
  • Sendy runs as its own Linux user with PHP locked down, so even if something got in through Sendy it couldn’t do much

I asked if we should use the newest PHP instead of 8.3. Claude’s answer was basically: Ubuntu’s 8.3 gets security patches from Ubuntu for years, Sendy supports it, and “newest” isn’t the same thing as “safest.” Fair enough.

Then I asked whether it had actually read Sendy’s documentation. It went and read the get-started guide, the migration instructions, and the troubleshooting page. That turned up two after-the-move steps from Sendy’s migration guide (more on those in step 9, including why we only ended up needing one of them). It also explained that Sendy’s compatibility check would say “mod_rewrite not enabled” on this setup even though it is. That’s a known false alarm when PHP runs as FPM, which would have sent me down a rabbit hole otherwise.

4. Restoring Sendy

Claude: Installed Sendy from the official zip, created a new database and database user with a brand new password (saved to a local file on my laptop, never printed), imported the backup, and checked the row counts again.

A few Sendy-specific details it got right that I wouldn’t have thought about:

  • The config keeps the exact same URL (APP_PATH), so every old unsubscribe link keeps working.
  • The database uses the same character set (utf8mb4), so subscriber names with accents or emoji survive the trip.
  • MariaDB settings and the server time zone match Namecheap’s, so the import behaves the same and scheduled campaign times don’t shift by a few hours.
  • Sendy’s uploads folder is writable, and the rest of its files aren’t.

The one weird Sendy wrinkle: on Namecheap I’d had to use an unscrambled version of Sendy’s functions.php, because Namecheap’s security scanner (Imunify) kept blocking the official scrambled one. The new server doesn’t have that problem, so it just uses the official file.

Me: Said yes.

5. Cron jobs

Sendy needs four cron jobs: scheduled campaigns, autoresponders, CSV imports (every 5 minutes), and segment updates (every 15).

Claude: Set them all up on the new server, logging to a file so we could see them run, but commented out. This is the double-send problem from earlier. The new server’s cron jobs only get turned on during the cutover, after the old ones are turned off.

6. Testing before the switch

This was the coolest part to me. You can test the new server with your real domain names before you change any DNS, by editing a file on your own computer (the hosts file) so that only your computer goes to the new server. Sendy thinks it’s at its real URL, so you can log in and click around like normal.

Claude: Gave me the exact command to run, and put a temporary self-signed certificate on the server so HTTPS would work for testing.

Me: Ran the command, clicked through the browser’s security warning, logged into Sendy on the new server, and clicked through my lists, campaigns, and settings.

Except at first my computer kept going to the old server. Turns out Firefox uses its own DNS (DNS over HTTPS) and completely ignores your hosts file. I had to turn that off. Now you know.

Testing turned up a real problem, too. Sendy on the new server said my AWS credentials were invalid, and the reason why is a whole story.

The AWS detour

Sendy keeps your AWS access key in its database (you enter it in Sendy’s settings). While checking how Sendy talked to SES, Claude noticed that mine was an access key for my AWS root account. That’s the master key to my entire AWS account, sitting on shared hosting next to a WordPress install with malware. (Yeah. I know.)

Claude: Walked me through making a dedicated IAM user just for Sendy, with only the permissions Sendy needs (SES and SNS), and told me exactly where to paste the new key in Sendy’s settings. After a test send worked, it walked me through deactivating the root key. Then I found another active root key I’d made 3,178 days ago and forgotten about, plus keys for two old IAM users. All deactivated, and they get deleted next week once I’m sure nothing breaks.

The “invalid credentials” error on the new server was because I’d tested it with a copy of the database made before the key swap, so it still had the old root key. Claude copied the new key over and the new server sent fine.

Me: All of the AWS clicking. Claude can’t log into my AWS account, and honestly I don’t want it to. AWS also wouldn’t let me sign in at first, because root login apparently only works in the us-east-1 region now. That was a fun 10 minutes.

7. DNS on Cloudflare

Me: Gave Claude a Cloudflare API token and verified my Gmail as an Email Routing destination.

Claude: Created the domains in Cloudflare and added every DNS record I actually need. It left out about thirty records that were only cPanel leftovers (webmail, cpcalendars, autodiscover, and so on). It also created the Cloudflare Email Routing rule that forwards karol@karolgajda.com to my Gmail, replacing the Namecheap forwarder. (Cloudflare won’t let you actually turn on Email Routing until the domain is active, so that part had to wait for cutover night.)

For Sendy, the important records are the ones Amazon SES uses to prove my email is really from me, and Claude found a bug in them. One of the three DKIM records at Namecheap had a typo and pointed at the wrong target. SES still said “verified,” so I never noticed. The Cloudflare version is correct. It also fixed my SPF record (it didn’t mention Amazon SES at all, but still listed Bluehost, which I haven’t used in probably ten years) and added a basic DMARC record.

Then it checked every record by querying Cloudflare’s nameservers directly, before anything was live.

The timing trick

Changing nameservers can take up to 48 hours to fully spread around the internet. For that whole time, some people would hit the old Sendy and some the new one. That’s the lost-subscriber problem, stretched over two days.

Claude’s fix was to do it in two stages:

  1. A few hours ahead, lower the “cache time” (TTL) on the Namecheap DNS records to 5 minutes.
  2. At cutover, first change the addresses in Namecheap’s DNS to point at the new server. Because of the short TTL, that takes effect in about 5 minutes. Then switch the nameservers to Cloudflare. Whichever DNS someone’s computer ends up asking, both now point to the same server.

So the window where the old Sendy could get traffic shrinks from two days to a few minutes.

Email had the same problem. Cloudflare Email Routing can’t be turned on until the domain is fully active on Cloudflare, so Claude added temporary records that keep sending mail to the Namecheap forwarder until then. No gap where replies to my newsletter bounce.

8. SSL

Claude: Once DNS pointed at the new server, it did a practice run against Let’s Encrypt’s test system first (so a mistake wouldn’t count against their rate limits), then got the real free certificates, swapped out the temporary test certificate, and ran a simulated renewal to make sure they’ll renew on their own every few months.

Me: Nothing.

9. Cutover

I did the cutover in the evening, once the old 4-hour DNS cache time had run out. Here’s how it actually went:

Is anything about to send? I only send campaigns manually, so I knew nothing was scheduled. Claude checked anyway, directly in Sendy’s database: nothing scheduled, nothing half-sent. It also found an autoresponder I’d forgotten about.

Old cron jobs off. Claude asked first (my rule), saved a copy of the Namecheap cron settings to my laptop, then put a # in front of just the four Sendy lines. Commented out, not deleted. From this moment nothing could send from the old server.

Final database sync. A fresh export from Namecheap, loaded into the new server, and row counts compared on all 24 of Sendy’s tables: every one matched. One new campaign had appeared since the earlier backup, and it came across too.

New cron jobs on. Their first runs finished without errors.

DNS. Claude asked first, then changed the Namecheap DNS records to point at the new server. One small surprise: cPanel took the change instantly, but Namecheap’s public nameservers took about two minutes to catch up, which made for a slightly tense two minutes. A few minutes later Google’s and Cloudflare’s public DNS were serving the new address too.

Real SSL certificates (step 8 above).

Nameservers. I logged into namecheap.com and switched both domains to Cloudflare’s nameservers. The .com registry showed the change within a minute, and Cloudflare marked both domains active a couple of minutes later. Since Cloudflare already had the same addresses, nobody would have noticed anything.

Email. With the domains active, Cloudflare Email Routing could finally be turned on. This is where the night’s only real hiccup happened. The API token I’d given Claude didn’t have the specific permission Cloudflare needs to turn Email Routing on (it lives under “Zone Settings,” not “Email Routing,” which is not exactly intuitive). But Claude had already deleted the temporary mail records before trying, so for about a minute my domains had no mail records at all. It noticed right away and put them back. Email servers retry for days when they can’t deliver, so nothing was lost, but it should have checked that the switch would work before removing the old records. I added the permission, Claude did it in the right order the second time, and two test emails landed in my Gmail.

Then the Sendy steps from its migration guide:

  • Delete and recreate Sendy’s Amazon SNS topics. Claude checked my SNS setup before I touched anything and said not to. The reason Sendy recommends it is that a move usually changes the address Amazon sends bounce notifications to. Mine didn’t change. Same URL, it just leads to a different server now. Deleting them would only have opened a window for lost bounces. And we got proof it works: when I opened a campaign in Sendy, it re-checked its Amazon setup, and Amazon sent six notifications to the new server, all accepted.
  • Visit /reset-cron, so Sendy re-checks that its cron jobs are running.

Then I sent myself a test campaign. It arrived, and the unsubscribe link opened Sendy’s “your unsubscribe tag is working” page on the new server.

Fun fact: bots were poking the new server for .env files (where apps often keep passwords) within a minute of DNS switching over. They got blocked.

Finally, Claude compared every subscriber on the old Namecheap Sendy against the new server. That catches anyone who subscribed or unsubscribed on the old server in the few minutes between the sync and DNS switching over. Nothing was missing. Two people who were already subscribed had signed up again (one on each server, which shows signups worked on the new one), and that only changed their signup time.

The whole cutover, from “anything scheduled?” to forwarded test emails in my inbox, took about 25 minutes.

Where Claude got it wrong

It wasn’t perfect, and I want to be honest about that:

  • It printed my old Namecheap database password to the screen. It tried to hide it with a command that, on macOS, doesn’t support the pattern it used. It owned up right away, and the new server uses a brand new password, so the leaked one is about to be worthless. But that’s exactly the thing my rules said not to do.
  • It guessed that another domain of mine was hosted on Namecheap. It wasn’t.
  • It misread when an AWS key was last used.
  • On cutover night it deleted my temporary mail records before confirming it could turn on Cloudflare Email Routing, which left my domains without mail records for about a minute. No harm done, but it should have tested first.

Every time, I pushed back with a screenshot or a “no, that’s not right,” and it corrected itself without arguing. That’s the part that made it trustworthy for me: it was wrong about the same amount as a human contractor would be, and easier to correct.

The scorecard

What Claude did: Basically everything that happened in a terminal. Discovery, checking every Sendy file against the official release, backups and verifying them, building and hardening the server, Apache/PHP/MariaDB config, the clean Sendy install and database restore, cron jobs, every DNS record on Cloudflare, finding the DKIM typo and the root-key problem, the two-stage cutover plan, the cutover itself, SSL, Email Routing, and the testing (including comparing every subscriber afterwards). Plus all the explaining, because I asked “wait, why?” a lot.

What I did:

  • Turned on SSH access in cPanel
  • Created API tokens for DigitalOcean and Cloudflare
  • Every click in the AWS console: the new IAM user for Sendy and deactivating old keys
  • Pasted the new AWS key into Sendy’s settings
  • Tested Sendy in my browser (and fought with Firefox)
  • Confirmed no campaigns were scheduled before the cutover
  • Verified my Gmail for Cloudflare Email Routing
  • Changed the nameservers
  • Added one missing permission to the Cloudflare token on cutover night
  • Said “yes” or “no” every time it asked before doing something risky

If I had to put a number on it, Claude did something like 90% of the work and I did 10%. But my 10% was the stuff that should be done by a human: logging into accounts with real power, approving anything destructive, and checking that things actually worked from the outside.

Before this, moving Sendy would have been a weekend of me copy-pasting commands I didn’t understand and hoping nobody got two copies of my newsletter. This time it took some setup plus a 25-minute cutover in the evening, and I mostly understood what was happening.

If you’re moving Sendy yourself

The short version, with or without an AI:

  1. Keep the exact same Sendy URL.
  2. Install Sendy fresh from the official zip, then copy over only the files you know you changed.
  3. Back up the database and check the backup (row counts, not just “the file exists”).
  4. Match the database character set, MySQL mode, and server time zone to the old server.
  5. Set up cron on the new server but leave it off.
  6. Test with your hosts file before touching DNS. (Turn off DNS over HTTPS in your browser.)
  7. Lower your DNS TTLs hours ahead of time.
  8. At cutover: pause campaigns, turn off the old cron, re-sync the database, turn on the new cron, then switch DNS.
  9. Visit /reset-cron. Sendy’s guide also says to delete its SNS topics so it recreates them, but if your Sendy URL is unchanged, check first: the existing ones may already point at the right place.
  10. Send yourself a test campaign and click everything.
  11. While you’re in there, make sure Sendy isn’t using your AWS root key.

Still to do

  • Wait a week, then cancel Namecheap hosting (but keep the domain registrations there)
  • Delete the deactivated AWS keys and old IAM users
  • Delete the API tokens I gave Claude
  • Upgrade the SES DKIM keys to 2048-bit and set up a custom MAIL FROM domain
  • Update Sendy to 7.1.6

If you’re running Sendy on shared hosting and it’s been nagging at you, this is very doable now, even if you’re not a server person. Just give your AI some rules first.