Static CMS Guide

How to Deploy a Free CMS with GitHub, Decap CMS, and Cloudflare Workers

A practical step-by-step guide to adding a secure, Git-backed CMS to a static website using Decap CMS, GitHub OAuth, and a free Cloudflare Worker.

LoadingPage views in the last 30 days
Back to blog

Overview

A static website is fast, cheap, and secure, but editing content directly in code can become tiring. If you want to publish blog posts, tutorials, installation guides, quick fixes, or resource lists, a small CMS can make the workflow much easier. The challenge is cost and security. Many hosted CMS products eventually push you into a paid plan, and many self-hosted CMS options require a database, server maintenance, or a paid VPS.

This guide shows a practical free approach: use Decap CMS for the editor interface, GitHub as the content storage backend, and a Cloudflare Worker as the OAuth proxy that lets the CMS log in with GitHub. The final result is a browser-based editor at /admin, content saved as Markdown files in your GitHub repository, and a review workflow where changes can stay pending until you approve them.

This is the same pattern I used to add a CMS to a static Next.js resource site. The details below are written for a Next.js static export, but the same idea works for many static sites because Decap CMS writes files to your repository instead of storing articles in a database.

As of April 25, 2026, Cloudflare Workers Free includes 100,000 requests per day according to Cloudflare's official Workers pricing and limits documentation. That is more than enough for a personal CMS OAuth proxy, because the Worker only runs during login, not for every public page view. Always confirm current limits before relying on any free tier for a production business.

What you will build

By the end, you will have this setup:

  • /admin loads a Decap CMS editor.
  • GitHub stores blog posts as Markdown files.
  • A GitHub OAuth App handles identity.
  • A Cloudflare Worker exchanges GitHub OAuth codes for access tokens.
  • The OAuth secret stays in Cloudflare, not in the browser or repository.
  • Your public site remains static and cache-friendly.
  • Admin routes get stricter no-index and security headers.

The architecture is straightforward:

  • The editor opens /admin on your static site.
  • Decap CMS launches a GitHub login popup.
  • The Cloudflare Worker handles /auth and /callback.
  • The Worker exchanges the GitHub code for an access token.
  • The Worker sends the token back to Decap CMS.
  • Decap commits Markdown content to your GitHub repository.
  • Your static site rebuilds and publishes the approved content.

Prerequisites

You need a few things before starting:

  • A static website project, such as a Next.js static export.
  • A GitHub repository that stores your website source.
  • A Cloudflare account.
  • Node.js and npm installed locally.
  • Wrangler installed through your project or via npx.
  • A custom domain is optional, but recommended for a public site.

This tutorial assumes your content lives in a folder like content/blog, and your site can render Markdown files as article pages. If your project does not have that yet, add the CMS first, then connect the Markdown renderer afterward.

Step 1: Install Decap CMS

Install Decap CMS in your project:

bash
npm install decap-cms

You can load Decap CMS from a CDN, but for a stricter Content Security Policy it is better to self-host the bundle inside your own public/admin folder. That way your admin page can use script-src 'self' instead of allowing a third-party script domain.

Create a small sync script:

javascript
// scripts/sync-decap-admin.js
const fs = require("fs");
const path = require("path");

const source = path.join(process.cwd(), "node_modules", "decap-cms", "dist", "decap-cms.js");
const targetDir = path.join(process.cwd(), "public", "admin");
const target = path.join(targetDir, "decap-cms.js");

fs.mkdirSync(targetDir, { recursive: true });
fs.copyFileSync(source, target);

console.log(`Synced Decap CMS bundle to ${target}`);

Then update your package.json scripts:

json
{
  "scripts": {
    "sync:admin-cms": "node scripts/sync-decap-admin.js",
    "build": "npm run sync:admin-cms && next build"
  }
}

This makes sure the CMS bundle exists every time you build the site.

Step 2: Create the admin page

Create public/admin/index.html. This file loads the CMS bundle and gives users a clean fallback message if the script does not load.

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Site CMS</title>
    <style>
      body {
        margin: 0;
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: #071528;
        color: #dbeafe;
        font-family: system-ui, sans-serif;
      }

      .fallback {
        max-width: 640px;
        padding: 32px;
        border: 1px solid rgba(148, 163, 184, 0.25);
        border-radius: 16px;
        background: rgba(15, 23, 42, 0.72);
      }
    </style>
  </head>
  <body>
    <noscript>
      <div class="fallback">
        <h1>JavaScript is required</h1>
        <p>The CMS editor needs JavaScript to run.</p>
      </div>
    </noscript>

    <script src="/admin/decap-cms.js"></script>
    <script>
      window.setTimeout(function () {
        if (!window.CMS) {
          document.body.innerHTML =
            '<div class="fallback"><h1>Editor could not start.</h1><p>The CMS script did not finish loading. Refresh once, then check browser extensions or security rules.</p></div>';
        }
      }, 3000);
    </script>
  </body>
</html>

If you use the Next.js App Router, you can also create a simple route at src/app/admin/page.tsx that redirects to /admin/index.html. For many static hosts, the file in public/admin is enough.

Step 3: Configure Decap CMS

Create public/admin/config.yml. This tells Decap where to save content and which backend to use.

yaml
site_url: https://example.com
display_url: https://example.com
logo_url: /icon.png
publish_mode: editorial_workflow
media_folder: public/uploads
public_folder: /uploads

backend:
  name: github
  repo: your-github-user/your-repo
  branch: master
  base_url: https://your-oauth-worker.your-subdomain.workers.dev
  auth_endpoint: auth
  squash_merges: true
  use_graphql: true
  open_authoring: true

collections:
  - name: blog
    label: Blog
    label_singular: Article
    folder: content/blog
    create: true
    extension: md
    format: frontmatter
    slug: "{{slug}}"
    preview_path: blog/{{slug}}
    summary: "{{title}} - {{updatedAt}}"
    fields:
      - { label: Title, name: title, widget: string }
      - { label: Description, name: description, widget: text }
      - { label: Excerpt, name: excerpt, widget: text }
      - { label: Category, name: category, widget: select, options: ["Guides", "DevOps", "Security", "AI", "Career"] }
      - { label: Reading Time, name: readingTime, widget: string, default: "8 min read" }
      - { label: Difficulty, name: difficulty, widget: select, options: ["Beginner", "Intermediate", "Advanced"] }
      - { label: Target Reader, name: target, widget: string }
      - { label: Publish Date, name: publishDate, widget: datetime, format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false }
      - { label: Updated At, name: updatedAt, widget: datetime, format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false }
      - { label: Author, name: author, widget: string }
      - { label: Eyebrow, name: eyebrow, widget: string, default: "Developer Blog" }
      - label: Body
        name: body
        widget: markdown

The important setting is base_url. Decap will call your Cloudflare Worker instead of trying to handle GitHub OAuth directly in the browser. The publish_mode: editorial_workflow setting gives you a safer publishing flow because content can be drafted, reviewed, and approved instead of going live immediately.

Step 4: Create a GitHub OAuth App

In GitHub, go to Developer settings and create a new OAuth App. Use values like these:

  • Application name: Example Site CMS
  • Homepage URL: https://example.com
  • Application description: Git-backed CMS login for the Example Site editorial admin.
  • Authorization callback URL: https://your-oauth-worker.your-subdomain.workers.dev/callback

After creating the app, copy the Client ID and generate a Client Secret. Treat the Client Secret like a password. Do not commit it to GitHub, do not put it in config.yml, and do not expose it in frontend JavaScript.

GitHub OAuth Apps use a callback URL to return the temporary authorization code to your application. In this setup, the Cloudflare Worker is the application receiving that callback.

Step 5: Build the Cloudflare OAuth Worker

Create a Worker folder with these files: cloudflare/decap-oauth-proxy/src/index.ts and cloudflare/decap-oauth-proxy/wrangler.jsonc.

Add this wrangler.jsonc:

json
{
  "$schema": "../../node_modules/wrangler/config-schema.json",
  "name": "example-decap-oauth",
  "main": "src/index.ts",
  "compatibility_date": "2026-04-25",
  "workers_dev": true
}

Then add the Worker code. This version includes the details that matter in practice: state validation, an HttpOnly cookie, GitHub token exchange, Decap's popup handshake, and security headers.

typescript
export interface Env {
  GITHUB_CLIENT_ID: string;
  GITHUB_CLIENT_SECRET: string;
}

function makeOrigin(siteId: string | null) {
  if (!siteId) return "https://example.com";
  if (siteId.startsWith("http://") || siteId.startsWith("https://")) return siteId;
  if (siteId.startsWith("localhost") || siteId.startsWith("127.0.0.1")) return `http://${siteId}`;
  return `https://${siteId}`;
}

function readCookie(cookieHeader: string | null, key: string) {
  if (!cookieHeader) return null;

  const parts = cookieHeader.split(";").map((part) => part.trim());
  const match = parts.find((part) => part.startsWith(`${key}=`));
  return match ? match.slice(key.length + 1) : null;
}

function jsonToBase64(value: unknown) {
  return btoa(JSON.stringify(value));
}

function base64ToJson<T>(value: string): T {
  return JSON.parse(atob(value)) as T;
}

function allowedMessageOrigins(primaryOrigin: string) {
  return Array.from(
    new Set([
      primaryOrigin,
      "https://example.com",
      "http://localhost:3000",
      "http://127.0.0.1:3000",
    ]),
  );
}

function securityHeaders(extraHeaders: HeadersInit = {}) {
  return {
    "Cache-Control": "no-store",
    "Content-Security-Policy":
      "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
    "Referrer-Policy": "no-referrer",
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
    ...extraHeaders,
  };
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/auth") {
      const siteId = url.searchParams.get("site_id");
      const state = crypto.randomUUID();
      const origin = makeOrigin(siteId);
      const payload = jsonToBase64({ state, origin });

      const authUrl = new URL("https://github.com/login/oauth/authorize");
      authUrl.searchParams.set("client_id", env.GITHUB_CLIENT_ID);
      authUrl.searchParams.set("redirect_uri", `${url.origin}/callback`);
      authUrl.searchParams.set("scope", "repo,user");
      authUrl.searchParams.set("state", payload);

      return new Response(null, {
        status: 302,
        headers: {
          ...securityHeaders(),
          Location: authUrl.toString(),
          "Set-Cookie": `decap_state=${state}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
        },
      });
    }

    if (url.pathname === "/callback") {
      const code = url.searchParams.get("code");
      const encodedState = url.searchParams.get("state");
      const stateCookie = readCookie(request.headers.get("Cookie"), "decap_state");

      if (!code || !encodedState || !stateCookie) {
        return new Response("Missing OAuth state.", {
          status: 400,
          headers: securityHeaders({ "Content-Type": "text/plain; charset=utf-8" }),
        });
      }

      const decoded = base64ToJson<{ state: string; origin: string }>(encodedState);

      if (decoded.state !== stateCookie) {
        return new Response("Invalid OAuth state.", {
          status: 403,
          headers: securityHeaders({ "Content-Type": "text/plain; charset=utf-8" }),
        });
      }

      const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          client_id: env.GITHUB_CLIENT_ID,
          client_secret: env.GITHUB_CLIENT_SECRET,
          code,
          redirect_uri: `${url.origin}/callback`,
        }),
      });

      const tokenData = (await tokenResponse.json()) as {
        access_token?: string;
        error?: string;
      };

      if (!tokenResponse.ok || !tokenData.access_token) {
        return new Response(`OAuth exchange failed: ${tokenData.error ?? "unknown_error"}`, {
          status: 500,
          headers: securityHeaders({ "Content-Type": "text/plain; charset=utf-8" }),
        });
      }

      const payload = JSON.stringify({
        token: tokenData.access_token,
        provider: "github",
      });

      const origins = allowedMessageOrigins(decoded.origin);
      const html = `<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Authorization complete</title>
  </head>
  <body>
    <p>Authorization complete. You can close this window if it does not close automatically.</p>
    <script>
      (function () {
        var origins = ${JSON.stringify(origins)};
        var payload = ${JSON.stringify(payload)};
        var authorizingMessage = "authorizing:github";
        var successMessage = "authorization:github:success:" + payload;
        var completed = false;
        var attempts = 0;

        if (!window.opener) {
          document.body.innerHTML = "<p>Authorization finished, but the CMS window was not found. Please return to the admin page and try again.</p>";
          return;
        }

        function isAllowedOrigin(origin) {
          return origins.indexOf(origin) !== -1;
        }

        function postToOpener(message, origin) {
          try {
            window.opener.postMessage(message, origin);
          } catch (error) {}
        }

        function announceAuthorization() {
          if (completed) return;
          origins.forEach(function (origin) {
            postToOpener(authorizingMessage, origin);
          });

          attempts += 1;
          if (attempts < 20) window.setTimeout(announceAuthorization, 250);
        }

        window.addEventListener("message", function (event) {
          if (completed || !isAllowedOrigin(event.origin)) return;
          if (event.data !== authorizingMessage) return;

          completed = true;
          postToOpener(successMessage, event.origin);
          window.setTimeout(function () {
            window.close();
          }, 400);
        });

        announceAuthorization();
      })();
    </script>
  </body>
</html>`;

      return new Response(html, {
        headers: securityHeaders({
          "Content-Type": "text/html; charset=utf-8",
          "Set-Cookie": "decap_state=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0",
        }),
      });
    }

    return new Response("Not found", {
      status: 404,
      headers: securityHeaders({ "Content-Type": "text/plain; charset=utf-8" }),
    });
  },
};

The most easily missed part is the Decap handshake. The popup must first send authorizing:github. Decap replies to that message, and only then should the popup send authorization:github:success: with the token payload. If you skip that handshake and send the token immediately, the popup may say authentication is complete while the main CMS page stays stuck on the login screen.

Step 6: Store OAuth secrets in Cloudflare

Log in to Cloudflare Wrangler:

bash
npx wrangler login

Then store your GitHub OAuth credentials as Worker secrets:

bash
npx wrangler secret put GITHUB_CLIENT_ID --config cloudflare/decap-oauth-proxy/wrangler.jsonc
npx wrangler secret put GITHUB_CLIENT_SECRET --config cloudflare/decap-oauth-proxy/wrangler.jsonc

When Wrangler asks for values, paste the Client ID and Client Secret from GitHub. These values are encrypted and stored by Cloudflare. They should not appear in your repository.

Step 7: Deploy the Worker

Deploy the OAuth proxy:

bash
npx wrangler deploy --config cloudflare/decap-oauth-proxy/wrangler.jsonc

Wrangler will print a URL similar to https://example-decap-oauth.your-subdomain.workers.dev.

Use that URL in two places:

  • GitHub OAuth App callback URL: https://example-decap-oauth.your-subdomain.workers.dev/callback
  • Decap config.yml backend base_url: https://example-decap-oauth.your-subdomain.workers.dev

If those two do not match, login will fail.

Step 8: Add secure headers for the admin route

If you deploy to Cloudflare Pages or another static host that supports header files, add security headers for /admin. Decap CMS needs a slightly looser policy than the public site because it uses runtime JavaScript behavior that may require unsafe-eval.

Create or update public/_headers:

text
/admin
  Cache-Control: no-store
  X-Robots-Tag: noindex, nofollow, noarchive
  Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://api.github.com https://example-decap-oauth.your-subdomain.workers.dev; form-action 'self'; upgrade-insecure-requests
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: accelerometer=(), autoplay=(), camera=(), browsing-topics=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()
  Cross-Origin-Opener-Policy: same-origin-allow-popups
  Cross-Origin-Resource-Policy: same-site

/admin/*
  Cache-Control: no-store
  X-Robots-Tag: noindex, nofollow, noarchive
  Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://api.github.com https://example-decap-oauth.your-subdomain.workers.dev; form-action 'self'; upgrade-insecure-requests
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: accelerometer=(), autoplay=(), camera=(), browsing-topics=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()
  Cross-Origin-Opener-Policy: same-origin-allow-popups
  Cross-Origin-Resource-Policy: same-site

The Cross-Origin-Opener-Policy: same-origin-allow-popups line matters. If you use a stricter opener policy, the GitHub login popup may complete authentication but fail to communicate back to the CMS window.

Step 9: Render Markdown content on your site

Decap will save Markdown files. Your site still needs to render them. In a Next.js project, a simple approach is to read Markdown files from content/blog at build time with gray-matter, then render the body with react-markdown.

A useful upgrade is to customize fenced code blocks so tutorials look professional:

javascript
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CodeBlock } from "@/components/tutorials/CodeBlock";

export function MarkdownArticleContent({ content }: { content: string }) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      components={{
        code: ({ inline, className, children }: any) => {
          const code = String(children).replace(/\n$/, "");
          const language = /language-([a-zA-Z0-9_+-]+)/.exec(className ?? "")?.[1] ?? "text";

          if (inline) {
            return <code>{children}</code>;
          }

          return <CodeBlock code={code} language={language} />;
        },
      }}
    >
      {content}
    </ReactMarkdown>
  );
}

Now future CMS posts can use normal Markdown code fences:

markdown
```bash
npm run build
npm run deploy
```

```typescript
console.log("CMS code blocks are styled automatically");
```

This is important for a developer resource site because tutorials are not just essays. They need readable commands, copy buttons, and clean formatting.

Step 10: Test locally

Run your local development server:

bash
npm run dev

Open http://localhost:3000/admin.

For local testing, the OAuth flow may use localhost:3000 or 127.0.0.1:3000 depending on your browser address. Include both in the Worker allowed origins if you want local login to work smoothly.

Also test a built version:

bash
npm run build

If the build fails, fix it before trying to deploy. A CMS integration touches public files, headers, Markdown parsing, and build-time content loading, so it is better to catch problems locally.

Common problems and fixes

The admin page is blank

Open the browser console. If you see CSP errors for the CMS script, self-host decap-cms.js and make sure your admin CSP allows script-src 'self'. If you are loading Decap from a CDN, the CDN domain must be allowed, but self-hosting is cleaner.

The CMS says config.yml has an eval error

Decap may need unsafe-eval on the admin route. Keep that exception limited to /admin and /admin/*. Do not loosen the CSP for the entire public site.

GitHub login popup closes but CMS still shows login

This usually means the OAuth popup did not complete Decap's message handshake. Make sure the callback page sends authorizing:github, waits for Decap to answer, and then sends authorization:github:success: with the token payload.

GitHub says callback URL is wrong

Check the exact Worker URL. The GitHub OAuth App callback should end in /callback, while Decap's base_url should not include /callback.

The site deploys but the admin route is cached

Set Cache-Control: no-store for /admin and /admin/*. If Cloudflare still shows the old admin page, wait for propagation or purge cache from the Cloudflare dashboard.

Contributors can create drafts

If open_authoring: true is enabled, outside GitHub users may be able to propose content, but they should not be able to publish directly unless they have repository permission. Keep editorial workflow enabled if you want review before publication.

Security checklist

Before considering the CMS ready, check these items:

  • The GitHub Client Secret is stored only as a Cloudflare Worker secret.
  • The Worker validates OAuth state.
  • The state cookie is HttpOnly, Secure, and SameSite=Lax.
  • /admin has Cache-Control: no-store.
  • /admin has X-Robots-Tag: noindex, nofollow, noarchive.
  • Public pages do not use the looser admin CSP.
  • The OAuth Worker allows only your real site origins.
  • The CMS content workflow uses review before publication.
  • Your repository branch and deploy flow are clear.
  • You can rebuild the site after publishing content.

This setup is not the same as a private enterprise CMS with role-based access control, audit logs, and granular permissions. It is a practical Git-backed editor for a personal or small team static site. For that use case, it is lightweight, transparent, and very cost-effective.

Deployment flow

For a static Next.js site, a typical flow is:

bash
git add .
git commit -m "Add Decap CMS with Cloudflare OAuth"
git push origin master
npm run deploy

If your site deploys from GitHub automatically, pushing may be enough. If you use a manual static export flow, keep running your deploy command after content or code changes.

Official references

The main documentation pages worth keeping nearby are:

  • Cloudflare Workers pricing and limits: https://developers.cloudflare.com/workers/platform/pricing/
  • Cloudflare Workers limits: https://developers.cloudflare.com/workers/platform/limits/
  • Decap CMS backend overview: https://decapcms.org/docs/backends-overview/
  • Decap CMS external OAuth clients: https://decapcms.org/docs/external-oauth-clients/
  • GitHub OAuth App creation docs: https://docs.github.com/en/developers/apps/creating-an-oauth-app

These docs are important because free tier limits, platform details, and OAuth behavior can change. The implementation pattern is stable, but always verify the current limits before promising a client or business that any platform is "free forever."

Conclusion

Using GitHub, Decap CMS, and a Cloudflare Worker gives you a clean free CMS workflow for a static website. GitHub becomes the content database, Decap becomes the editor, and Cloudflare handles the small secure OAuth bridge that a browser-only static site cannot safely perform by itself.

The biggest lesson from this setup is that the hard part is not installing the CMS. The hard part is making authentication work securely: no exposed secrets, correct callback URLs, route-specific security headers, and the Decap popup handshake. Once those pieces are correct, the experience feels simple. You open /admin, log in with GitHub, write Markdown, and publish through your normal Git workflow.

For a developer blog, portfolio, or resource hub, this is a strong starting point. You keep the performance and simplicity of a static site while gaining a real editorial workflow for tutorials, quick fixes, AI tool roundups, and long-form technical posts.