← All articlesWeb development

How to host a website on Cloudflare for practically nothing

Build and deploy a static website with Astro, GitHub and Cloudflare Workers, then add DNS, feature flags and a Turnstile-protected form without running a server.

This website and the Diplo Translator Omni website use the same basic hosting pattern: build the pages with Astro, keep the source in GitHub, and deploy the output to Cloudflare Workers Static Assets.

For a blog, portfolio, documentation site or small product website, it is a useful combination. There is no always-running application server to maintain, and the static hosting can fit comfortably within Cloudflare’s free offering.

The Translator site goes a little further. Its pages are still static, but a small Worker handles the support form, verifies a Turnstile token, applies rate limiting and sends the enquiry through a configured email binding. It is a working example of adding a focused server-side feature without rebuilding the whole site around a server-rendered application.

Here is the process, including the details that are easy to miss when following a quick deployment tutorial.

Jump to the static build, GitHub connection, DNS setup, Turnstile or the GitHub Actions YAML.

What does “practically nothing” mean?

Under the Workers pricing published when this article was checked, requests served directly as static assets are free and unlimited. The Workers Free allowance for dynamic invocations is separate: 100,000 requests per day with a 10ms CPU allowance per invocation. The paid plan has a minimum account charge of US$5 per month. Optional Workers Cache and requests that execute Worker code have their own billing implications, so do not assume every request involving a static file is automatically in the free static-assets category.

Builds also have their own allowance. Cloudflare currently lists 3,000 build minutes per month and one concurrent build on the free plan. Check the current Builds limits and pricing before relying on those numbers.

Your domain registration still costs money. GitHub Actions, email sending, storage, databases and third-party APIs can have separate quotas or charges. In particular, hosting the Omni marketing website does not make the software licence or its customers’ AI-provider usage free.

For a modest static site, the useful proposition is that you may have no additional monthly application-hosting bill. That is different from promising that every possible feature and traffic level costs nothing. Prices and limits in this guide were checked on 10 November 2026.

The architecture: build once, serve files

The normal publishing path looks like this:

Markdown, Astro components, CSS and images
                  ↓
             GitHub commit
                  ↓
       Cloudflare Workers Builds
                  ↓
             Astro build
                  ↓
      dist/ — HTML, CSS, JS, images
                  ↓
      Workers Static Assets deployment
                  ↓
         Visitors on your domain

Astro renders the pages during the build. A visitor requesting an article receives an already-generated HTML document. Browser JavaScript can still provide interactive features: the LINQ comparer on this site is one example.

My personal site also generates its sitemap, RSS feed, robots.txt, llms.txt and article metadata during the build. A file named rss.xml.ts in the source does not imply a running API: in this static setup, Astro executes it while building and writes the resulting XML to disk.

Cloudflare stores and serves the deployed assets without fetching them from a separate origin server. You do not need to provision a Worker script simply to return those files. The Static Assets documentation covers the platform model.

If your application needs per-user server rendering, a database-backed administration system or an existing server runtime, assess those requirements separately. The architecture described here is specifically useful when the public pages can be generated ahead of time.

1. Build a static site locally

The important Astro settings are straightforward:

import { defineConfig } from 'astro/config';

export default defineConfig({
  site: 'https://www.example.com',
  output: 'static',
  trailingSlash: 'always',
});

Use your intended public canonical address for site, even when reviewing a temporary deployment hostname. This site uses https://www.diplo.co.uk; the product site uses https://translator.diplo.co.uk.

Install dependencies, check the project and build it:

npm ci
npm run check
npm run build

Those commands assume an existing Astro project with a committed lockfile and the corresponding scripts. For a new project, create the Astro application first and establish those scripts before configuring CI.

Commit the source, package.json, package-lock.json and deployment configuration. Keep node_modules, dist, local environment files and Wrangler’s local state out of Git. The deployment should rebuild from source, rather than depend on generated files copied from a developer’s machine.

I keep the project’s Node version in .nvmrc and pin Wrangler in the development dependencies. Use supported versions compatible with your project and review dependency updates; a small static site still has a build toolchain to maintain.

2. Tell Wrangler where the files are

Here is a reduced version of this site’s wrangler.jsonc:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-static-website",
  "compatibility_date": "2026-10-19",
  "assets": {
    "directory": "./dist",
    "not_found_handling": "404-page",
    "html_handling": "force-trailing-slash"
  }
}

The Worker name must match the project you create in Cloudflare. The assets directory must match the framework’s actual build output. The compatibility date is a deliberate platform-behaviour setting, not a value to change on every build; the date above is the one used in this project’s configuration.

There is no main entry because this site has no custom Worker code. It also needs no Astro server-rendering adapter for this static deployment.

Create a real Astro 404 page, which builds to 404.html. With 404-page handling, an unknown path gets the error page and a 404 status. Using a single-page-application fallback for a conventional blog can turn missing pages into misleading successful responses.

For an early packaging check, Wrangler also supports:

npx wrangler deploy --dry-run

Run it after building. A successful dry run checks the local deployment package; it does not prove that your account permissions, custom domain or live application behaviour are correct.

3. Connect GitHub to Workers Builds

In Cloudflare’s Workers & Pages area, create an application using the Git repository import flow. Select the GitHub account and repository, then connect it to the Worker.

If the repository is missing from the list, check the Cloudflare GitHub app installation’s repository access. A private repository can remain private; the integration needs permission to read it.

My personal site uses these settings:

Setting Value
Production branch main
Root directory Repository root
Build command npm run build:cloudflare
Production deploy command npx wrangler deploy
Node version Project’s committed .nvmrc
Preview branches Enabled for branches I want to review

build:cloudflare is my project’s script, not an Astro or Cloudflare built-in. It runs checks, builds the site and audits the output before allowing deployment. A simpler project’s equivalent might initially be npm run check && npm run build.

Cloudflare handles dependency installation and runs the configured commands. Its Builds configuration documentation explains the settings and how the project’s Wrangler version is selected.

Push a commit and follow the build logs. Check whether a failure happened during dependency installation, the application build, an audit or deployment; they are different stages with different fixes.

Preview deployments need their own configuration

New Workers use Worker Previews with npx wrangler preview by default. Older connected projects can still use the earlier preview-version model, including wrangler versions upload. The Translator project’s configuration records that older model. Follow the current branch-build guidance and the settings for your specific project rather than changing commands indiscriminately.

If deployment succeeds but the dashboard says “No URLs enabled”, inspect Domains & Routes and enable the relevant workers.dev hostname or configure a custom domain. A successful build does not itself guarantee that a public hostname is enabled. Worker Previews have their own URL/settings model as well.

Review the deployment on its temporary URL before pointing your real domain at it. Check images, direct navigation to nested pages, redirects and missing URLs, as well as the homepage.

4. Keep previews out of search results

This site uses a build-time flag, SITE_INDEXING_ENABLED, combined with the branch supplied by Workers Builds:

const production = process.env.WORKERS_CI_BRANCH === 'main'
  && process.env.SITE_INDEXING_ENABLED === 'true';

const env = {
  ...process.env,
  SITE_INDEXING_ENABLED: String(production),
};

The build script passes that env object to its child check/build/audit processes. Only the production branch with an explicit true enables indexing. A shared Cloudflare variable cannot accidentally make every branch indexable.

The layout emits noindex, nofollow when indexing is disabled. The generated robots file disallows crawling in preview mode, while the production build allows it and advertises the sitemap. These are project conventions you must implement; setting an arbitrary environment variable alone has no effect.

Changing a build-time flag requires a fresh build. Redeploying the same old dist directory cannot change HTML that already contains a robots meta tag.

Noindex and robots rules are not access control. Use Cloudflare Access or another appropriate restriction if a preview must be private. For an already indexed URL, blocking crawling alone can prevent a crawler from seeing a new noindex instruction, so do not treat robots rules as a universal removal mechanism.

5. Connect the domain without breaking email

You can keep your domain registration with another registrar while managing DNS in Cloudflare. For a typical full-zone setup, add the zone, copy and verify the existing records, then update the authoritative nameservers at the registrar. If the zone is already active in the right Cloudflare account, there is no new nameserver move to make.

Before changing hosting records, export the DNS configuration and identify the exact hostnames serving the old website. Keep the previous values for rollback. Mail and unrelated subdomains should retain their own records.

For a Worker acting as the website’s origin, add a Custom Domain under the Worker’s Domains & Routes settings. A Worker Route is useful for other arrangements, such as placing code in front of an existing origin. Cloudflare’s Custom Domains documentation explains the distinction and the managed DNS/certificate setup.

In a dialog for a zone such as example.com, enter www if you want www.example.com; leaving the subdomain blank selects the apex, example.com.

An existing website A, AAAA or CNAME record can conflict with adding the same hostname. Resolve only the identified website conflict at cutover, after recording its old value. Do not clear the DNS zone: MX records, SPF/DKIM/DMARC TXT records and other service records are separate concerns. In my case, translator.diplo.co.uk is a separate Worker website and should not be redirected or replaced when changing the main site.

Choose one canonical hostname

This site uses HTTPS www. If you make the same choice, ensure the apex hostname resolves through Cloudflare and configure a host-specific redirect to www.

For a Cloudflare Single Redirect, an example is:

Match:       http.host eq "example.com"
Destination: concat("https://www.example.com", http.request.uri.path)
Status:      301
Preserve query string: enabled

That is a description of dashboard fields, not a configuration file to upload. The source hostname needs proxied Cloudflare handling for the rule to execute. Preserve the path and query string, check HTTPS for both hostnames, and make sure an existing rule does not redirect www back to the apex.

A catch-all redirect for every subdomain could break a product site or API. Match the intended hostname explicitly. You can use the Single Redirect settings reference when adapting the rule.

6. Keep redirects, headers and caching deliberate

For old paths on this website, I generate a _redirects file into the deployed assets. A small example would be:

/old-guide/ /articles/new-guide/ 301

Use permanent redirects when the move is permanent, avoid chains and check that the destination really exists. There is no benefit to redirecting every removed article to an unrelated homepage. The Static Assets redirects documentation covers the supported syntax and behaviour.

A _headers file can apply headers to static responses:

/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()

In Astro these files live in public and are copied into dist. Headers declared there are not automatically applied to responses your custom Worker generates; set the appropriate headers on API responses yourself. See the headers documentation.

Treat browser caching separately from deploying new files. Long-lived immutable caching is appropriate for fingerprinted assets whose filenames change with their content. If you replace an image under the same stable URL, visitors may keep an old cached copy. This is one reason I use versioned filenames when deliberately replacing article covers.

Keep images compact too. This site’s social covers are WebP, and the build checks their dimensions, decoding and metadata. A small file helps even when the hosting platform handles distribution for you. Asset-count and file-size limits still apply; review Cloudflare’s current static-asset limitations.

7. Add server-side behaviour only where you need it

The Translator Omni site is an example of the next step. It has product information, documentation and walkthroughs as static pages, plus a small support API.

Its routing configuration uses this pattern:

{
  "name": "my-product-website",
  "compatibility_date": "2026-10-19",
  "main": "worker/index.ts",
  "assets": {
    "directory": "./dist",
    "not_found_handling": "404-page",
    "binding": "ASSETS",
    "run_worker_first": ["/api", "/api/*"]
  }
}

This is a routing example, not the full contact-form configuration: the deployed implementation also needs its environment settings, rate-limit and email bindings, and Turnstile secret.

Matching static pages can be served through the assets system. The listed API paths go to the Worker first. In the Translator implementation, /api/support handles submissions, unknown API routes return JSON 404s, and the Worker’s fallback can call env.ASSETS.fetch(request).

Selective routing avoids needlessly invoking application code for every image and stylesheet. If you need authentication or transformation before serving particular files, configure and test that route deliberately. Cloudflare documents the choices under routing with a Worker script.

8. Use build variables, runtime variables and secrets for different jobs

The distinction is particularly useful on a mostly static site:

Setting Where it is used What changing it affects
SITE_INDEXING_ENABLED Build script and Astro build Generated indexing metadata; rebuild required
PUBLIC_SUPPORT_FORM_ENABLED Astro build Whether the generated contact page exposes the active form
PUBLIC_TURNSTILE_SITE_KEY Astro build/browser Public widget key embedded in the page
SUPPORT_ENABLED Worker runtime Whether the endpoint accepts submissions
SUPPORT_ORIGIN Worker runtime Expected website origin for submissions
TURNSTILE_SECRET_KEY Worker runtime secret Server-side challenge verification

Astro exposes PUBLIC_ variables to client-side code. A Turnstile site key belongs there because it is intended to be public. The secret key does not. Also remember that build-time code can leak any value it deliberately renders into HTML, regardless of its prefix. The Astro environment-variable guide explains the mechanism.

Check flags as strings. The value "false" is truthy in JavaScript, so Boolean(process.env.FEATURE_ENABLED) is not a reliable flag parser. Comparing against "true" is a simple explicit convention.

Store Worker secrets through Cloudflare’s secret-management mechanism, for example:

npx wrangler secret put TURNSTILE_SECRET_KEY

That command prompts for the value. Do not put it in committed source, a PUBLIC_ variable or a screenshot. Local Worker secrets belong in an ignored .dev.vars file for development, not in the public asset directory. Refer to the Worker secrets documentation for deployment environments and configuration details.

The two support-form switches serve different purposes. Disabling the public build flag can hide the form; the runtime switch prevents the endpoint from accepting submissions. Hiding a button alone does not stop someone calling its API.

9. Protect the form with Turnstile and server-side checks

The browser loads the Turnstile widget using the public site key. After the challenge succeeds, it submits the resulting token with the enquiry to the Worker.

The Worker must verify that token with Cloudflare before processing the enquiry. Cloudflare’s server-side validation guidance requires that verification; the client widget alone is insufficient. Tokens are single-use and expire after five minutes. Check the returned hostname and action as well as success.

Here is a deliberately small verification helper following the same approach as the Translator site:

type VerificationEnv = {
  TURNSTILE_SECRET_KEY: string;
  SUPPORT_ORIGIN: string;
};

export async function verifyTurnstile(
  token: string,
  env: VerificationEnv,
): Promise<boolean> {
  if (typeof token !== 'string' || !token || token.length > 2048) {
    return false;
  }

  try {
    const response = await fetch(
      'https://challenges.cloudflare.com/turnstile/v0/siteverify',
      {
        method: 'POST',
        signal: AbortSignal.timeout(5000),
        body: new URLSearchParams({
          secret: env.TURNSTILE_SECRET_KEY,
          response: token,
        }),
      },
    );
    if (!response.ok) return false;

    const result = await response.json() as {
      success?: boolean;
      hostname?: string;
      action?: string;
    };
    return result.success === true
      && result.hostname === new URL(env.SUPPORT_ORIGIN).hostname
      && result.action === 'support';
  } catch {
    return false;
  }
}

This helper does not implement the full endpoint. The production flow also validates the method, content type, body size, allowed fields, source origin and field lengths; checks a honeypot; rate-limits attempts; and keeps sending destinations fixed in server configuration. It does not let a visitor choose an arbitrary delivery address.

The response should not claim success before the sending operation has been accepted. Handle verification or delivery failures explicitly, avoid logging message bodies or tokens, and return non-cacheable API responses. Origin checks are useful alongside these controls, but a non-browser client can supply an Origin header, so they are not authentication.

Test rejected, expired and replayed tokens as well as the happy path. Use Turnstile’s test keys in an isolated development setup. The Translator implementation has explicit local-mode handling and refuses those dummy secrets in production; it does not weaken production checks just to make localhost work.

Turnstile has a free plan, while the Worker and delivery service retain their own usage rules. The Translator site uses an email binding configured for its support destination. Cloudflare’s email product and APIs evolve, so follow the current email-sending documentation and account requirements when implementing your own delivery step.

10. An optional GitHub Actions deployment workflow

Both of these sites use Cloudflare’s Git-connected Workers Builds. A GitHub Actions deployment is an alternative for projects that want CI configuration in their repository, integration with other checks, or a more customised release process.

Choose one system to deploy a particular production Worker. Running both for the same push can produce duplicate deployments and confusing release order.

For a project using this site’s build:cloudflare script and .nvmrc, an illustrative .github/workflows/deploy.yml is:

name: Deploy website

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: cloudflare-production
  cancel-in-progress: false

jobs:
  deploy:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci
      - name: Check, build and audit
        run: npm run build:cloudflare
        env:
          WORKERS_CI_BRANCH: ${{ github.ref_name }}
          SITE_INDEXING_ENABLED: ${{ vars.SITE_INDEXING_ENABLED || 'false' }}
      - name: Deploy checked assets
        run: npx wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}

Create a narrowly scoped Cloudflare deployment API token as a GitHub Actions secret. Configure the account ID as a repository variable and set SITE_INDEXING_ENABLED to true when the production site is ready to be indexed. Without that flag, this example deliberately builds the noindex variant.

The branch variable is set explicitly because the example reuses a script originally written for Workers Builds. In a different project, use its actual build command and branch logic. npm ci installs the repository’s pinned Wrangler, and npx wrangler deploy uses it; no unpinned deployment CLI installation is needed in the workflow.

The action tags shown match the official checkout and setup-node documentation at the time of writing. For a production workflow, consider pinning reviewed action commits and maintaining those pins. Check requirements for self-hosted runners. Cloudflare also provides a deployment action in its GitHub Actions guide.

This YAML has a production-only deployment path. It does not create branch previews, configure DNS or provision your Turnstile widget. GitHub Actions also has its own runner allowances and billing; moving the build there does not remove build costs from consideration.

11. Test the hosting behaviour, not just the build

Astro’s local preview is useful for checking the generated site. For Cloudflare-specific behaviour, run the built assets through Wrangler too:

# Build first, then keep this running in one terminal:
npx wrangler dev --local --ip 127.0.0.1 --port 4335

# In another terminal, inspect the actual response headers/status:
curl.exe -I http://127.0.0.1:4335/
curl.exe -I http://127.0.0.1:4335/a-page-that-does-not-exist/

curl.exe selects the executable explicitly on Windows; use curl on systems where that is the command name. The second request should return a genuine 404 for this configuration.

My personal-site build audits internal URLs, fragments, redirects, article dates, structured data, RSS, llms.txt and social-image files. Separate edge checks verify response statuses, headers and file delivery. A successful homepage render cannot tell you whether an old URL redirects correctly or a feed is being served with the wrong content type.

For a product site, add checks for the interactive parts too: a disabled form, invalid input, failed verification, rate limiting and delivery errors. Do not send real support messages as a side effect of every routine build.

Updating the site and rolling back

For everyday content changes, edit locally, review the result, commit and push. With Workers Builds connected, the configured production branch triggers a build and deployment. Use a feature branch when you want a separate preview first.

To rebuild unchanged source, use the build’s retry/rebuild control in the dashboard, or trigger another build through the configured integration. Watch for a build being run: deploying an existing version again does not regenerate its static HTML or incorporate changed build-time variables.

Keep the previous deployment available for application rollback. If you change DNS or external-service configuration during a release, record how to restore those too; rolling back Worker code alone does not restore DNS, external data or email settings.

The appeal for me is how small the moving parts can remain. This site publishes writing, music links and interactive examples as static files. The Translator Omni website adds a focused support endpoint while using the same basic publishing workflow. If you want to see how a small commercial product site can look on this setup, its feature overview and guides are useful places to explore.