Categories
Software Engineering Tutorial

I Moved My WordPress Blog Off Bluehost – Why Bots Love wp-login.php, and How a Static Mirror Helps

I moved this WordPress blog off cheap shared hosting to a server I control. Here is what I actually ran into: bots hitting xmlrpc.php and the rest, 43 posts turning into hundreds of archive and feed URLs, Let’s Encrypt HTTPS, cutting over DNS without touching MX records, and keeping WordPress private behind a static copy with the same URLs.

I Finally Moved This WordPress Blog Off Cheap Shared Hosting

I ran this blog on Bluehost-style shared hosting for years. It worked. It was also kind of a pain. In October 2026 I moved it to a server I already pay for, and I want to write down the practical stuff I actually ran into, not a sales pitch for a host.

This is a guide to the parts a lot of “just copy WordPress” posts skip:

  • Why bots will not stop poking WordPress, even on a small personal blog
  • How to move the site without breaking HTTPS or email
  • Why a few dozen posts turn into hundreds of archive and feed URLs
  • How to keep WordPress itself private and still serve the same URLs from a static copy and a CDN
  • How I post now with wp-cli and an AI agent, as a draft I still review

If you have a WordPress blog on cheap shared hosting and you have been meaning to leave, this is the post I wish I had.

Why Did I Leave Shared Hosting?

Shared hosting is fine until you want more control. I wanted SSH that I actually own, a normal wp-cli workflow, and the option to take WordPress off the public internet later.

There is also a security reason that is not hype. WordPress’s own hardening guide is pretty direct about shared servers: if another site on the same machine gets compromised, yours can too. You do not control the neighbors. On a server I run, I at least control the software, the firewall, and what is allowed to face the world.

And honestly, I was already paying for the server. Keeping Bluehost just to hold this blog started to feel silly.

Why Do Bots Keep Hitting wp-login.php?

Because WordPress is everywhere, and the login form always lives at the same path. wp-login.php is not a secret. /wp-admin/ just sends you there. Bots do not need to “find” your blog in a clever way. They try the same URLs on every hostname they can get their hands on.

WordPress.org documents this in Brute Force Attacks. The usual targets are:

  • wp-login.php, password guessing
  • xmlrpc.php, especially if you leave XML-RPC on when you do not need it (Jetpack and the official mobile app are reasons you might still need it)
  • /wp-json/, the REST API
  • old plugin and theme file paths, in case you are running something outdated

The docs also say that hiding the login URL can cut down noise, but it should not be your only defense. I agree. A bot that already has a list of WordPress paths does not care what you renamed the login page to if xmlrpc.php is still sitting there.

WordPress.org Brute Force Attacks documentation, listing key defenses for login and XML-RPC

This is not theoretical for me. I looked at one day of access logs from the old shared host, dated October 7, 2026. I counted request paths only, no visitor details. Out of 588 requests that day:

  • 198 hit xmlrpc.php (almost all of them POST)
  • 27 hit /wp-includes/
  • 11 hit /wp-content/plugins/
  • 8 hit /wp-json/
  • 7 hit /wp-admin/

That is about a third of the day’s traffic going to xmlrpc.php alone, on a quiet personal blog. I left wp-cron.php out of that list (83 POSTs) because WordPress often calls that itself.

Bar chart of WordPress paths from one day of shared hosting logs on October 7, 2026, with 198 requests to xmlrpc.php

So what can you realistically do?

  • Keep WordPress, themes, and plugins updated. Official updates exist for a reason.
  • Use a strong, unique password, and turn on two-factor authentication for admin accounts. WordPress core does not include 2FA, so that is a plugin or your identity provider, which is also what the brute force guide says.
  • If you do not need XML-RPC, disable it. If you do, rate-limit it.
  • Rate-limit wp-login.php at the server or at a WAF, if you still have to expose it.

All of that helps. The more complete answer, if your public site is basically a blog, is: stop exposing live WordPress to the public internet at all. Serve a static copy at the same URLs, and keep the real WordPress install private. I will get to that.

How Do I Actually Move the Site?

Here is the short version of what I did. I am leaving out hostnames, IPs, usernames, and database names on purpose.

  1. Take a real backup, not just “I have some posts in my email.” I copied the WordPress files, dumped the database, and also ran wp export for a portable copy of the content.
  2. Restore it on the new server with a new database and a new database user. Change the password. If you use wp-cli, wp config shuffle-salts is a good idea so the old cookies and keys are useless.
  3. Turn off host-specific plugins. Shared hosts love to drop in their own plugin and a must-use page cache. Those will not help you on the new machine, and they can get in the way.
  4. Test before you touch public DNS. A hosts-file override or a temporary hostname is enough to click through the site, log in, and look for broken media or weird permalinks.
  5. Put HTTPS on the new server, then cut DNS over. Email is its own step, below.

wp-cli is the right tool for a lot of this. The official site is wp-cli.org.

The official WP-CLI website, describing the command line interface for WordPress

Example wp-cli commands for exporting a site, flushing rewrites, and creating a draft post

After the files and database are in place I run wp rewrite flush and wp cache flush. If the test URL is different from the real domain, wp search-replace is how you retarget the stored URLs. Do a --dry-run first.

What About HTTPS?

On shared hosting, HTTPS is usually a checkbox. On your own server, you get to do it yourself, which is less scary than it sounds.

Let’s Encrypt issues free certificates through an automated API. They recommend running an ACME client, and for most people that client is Certbot.

Let's Encrypt getting started page, explaining ACME clients and Certbot

The official Certbot website from the Electronic Frontier Foundation

The usual path with Apache looks like this:

sudo certbot --apache

If your DNS already points at the new server, Certbot can prove you control the domain with an HTTP challenge and install the certificate. If you need the certificate before the cutover, use a DNS challenge instead.

Then turn on auto-renewal (Certbot usually installs a timer or a cron job when you install it the recommended way) and test it:

sudo certbot renew --dry-run

I actually do that dry run. Certificates expire. A blog that randomly becomes “your connection is not private” is a bad time.

How Do I Cut Over DNS Without Breaking Email?

This is the one that makes people nervous, and it should. Your domain is not one record. The website is usually an A record (and an AAAA record if you have IPv6), plus maybe a CNAME for www. Email is a completely different set of records: MX, and often TXT records for SPF, DKIM, and DMARC.

I only changed the web records. I left the MX and mail-related TXT records alone.

Diagram of a DNS cutover: change A and AAAA records for the website, leave MX and mail TXT records alone

A few habits that helped:

  • Lower the TTL on the A and AAAA records a day or two before the move, so the change does not linger for 24 hours.
  • Flip those records to the new server, wait, and then confirm the site is serving from the new place over HTTPS.
  • Send yourself a test email, to and from the domain, before you celebrate.
  • Read the MX records once with fresh eyes. A typo in an MX hostname can sit there quietly and drop mail, and it has nothing to do with the website.

Do not let a “migrate my WordPress site” checklist talk you into replacing the whole DNS zone.

Why Does WordPress Make Hundreds of Pages From 43 Posts?

This one surprised me, and I have been using WordPress for a long time.

When I inventoried this blog during the move, I had 43 published posts. WordPress had turned those into about 509 HTML pages and about 900 URLs in total, including 384 feeds and 5 sitemaps. The URLs were clean permalinks. The only query-string URLs were search (?s=).

Diagram showing how 43 published posts became about 509 HTML pages, 384 feeds, and about 900 URLs

The 43 posts are just the articles. WordPress also builds:

  • Category archives, and extra pages when a category has more than one page of posts
  • Tag archives (I have a lot of tags, so this is a big one)
  • Date archives, by year and by month
  • An author archive
  • Attachment pages for images
  • The home page, pagination, and a few utility pages

And then it hangs a feed off almost all of that. The official WordPress Feeds docs describe the built-in ones: RSS 2.0, RSS 0.92, RDF/RSS 1.0, Atom, a site-wide comments feed, and a comments feed on each post. Categories, tags, and authors get feeds too. Date archives get them as well.

If you have a lot of tags, the feed count gets silly in a hurry. That is how you end up with hundreds of feeds on a blog that does not feel “big.”

WordPress.org Feeds documentation describing the built-in RSS and Atom feed types

If you move off WordPress later, or you publish a static copy, this is the list you have to keep if you care about old URLs. Search engines and old links do not only point at /hello-world/.

Which Feeds Actually Matter for SEO?

Most of them do not.

Google Search Central is pretty clear about the split. An XML sitemap is for the whole set of URLs on the site. An RSS or Atom feed is for recent changes. They recommend using both. They also say that submitting a sitemap or a feed does not guarantee indexing.

Google Search Central blog post on best practices for XML sitemaps and RSS or Atom feeds

Their later sitemap documentation says the same thing in a shorter way: if your CMS already makes an RSS or Atom feed, you can submit that feed as a sitemap, but it will only cover recent URLs. The XML sitemap is still how you show Google the full site.

Google Search Central documentation on building and submitting a sitemap

So, in practice:

  • Keep a real XML sitemap, and mention it in robots.txt.
  • Keep the main site feed at /feed/ (Atom at /feed/atom/ is fine too). That is the useful “something new was published” signal.
  • You do not need a feed for every tag, category, author, month, and post comments thread in order to rank. Those exist because WordPress generates them, not because Google asked for 384 of them.

I still keep those extra feed URLs working, so I do not create a pile of 404s. I just do not treat them as SEO work.

How Do I Keep WordPress Private and Still Serve the Blog?

This is the setup I wanted once the site was on a server I control.

WordPress stays the source of truth. I write and edit there. The public site is a static mirror: a generated copy of every public URL, served from a CDN, with the same paths as before. /some-post/ is still /some-post/. /tag/php/ is still /tag/php/. /feed/ is still /feed/.

What the public copy does not include is the live WordPress runtime. No wp-login.php, no xmlrpc.php, no /wp-json/, no plugin PHP. A bot that requests those on the public hostname gets a static 404 (or whatever you choose to serve), not a login form and not a REST API index.

Diagram of a private WordPress install with a public static copy served from a CDN at the same URLs

The build is straightforward in spirit:

  1. Collect every public URL you care about (sitemaps, post and page permalinks, archives, feeds).
  2. Fetch those URLs and write static files, including index.html for slash URLs.
  3. Copy static assets (uploads, CSS, JS, fonts). Do not copy PHP, wp-config.php, or anything in wp-admin.
  4. Publish that tree to object storage or a CDN, at the same URL paths.
  5. Rebuild when a post changes.

I tested this on a preview hostname first and compared pages against live WordPress: status codes, titles, images, feeds, and sitemaps. I also made sure search-shaped URLs (?s=) and the WordPress endpoints were not required for the public site to make sense. I do not need comments on this blog, which makes the static copy a lot simpler.

This is also the first setup that actually answers the bot question. Rate limits help when WordPress is public. If WordPress is not public, the bots are just requesting files that are not there.

What Does This Actually Cost?

I did not move for a giant refund. I moved because I already pay for a server that hosts other projects, and I wanted WordPress off cheap shared hosting. Still, the prices are worth putting next to each other, because the “cheap” shared plan is not always the cheap option once it renews.

These are published list prices I checked in November 2026. They can change, they usually exclude tax, and they are not my actual bills. I am not going to invent a traffic number for this blog and pretend it is a receipt. Where I do the math on a small blog, I will label it as an example.

Cost comparison as of November 2026 for Bluehost Shared Starter, AWS Lightsail, EC2 t3.micro, an existing server, and a static S3 plus CloudFront mirror

Here is the same comparison in plain text, so it still works if the image does not load.

  • Bluehost Shared Starter: $3.99/mo intro on a 36-month term, then $9.99/mo at 36-month renewal. The plan they list for a simple website or blog. VAT and GST are not included.
  • AWS Lightsail, Linux, with a public IPv4 address: $5/mo for the smallest bundle, with 0.5 GB memory, 2 vCPUs, 20 GB SSD, 1 TB transfer, and a static IP in the bundle.
  • Amazon EC2 t3.micro, Linux, US East (N. Virginia): $0.0104 per hour, about $7.59 if it runs 730 hours in a month. Compute only. Disk is extra, and an in-use public IPv4 address is $0.005 per hour.
  • A server you already pay for: no new server bill for the blog itself. This is my situation. The blog shares a machine I already use for other work.
  • Static mirror (S3, CloudFront, Route 53, Let’s Encrypt): about $0.50/mo in a small-blog example, or less. Storage and DNS are cheap at this size. Transfer often fits in CloudFront’s always-free allowance. Certificates are free.

Shared hosting. On the official Bluehost pricing page, Shared Starter is listed at $3.99 per month as an introductory rate “for 36 month term,” and the same card says it renews at $9.99 per month. That is the plan they describe as “made for a simple website or blog.” Business is $6.99 intro and $13.99 at renewal, on the same 36-month term. Those cards also say VAT and GST are not included. Bluehost has a separate renewal price FAQ (last updated February 11, 2026) with other renewal terms, including $15.99 per month if you renew Starter month to month, and $11.99 per month on a 12-month renewal. The number that matters for a long stay is the renewal price, not the intro teaser.

A self-managed server. If you are starting from zero and you just want a small Linux box, Amazon Lightsail is the straightforward published price. The smallest Linux/Unix bundle with a public IPv4 address is $5 per month, and it includes 0.5 GB of memory, 2 vCPUs, 20 GB of SSD, and 1 TB of transfer. The next size up is $7 per month with 1 GB of memory. There is also an IPv6-only bundle that starts at $3.50 per month if you do not need IPv4.

Amazon Lightsail pricing page showing Linux bundles with a public IPv4 address, starting at 5 US dollars per month

Amazon EC2 is more of an a la carte bill. A t3.micro on Linux in US East (N. Virginia) is $0.0104 per hour. If it runs all month, that is about $7.59 (I multiplied the published hourly rate by 730 hours). A t3.small is $0.0209 per hour, or about $15.26 the same way. That is only the instance. You also pay for disk, and AWS bills $0.005 per hour for an in-use public IPv4 address on the Amazon VPC pricing page, which is about $3.65 a month if you keep one attached. Lightsail’s $5 bundle is usually easier to reason about than stacking those EC2 line items.

In my case I did not buy a new box just for this blog. I already pay for a server that hosts other projects, so the extra cost of putting WordPress on it is basically the time to set it up, not a second VPS invoice.

The static mirror. This is the cheap one at small scale, as long as you stay honest about the math.

  • Amazon S3 Standard storage in US East (N. Virginia) is $0.023 per GB-month for the first 50 TB. PUT, COPY, POST, or LIST requests are $0.005 per 1,000. GET and other requests are $0.004 per 10,000. Data transferred from S3 to CloudFront is not charged as S3 data transfer out.
  • Amazon CloudFront still publishes an always-free allowance of 1 TB of data transfer out and 10 million HTTP or HTTPS requests per month on the AWS Free Tier networking page. CloudFront also has a $0 per month Free flat-rate plan that includes 1 million requests, 100 GB of transfer, 5 GB of S3 storage credits, and Route 53 DNS, with no overage charges, though it is a different product from the always-free pay-as-you-go allowance.
  • Amazon Route 53 is $0.50 per hosted zone per month for the first 25 hosted zones. Standard queries are $0.40 per million. Alias records pointed at CloudFront do not add a query charge.
  • Let’s Encrypt certificates are free.

AWS Free Tier networking page listing CloudFront always-free allowances of 1 TB of data transfer out and 10 million HTTP or HTTPS requests

A labeled example, not a bill: say a small blog stores 1 GB in S3 and, in a month, serves 20 GB and 50,000 requests through CloudFront. The storage would be about $0.023. The transfer and requests would fit inside CloudFront’s always-free allowance. The Route 53 hosted zone would be $0.50, unless you already pay for that zone or you use the $0 CloudFront flat-rate plan that includes DNS. Let’s Encrypt is $0. The example total is about $0.52 a month, or close to $0.02 if the hosted zone is already on the account. That is an illustration for a quiet site. A huge traffic spike, a lot of images, or a region with different rates would change it.

The useful comparison, for me, is this. After the intro period, a Bluehost Starter plan is $9.99 a month on the 36-month renewal. A small Lightsail box is $5 a month if you need a new server. If you already have a server, the blog may add nothing to that bill. And once the public site is a static copy, S3 plus CloudFront plus DNS can sit well under a dollar a month at small-blog size, with a free certificate.

How Do I Post With wp-cli and an AI Agent?

Once WordPress is just software on a server, you do not have to click through wp-admin to publish. WP-CLI can create posts, set categories and tags, attach a featured image, and leave the thing as a draft.

That is what I have an AI agent do. It writes the draft with wp post create --post_status=draft. I review it. If it looks good, it goes live, and then the static mirror gets rebuilt so the public URLs update.

I will not auto-publish a post I have not read. Drafts exist for a reason. But I also will not go back to FTPing a theme file and pasting HTML into the block editor at midnight if I can type “write a draft about X” and then actually edit it.

The nice loop looks like this:

  1. AI agent creates a draft through wp-cli
  2. I review it on the private WordPress install
  3. The post is published
  4. The static mirror rebuilds
  5. The CDN serves the new page at the same old URL

That is the whole point of moving off shared hosting, for me. Not a slightly faster TTFB. A workflow I can run with tools I already use, without leaving a WordPress login form on the public internet all day.

Did You Really Not Click the Link Yet?

If you only open one official page, make it Hardening WordPress. Then read the brute force page, grab a certificate from Let’s Encrypt with Certbot, and bookmark WP-CLI. Google’s notes on sitemaps and RSS or Atom feeds are worth a pass too.

And if you have been putting off leaving shared hosting, start with a backup and a test restore. The DNS flip is the scary five minutes. The rest is just being careful with MX records and not exposing more WordPress than you need to.