Moving a business site from Vercel to Cloudflare Pages, with the domain at GoDaddy
A step-by-step guide to hosting a small business's static site for free on Cloudflare Pages: why Vercel's free plan didn't fit, moving a GoDaddy domain's DNS to Cloudflare without moving the domain, the www redirect, a staging site on its own subdomain kept private with Cloudflare Access, and the problems I hit on the way. Written after doing it for my family's catalogue site in October.
In October I built a catalogue website for FI Woodart, my family's wood-art business. I put it on Vercel first, because that's what I always reach for. Then the family bought a domain at GoDaddy, and before pointing it anywhere I reread Vercel's terms. The free Hobby plan is "restricted to non-commercial personal use only", and Vercel's fair use guidelines list "advertising the sale of a product or service" as commercial use. A catalogue of things for sale is exactly that. Staying meant Vercel's Pro plan, at about $20 a month, for a static site with no server code.
Cloudflare Pages allows commercial sites on its free plan, so I moved. The switch itself took one evening. This guide covers every step in order, including the ones that confused me. It assumes a static site in a GitHub repository (mine is built with Astro) and a domain registered at GoDaddy. Cloudflare and GoDaddy change their screens often, so the menu names may have moved by the time you read this.
What you end up with
All of it is free. Cloudflare Pages' free plan allows 500 builds a month, one at a time, and states no bandwidth limit for a static site. Cloudflare Access, which keeps staging private, comes with 50 free seats on every account. The only bill is the domain renewal at GoDaddy.
The idea that makes the rest make sense
Your domain has a registrar and a DNS host, and they don't have to be the same company. The registrar is where you bought the name and where you renew it. The DNS host answers the question "where does this name point?". At first GoDaddy is both.
Cloudflare Pages can only serve the bare domain (fiwoodart.co.in rather than www.fiwoodart.co.in) if Cloudflare is the DNS host. So you keep GoDaddy as the registrar and hand only the DNS to Cloudflare, by changing the domain's nameservers. You still renew at GoDaddy. Turn on auto-renew there. After the change, the DNS records in your GoDaddy account stop mattering, because nothing reads them any more.
You can also move the registration itself to Cloudflare later, but a domain can't change registrar in its first 60 days, and you don't need to.
Before you touch DNS: prepare the code
Do this first, while the old host is still serving. If your site behaves the same on both hosts, you can switch the domain without a broken moment.
Tell production apart from everything else. Cloudflare Pages sets CF_PAGES_BRANCH on every build. I treat only a main build as production, and anything else as staging that must not be indexed:
// src/lib/env.ts
const branch = process.env.CF_PAGES_BRANCH;
export const isProduction = branch === "main";
export const isStaging = !isProduction;
Without this, nothing distinguishes the live build from a preview, and the live site can ship hidden from Google with a staging banner on it. That is exactly what happened to me for two minutes, and it's further down.
Give each build the right address. Canonical tags and the sitemap need absolute URLs. Production should use your domain, and previews their own address, which Cloudflare provides as CF_PAGES_URL:
// astro.config.mjs
const env = process.env;
const host = env.CF_PAGES_BRANCH === "main" ? env.SITE_URL : env.CF_PAGES_URL;
export default defineConfig({
site: host || "http://localhost:4321",
build: { format: "file" },
});
SITE_URL is a variable you set in Cloudflare, in step 1.
Check what happens to /about. On Vercel I had clean URLs turned on. On Pages, a site built as about/index.html answers /about with a redirect to /about/. Building pages as about.html instead (format: "file" above) makes Pages serve /about directly. If you do this, check your canonical tag. Mine was built from the page's path, which now ended in .html, so every canonical said /about.html. I strip it:
const canonical = new URL(Astro.url.pathname.replace(/(index)?\.html$/, ""), Astro.site);
Move your headers. Pages doesn't read vercel.json. Put response headers in a _headers file in the folder that gets published (public/ in Astro):
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Redirects, if you have any, go in a _redirects file next to it.
Pin Node. A .node-version file at the root containing 22 tells Pages which Node to build with.
1. Create the Pages project
In the Cloudflare dashboard, go to Workers & Pages, then Create. Make sure you're on the Pages tab, because the page opens on Workers by default. Choose Connect to Git and pick your repository.
The first build gives you your-project.pages.dev. Check it before going further.
2. Build only main and staging
By default Pages builds a preview of every branch you push. I wanted two builds and no more: main for the live site and staging for testing.
Go to Settings, then Build, then Branch control. Set the production branch to main, set Preview branch to Custom branches, and include staging.
Two things caught me here. The include box is a chip input: type staging and press Enter so it turns into a chip before you save. If you just type and save, the text is thrown away. Mine silently kept a * chip, which means every branch, and feature branches went on building. And "All non-Production branches" ignores the include list completely, so don't choose it to save time.
Saving these settings doesn't start a build. To get the first staging build, push something to staging; an empty commit does it. After any push, wait a few minutes before checking whether a branch built. I once checked a minute and a half after a push, saw nothing, and wrongly decided the setting worked.
3. Add the domain to Cloudflare
In the dashboard, add your domain (it's under Domains, as onboarding a domain) and choose the Free plan. If you start from the Pages project's Custom domains tab instead, Cloudflare offers to "Begin DNS transfer". That sounds like moving the registration, but it only means adding the domain to Cloudflare's DNS. It leads to the same place.
Cloudflare scans GoDaddy's existing records and copies them in. The quick scan isn't guaranteed to find everything, so compare it against GoDaddy's DNS page. For a domain that only ever had GoDaddy's defaults, I deleted all four imported records:
Pages creates the records it needs in step 6, and stale ones get in the way. Keep any MX and TXT records if the domain has email. I had none. Deleting records here only changes Cloudflare's copy, so it's safe; GoDaddy's originals don't move.
Cloudflare then shows you two nameservers assigned to your domain. Copy them exactly.
4. Change the nameservers at GoDaddy
In GoDaddy, open the domain's DNS settings. It took me two tries to find them: the first page I landed on was GoDaddy's "GoConnect" shortcut page, which has no nameserver setting. The route that worked was the domain's Manage DNS card, which has tabs for DNS Records, Forwarding, Nameservers, Hostnames and DNSSEC.
GoDaddy says most changes take effect within an hour and can take up to 48.
5. Wait for Cloudflare to show "Active"
Mine went Active in about eight minutes. Before that, the checks disagreed, which looked alarming but wasn't. The registry had the new nameservers straight away, while public resolvers kept giving the old answer until their cached copy expired, an hour later at most. To see what a public resolver currently thinks:
nslookup -type=NS yourdomain 1.1.1.1
Cloudflare also emails you when the domain becomes active.
6. Connect the domain and www to Pages
In the Pages project, go to Custom domains, choose Set up a custom domain, enter the bare domain, and activate it. Cloudflare creates the DNS record itself: a CNAME from the bare domain to your-project.pages.dev. A CNAME isn't normally allowed at the bare domain, but Cloudflare flattens it, so it works. Then do the same for www.
Doing it twice felt redundant to me at first. It isn't: they are two different names, and each needs its own record and its own certificate. Both of mine were active within about six minutes, certificates included. Don't create these CNAME records by hand before adding the domains in Pages. Cloudflare warns that a record pointing at Pages without the domain being added there first gives a 522 error.
Both records should be proxied, the orange cloud. Steps 7 and 9 depend on it.
7. Redirect www to the bare domain
In your domain's Rules, create a rule from the template Redirect from WWW to root. It matches https://www.*, sends it to https://${1}, with a 301 and Preserve query string ticked. Cloudflare's version is the same. Deploy it.
Mine showed as active, and www still didn't redirect on my laptop for a while. The rule was fine. My internet provider's resolver still had GoDaddy's old address for www cached, so my requests never reached Cloudflare. Flushing the computer's DNS cache doesn't help when the stale answer comes from further up. It sorted itself out within the hour, and a phone on mobile data showed the redirect working sooner. To test the rule regardless of your resolver, send the request straight to Cloudflare with curl --resolve.
8. Put the real site live
The moment the domain connected, it served whatever was on main. For me that was a build from before the code changes above, so the live domain showed a "Staging · local" ribbon, told search engines not to index it, and had a canonical tag pointing at localhost. Merging staging into main fixed all of it two minutes later. It was a good accident: an unready site that hides itself from Google is the safe way round.
Once the production build finishes, check the live site from a terminal:
curl -sI https://yourdomain/about # 200, no redirect
curl -sI https://www.yourdomain/ # 301 to https://yourdomain/
curl -s https://yourdomain/robots.txt # Allow, with your sitemap's address
Then view the page source and confirm the canonical tag uses your domain, with no .html on the end.
9. Give staging its own subdomain
Pages already serves the staging branch at staging.your-project.pages.dev. A subdomain of your own is easier to remember and send to people. Cloudflare calls this a custom branch alias:
10. Make staging private
Staging should be visible to you and the people checking it, not the public. Cloudflare Access puts a login in front of a hostname. The dashboard now calls this part of Cloudflare Cloudflare One, formerly Zero Trust. Setting it up asks you for a team name; mine started as a random one, which I renamed.
That covers your subdomain, but staging is also reachable at its pages.dev address, which stayed public. Close it in the Pages project: Settings, then General, then Access policy, then Enable. This creates a second Access application covering all your preview addresses. By default it admits only you, the account owner, so add the same emails to its policy.
Check both from a private window. Each staging address should land on a sign-in page asking for an email. The live domain and your-project.pages.dev, which mirrors production, should load as normal.
11. Keep staging out of search
Access already stops search engines reaching staging. I keep the site's own guard as well, because it costs nothing. Every non-production build tells crawlers to stay away, in the robots file:
// src/pages/robots.txt.ts
export const GET: APIRoute = ({ site }) => {
const body = isProduction
? `User-agent: *\nAllow: /\n\nSitemap: ${new URL("sitemap-index.xml", site)}\n`
: "User-agent: *\nDisallow: /\n";
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
};
It also adds <meta name="robots" content="noindex, nofollow"> and a small "Staging" ribbon to every page. Pages also sends a noindex header on preview addresses by default.
12. Tell Google about the site
In Google Search Console, add a Domain property for the bare domain. This covers www, every subdomain and both protocols in one property. Domain properties are verified by a TXT record. Because the DNS is on Cloudflare, Search Console offered to add the record itself, and verification finished without my typing anything into DNS. Leave that record in place for good.
Then submit the sitemap. A Domain property needs the full address, such as https://yourdomain/sitemap-index.xml. Use URL inspection to request indexing for your main pages if you don't want to wait for Google to find them.
If you're doing this for a client, set it up under your own Google account and add the client as an Owner, so they keep access whatever happens to you.
13. Retire the old host
Once the live site is right, disconnect the repository in Vercel's project settings, so pushes stop building there too. Then delete vercel.json and any code that checked Vercel's variables. To confirm the disconnect, check the repository: Vercel's status check stops appearing on new pushes.
Mistakes to avoid
| Mistake | What happens |
|---|---|
| Following Vercel advice to keep DNS at GoDaddy | Fine on Vercel, but Pages can't serve the bare domain unless Cloudflare runs the DNS |
| Typing into Branch control's include box without pressing Enter | The text is discarded on save, and every branch keeps building |
| Leaving GoDaddy's imported records in Cloudflare | Parking records and the old www record clash with the ones Pages creates |
| Changing nameservers with DNSSEC still on | The domain can stop resolving |
Adding the Pages CNAME by hand before adding the domain in Pages | A 522 error |
| An unproxied staging record | The staging subdomain quietly shows the live site |
| Protecting only the staging subdomain with Access | Its pages.dev address stays open to anyone until you enable the project's access policy |
Pointing an Access application at the bare domain or * | Your live site sits behind a login |
| Judging the www redirect from your own laptop right away | You may be seeing your provider's cached answer, not Cloudflare's |
More like this, occasionally.