In short

You do not need servers in every region to feel local in every region. One small origin server behind a global CDN edge covers all of it: visitors everywhere get pages served from their nearest data centre, your editors publish once and it goes live everywhere within seconds, and you pay for one machine instead of a fleet. This guide shows exactly how to set up the cache headers, edge rules, and purge hook that make it work.

Customers reward businesses that feel like theirs. Someone searches for a service near them and clicks the result that looks local, loads fast, and feels close to home. If your site feels like it lives somewhere else, or crawls for a visitor on the far side of the map, they are gone before you get to say hello. When you serve more than one town, city, or country, feeling local everywhere at once is the difference between a search that turns into booked work and one that bounces.

The usual advice is to put servers in every region. That is expensive, slow to run, and painful to keep in sync. On one site I run, ampy.se, the business needs to look local in many places at once. I do it from a single small server in Helsinki, operated from one control point, and I add new regions as configuration, not as a rebuild. It still shows up fast and local to people everywhere. This is exactly how, step by step, in a way you can copy.

Why one origin plus a global edge beats a fleet of servers

Here is the idea in plain terms. When you run servers in many regions, you are really running many copies of the same site. Each copy needs updating, patching, backing up, and watching. When one drifts out of sync, a customer in that region sees something slightly wrong, and you may not notice for weeks. You pay for all of it, every month, forever.

There is a much simpler shape that works beautifully for a content site (which most business sites are). You keep one origin server, the single source of truth. In front of it you put a content delivery network, an edge CDN. The CDN has data centres all over the world. When someone in Berlin, Toronto, or Tokyo visits, they are served from the data centre nearest to them, not from your one server in Helsinki. The page has already been copied out to that edge, so it arrives almost instantly.

The key insight is this: for a content site, most pages are the same for every visitor. A services page, a location page, an article, they do not change between one visitor and the next. So there is no reason to build them fresh every time. You build the page once, the edge holds a copy, and thousands of visitors around the world get that copy at local speed. Your origin is barely touched.

What you actually need

  • One small origin server. I use a Hetzner VPS in Helsinki. It runs WordPress, a database, and a page cache.
  • An edge CDN in front of it. I use Cloudflare, because its network is huge and its cache rules are easy to reason about.
  • Correct cache-control headers, so the edge knows exactly what it may cache and for how long.
  • A clear split between the pages you cache and the small dynamic parts you never cache.
  • A repeatable deploy, so adding a region is a config change, not a project.

The part nobody warns you about

The danger with edge caching HTML is that you can cache something you should not, and show the wrong thing to the wrong person. Imagine caching a logged-in admin bar, or a shopping cart, or a form with a security token in it. If the edge stores that and serves it to the next person, you have a real problem. This is why most people never cache HTML at the edge at all, and then wonder why their site is slow far from home.

The fix is not to avoid edge caching. The fix is to be precise about what is cacheable. Public pages, the same for everyone, get cached hard. Anything tied to a specific person, the admin, a logged-in user, a live form, is marked so the edge never touches it. Once you draw that line clearly, edge caching HTML is safe and the whole site flies.

Once you draw that line clearly, edge caching HTML is safe and the whole site flies.

Building it step by step

Step 1: get the origin producing clean, cacheable HTML

Before the edge can cache your pages well, the origin has to send the right signals. Every response carries a Cache-Control header that tells caches what they may do. For a public page you want to say: yes, you may store this, and here is how long it stays fresh.

WordPress, by default, tends to send headers that discourage caching, because it assumes pages are dynamic. So the first job is to correct that for public visitors while leaving logged-in requests alone. Here is the pattern I use, dropped into the theme or a small must-use plugin. Read the WHY in the comments.

<?php
/**
 * Send cache-friendly headers for public pages only.
 * Logged-in users and anything with a nonce must never be cached,
 * so we bail out early for them.
 */
add_action( 'template_redirect', function () {

    // Never cache the admin, logins, or logged-in sessions.
    if ( is_admin() || is_user_logged_in() ) {
        return;
    }

    // Never cache POST requests (form submissions, search posts, etc.).
    if ( ! empty( $_POST ) ) {
        return;
    }

    // Never cache pages that must stay private per visitor.
    if ( is_preview() || is_404() ) {
        nocache_headers();
        return;
    }

    // Public page: safe to cache at the edge for a while,
    // and let the browser hold it a shorter time.
    // s-maxage controls the CDN. max-age controls the browser.
    header( 'Cache-Control: public, max-age=600, s-maxage=86400' );
}, 1 );

Two numbers matter here. max-age is how long the visitor’s own browser keeps the page. s-maxage is how long shared caches, like the CDN, keep it. I let the edge hold pages far longer than the browser, because I can purge the edge instantly when content changes, but I cannot reach into someone’s browser. Long edge life, short browser life, is the sweet spot.

Step 2: run a page cache on the origin

Even with the edge doing most of the work, you want the origin to answer instantly on the rare miss. When the edge has no copy of a page (a fresh region, or a page just purged), it asks the origin. If the origin has to boot WordPress and run database queries for that request, it is slow, and if many regions miss at once, your one server feels the load.

The answer is a page cache in front of WordPress. On ampy.se I use a full page cache so that most origin hits are served as flat, pre-built HTML without touching PHP or the database at all. FlyingPress does this cleanly at the WordPress level, and you can pair it with a server-level cache like Varnish or nginx if you want another layer. The point is simple: by the time a request reaches the actual application, it should be the exception, not the rule.

Pro tip

Stack your caches outermost-first: the CDN edge handles the vast majority of traffic, the server page cache answers edge misses as flat HTML, and the database is only touched when something genuinely new must be built. Each layer you add makes the one behind it irrelevant for most requests.

The layers stack like this, outermost first:

  • Cloudflare edge: holds HTML copies worldwide, serves almost every visitor.
  • Server page cache: answers edge misses as flat HTML.
  • Object cache (Redis): speeds up the rare request that does reach PHP.
  • WordPress and the database: touched only when something genuinely new must be built.

Step 3: cache HTML at the edge, safely

By default Cloudflare caches images, CSS, and JavaScript, but not HTML. To get the local-everywhere speed, you have to tell it that your public HTML is fair game. You do this with a cache rule, and you make it conditional so you never cache the wrong thing.

In Cloudflare, create a cache rule with a condition that excludes anything risky. The rule I use says: cache eligible for everything, EXCEPT when the URL path looks like the admin or a logged-in area, and EXCEPT when a login cookie is present. Expressed as a Cloudflare rule condition, it looks like this.

// Cloudflare cache rule condition (Rules -> Cache Rules).
// "If" this matches, set caching to Eligible and respect origin headers.

(not starts_with(http.request.uri.path, "/wp-admin/"))
and (not starts_with(http.request.uri.path, "/wp-login"))
and (not http.request.uri.path contains "/cart")
and (not any(http.request.cookies["wordpress_logged_in_*"][*] != ""))

// Then set:
//   Cache eligibility: Eligible for cache
//   Edge TTL: "Use cache-control header if present"
//   Respect the s-maxage we set on the origin.

Notice that the edge caching decision and the origin headers work together. The origin says “this public page may live in a shared cache for a day” with s-maxage. The edge rule says “for this path, with no login cookie, honour that.” Neither alone is enough. Together they mean a visitor in any region gets a page that was built once and copied to their nearest data centre.

Step 4: keep the dynamic bits uncached

Some things must be live for every visitor, even on an otherwise cached page. A contact form needs a fresh security token. A “get a quote” form must not reuse another person’s session. The clean way to handle this is to keep the page itself cached, but load the truly dynamic parts separately so the cache never freezes them.

There are two common patterns, and I use both depending on the case.

Pattern one: keep dynamic endpoints out of the cache entirely

Form submissions, the WordPress REST API, and admin-ajax should never be cached. Make sure their responses say so, and that your edge rule already excludes their paths. Here is how I mark those responses on the origin.

<?php
/**
 * Force no-cache on dynamic endpoints so neither the edge
 * nor the browser ever stores a per-visitor response.
 */
add_action( 'rest_api_init', function () {
    header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
} );

Pattern two: cache the page, fetch the live piece after load

If a page is mostly static but has one small live element (say a form with a nonce), cache the whole page and pull the live element in with a small request after the page loads. The page arrives instantly from the edge; the one dynamic bit is fetched fresh. The visitor never feels the difference, and you never cache a security token.

<?php
/**
 * Expose a tiny endpoint that returns only the fresh nonce.
 * The cached page loads instantly; JS calls this to fill in
 * the live token before the form can be submitted.
 */
add_action( 'rest_api_init', function () {
    register_rest_route( 'site/v1', '/form-token', array(
        'methods'             => 'GET',
        'permission_callback' => '__return_true',
        'callback'            => function () {
            // no-store so this fragment is never cached anywhere.
            header( 'Cache-Control: no-store' );
            return array( 'nonce' => wp_create_nonce( 'contact_form' ) );
        },
    ) );
} );

This split is the whole trick. The heavy, shared page is cached hard and served locally everywhere. The tiny, personal piece is fetched live. You get static-file speed without ever showing one person another person’s data.

Step 5: purge the edge the moment content changes

The one downside of caching a page for a day is that an edit might not show up for a day. The fix is to purge the edge whenever content changes, so the cache is long-lived but never stale. When an editor updates a page, I tell Cloudflare to drop that URL from every region at once, and the next visitor gets the fresh copy rebuilt.

Never put your API token in code. Read it from a constant defined in wp-config.php, so secrets stay out of the repository.

<?php
// In wp-config.php (never in the theme, never committed):
// define( 'CF_ZONE_ID', 'YOUR_ZONE_ID' );
// define( 'CF_API_TOKEN', 'YOUR_CLOUDFLARE_TOKEN' );

/**
 * When a post or page is saved, purge just its URL at the edge.
 * The cache stays long-lived, but edits go live within seconds.
 */
add_action( 'save_post', function ( $post_id ) {

    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }

    $url = get_permalink( $post_id );
    if ( ! $url ) {
        return;
    }

    wp_remote_post(
        'https://api.cloudflare.com/client/v4/zones/' . CF_ZONE_ID . '/purge_cache',
        array(
            'headers' => array(
                'Authorization' => 'Bearer ' . CF_API_TOKEN,
                'Content-Type'  => 'application/json',
            ),
            'body'    => wp_json_encode( array( 'files' => array( $url ) ) ),
            'timeout' => 5,
        )
    );
}, 20 );

Step 6: make routing look local in each region

Looking local is partly speed and partly content. The speed comes from the edge. The local feel comes from having a real page for each place you serve, with the right town or country in the copy, the right contact details, and location schema. On a multi-region site I build a page per location from a template, so each region has its own URL that ranks locally and reads as if it were made for that place.

Those location pages are just public HTML, so they cache at the edge like everything else. That means a page written for one region is served from the data centre nearest to the person reading it. Local in content, local in speed, from one origin.

If you need the CDN to route different regions to different content, Cloudflare can branch on the visitor’s country at the edge. Keep this light: a redirect to the correct localized path is usually all you need, and it keeps every variant cacheable.

// Cloudflare rule: send visitors to the localized path once,
// then let normal edge caching serve it. ip.geoip.country
// is provided by the edge, so this never touches the origin.

(ip.geoip.country eq "SE" and not starts_with(http.request.uri.path, "/se/"))
// Then: redirect (302) to concat("/se", http.request.uri.path)

A repeatable deploy, so a new region is config not a rebuild

The reason this scales without more servers is that adding a region never means new infrastructure. It means new content and, at most, a new routing rule. I keep the whole setup in version control so a region is a repeatable step, not a one-off scramble.

My checklist for adding a region looks like this:

  • Add the location pages from the template, filling in the local details.
  • Confirm the pages send public cache headers (Step 1 already covers this, so usually nothing to do).
  • If the region needs its own path, add one Cloudflare routing rule.
  • Deploy. The edge fills its regional caches on first visit, and every visitor after that is served locally.

Because the origin, the headers, and the cache rules are all defined once and reused, there is no drift. Every region behaves the same, because every region is served by the same single origin through the same edge. When I patch WordPress, I patch it once. When I back up, I back up one server. When I change a cache rule, it applies everywhere at once.

Prove it works

Do not trust that caching is working; check it. Every cached response from Cloudflare carries a header, cf-cache-status. After the first visit it should say HIT. Test it from the command line against a public URL.

# Ask for headers only, twice. The second call should say HIT.
curl -sI https://www.example.com/services/ | grep -i cf-cache-status
# cf-cache-status: HIT   (served from the edge, not your origin)

# Confirm the origin is telling the edge it may cache:
curl -sI https://www.example.com/services/ | grep -i cache-control
# cache-control: public, max-age=600, s-maxage=86400

# Confirm a dynamic endpoint is NOT cached:
curl -sI https://www.example.com/wp-json/site/v1/form-token | grep -i cf-cache-status
# cf-cache-status: DYNAMIC   (correct: never cached)

Then test the thing that actually matters: speed from far away. Use an online speed test that runs from several cities, or ask a friend on another continent to load the site. A properly edge-cached page should load fast from everywhere, not just near Helsinki. If one region is slow, check that its pages are getting HIT, not MISS, and that no stray login cookie is disabling the cache.

The payoff, in business terms

Here is what this actually buys you, past the jargon:

  • Customers everywhere get your pages fast and feel like you are local to them, so far fewer of them leave before the page loads.
  • Search engines see a fast, stable site in every region and rank you locally, where the customers actually are.
  • Your editors change something once and it goes live everywhere within seconds, because the edge purges on save.
  • You pay for one small server, not a fleet, and you patch, back up, and watch one thing instead of many.
  • Adding a new region is an afternoon of content and one config change, not a project with its own budget.

You do not need servers in every country to look local in every country. You need one origin you trust, a global edge in front of it, honest cache rules that draw a clean line between the shared page and the personal piece, and a deploy you can repeat. Build that, and you show up fast and local to customers everywhere, while your bill stays the size of one small machine.