Deploying a Hugo site on Gitea to Cloudflare Pages with Gitea Actions


A while back I wrote a post on deploying a Hugo site from Gitea to Cloudflare Pages using a Gitea push mirror to GitHub, because at the time Cloudflare Pages only supported GitHub and GitLab as Git sources. That workaround works but it always felt wrong, two repos to keep in sync, a Personal Access Token to rotate, and a third-party round trip on every commit.

You do not need any of that now. Gitea has Actions, and Cloudflare Pages has direct upload via wrangler . One workflow file in your Gitea repo builds the site and pushes the public folder straight to Pages. No GitHub in the loop.

This post is how I have it set up on this site.

  • A Hugo site already in a Gitea repository
  • Gitea with Actions enabled and a runner connected to your instance (the Gitea Actions docs cover runner setup)
  • A Cloudflare account

Cloudflare Pages needs the project to exist before a workflow can push to it. The first deploy has to create it.

Easiest route is the dashboard: Workers & Pages > Create application > Pages > Upload assets. Give it a project name (I used jamieedecom), drop in any placeholder folder for the initial upload, and finish. After that the project exists and you can ignore the dashboard for deploys from here on.

If you would rather do it from the CLI, npx wrangler@3 pages project create jamieedecom --production-branch main does the same thing.

Note the project name you choose. The workflow references it.

Pages deploys from CI need a token, not your global API key. Scope it tight.

In the Cloudflare dashboard go to My Profile > API Tokens > Create Token > Create Custom Token.

Permissions to set:

  • Account > Cloudflare Pages > Edit

Account resources: scope it to the specific account that owns the Pages project, not “All accounts”.

Click Continue to summary, then Create Token. Copy the token immediately, you only see it once.

While you are in the dashboard, grab your account ID too. It is on the right-hand sidebar of any account-level page in the dashboard, labelled Account ID.

In your Gitea repo go to Settings > Actions > Secrets and add two:

  • CLOUDFLARE_ACCOUNT_ID set to your account ID
  • CLOUDFLARE_API_TOKEN set to the token you just created

These are repo-scoped, so they only exist for this repo’s workflows.

Create .gitea/workflows/deploy.yml:

name: Deploy to Cloudflare Pages

on:
  push:
    branches:
      - main
      - dev
  schedule:
    # Daily at 06:05 UTC so scheduled posts (date in the future) appear shortly after midnight UK.
    - cron: '5 6 * * *'
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.140.1'
          extended: true

      - name: Install Node dependencies
        run: npm install

      - name: Build
        run: hugo

      - name: Deploy
        env:
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
        run: npx wrangler@3 pages deploy public --project-name=jamieedecom

Commit it, push, and Gitea will run the workflow on your runner. Two minutes later the site is live on Cloudflare’s edge.

A few of those steps have load-bearing details worth pulling out.

submodules: recursive on the checkout. Most Hugo themes are added as git submodules. Without recursive, the runner clones an empty themes/ folder and the build fails with “theme not found”.

peaceiris/actions-hugo@v3 with extended: true. The extended build is the one with libsass support, which you need if your theme uses SCSS, or if you are using Hugo Pipes with Tailwind, PostCSS, or any other asset pipeline. Pin the hugo-version to whatever version you develop against locally so the CI build matches what you see on your machine.

npm install. This is here because some Hugo themes (and my Tailwind setup) depend on Node packages at build time. If your site has no package.json you can drop this step.

hugo. No flags. Defaults are fine. The build output lands in public/.

npx wrangler@3 pages deploy. This is the actual upload. wrangler reads CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN from the environment, hashes every file in public/, uploads only the changed ones, and creates a new deployment on the Pages project. The first deploy uploads everything, subsequent deploys only push the diff so they are quick.

The --project-name=jamieedecom flag has to match the Pages project you created earlier exactly.

The schedule trigger in the workflow is the bit people miss when they switch from a Git-source Pages setup to a CI-driven one.

If you write a post with a future date: in the frontmatter (handy for queueing things), Hugo will not include it in the build until the build time passes that date. With Git-source Pages, the site only ever rebuilds on a push, so a scheduled post would sit invisible until the next commit went in.

The cron line in the workflow fixes that. Once a day, at 06:05 UTC (just after midnight UK in winter, just after 1am in summer), the workflow runs with no code changes and rebuilds the site. Any post whose date has now passed is included in the new build.

workflow_dispatch: is also there so I can manually fire a deploy from the Gitea UI if I need to, without pushing a dummy commit.

Worth saying out loud what changed.

The old workflow had Gitea push-mirror to a GitHub repo, then Cloudflare Pages watched the GitHub repo for changes and built there. Three systems, two tokens (GitHub PAT for the mirror, and whatever auth Cloudflare used to talk to GitHub), and a GitHub account in the loop you did not really need.

This new flow has Gitea Actions run the Hugo build on your own runner and push the built site to Cloudflare directly via wrangler. One repo, one token, no GitHub.

The trade-off is that you now own the runner. If your runner is offline, no deploy. With the old setup the build happened on Cloudflare’s infrastructure so you did not have to think about it. For a personal site that is usually fine, a self-hosted runner is cheap and stable, and you get to control the Hugo version and any system tools the build needs.

That is the whole setup. One workflow file, two secrets, one Pages project. Every push to main or dev deploys. A nightly cron picks up scheduled posts. No mirror, no GitHub.

If you are still on the GitHub mirror setup, this is a straight swap. Delete the push mirror in Gitea, drop in the workflow file, add the secrets, and the next push deploys.

The Cloudflare Pages direct upload docs and the wrangler pages deploy reference cover everything wrangler does under the hood if you want to dig further.

×
Page views: