Nathan Schneider 346c859eb5 Hugo site with Woodpecker CI -> Surfer deploy pipeline
Builds the site with hugomods/hugo:dart-sass-node-git and syncs public/
to the Surfer app on the same Cloudron server (surfer put --all --delete).
README covers full setup: Gitea OAuth app, Woodpecker env.sh forge config,
agent options, and repo secrets.
2026-10-02 17:37:36 -06:00

auto-deploy — a Gitea → Woodpecker CI → Surfer pipeline on one Cloudron

A minimal but complete example of a fully self-hosted static-site publishing pipeline, with every piece running on a single Cloudron server:

  git push                      webhook                  "surfer put"
      │                           │                           │
      ▼                           ▼                           ▼
┌───────────┐   clone   ┌─────────────────────┐   upload   ┌──────────┐
│   Gitea   ├──────────►│  Woodpecker         ├───────────►│  Surfer  │
│ git host  │◄──OAuth───┤  (server + agent)   │  public/*  │  static  │
└───────────┘           └─────────────────────┘            │  files   │
     source                build & CI                 └────┬─────┘
                                                            ▼
                                              https://surfer.cloudron.example

You edit content, push to main on Gitea, and the site reappears on Surfer a minute later — no manual uploads, no credentials in the repo. This repo is a working Hugo site; the pipeline in .woodpecker.yml is all the plumbing.

What's in this repo

File Purpose
.woodpecker.yml The CI pipeline: build Hugo, upload the output with surfer put
hugo.toml Site config — baseURL must match the Surfer deployment domain
content/_index.md, content/about.md Minimal content so the example deploys out of the box
layouts/home.html, layouts/single.html Tiny standalone templates (no theme dependency)
.gitignore Keeps build output (public/) out of git

Prerequisites

A Cloudron server with three apps installed from the App Store:

Stage App Example location Cloudron docs
Source Gitea https://gitea.cloudron.example docs
CI Woodpecker https://woodpecker.cloudron.example docs
Serving Surfer https://surfer.cloudron.example docs

Substitute your real domains everywhere below.

1. Create a Surfer access token

  1. Open the Surfer admin UI: https://surfer.cloudron.example/_admin/.
  2. Go to Settings and create an Access Token (it looks like api-7e6d90ff-...). You'll add it to Woodpecker in the next sections.
  3. Decide where the site lives on the Surfer app:
    • At the root — https://surfer.cloudron.example/ serves the site. Keep SURFER_DEST: / in .woodpecker.yml and baseURL = "https://surfer.cloudron.example/" in hugo.toml.
    • In a subdirectory — e.g. .../mysite/. Set SURFER_DEST: /mysite/ and baseURL = "https://surfer.cloudron.example/mysite/". If these don't match, CSS/JS/links will 404.

The simplest setup is one Surfer app per site, each deployed at /. Surfer is cheap, so prefer that unless you specifically need several sites in one place.

2. Point Woodpecker at Gitea

2a. Register an OAuth application in Gitea

In Gitea go to Settings → Applications → Manage OAuth2 Applications (https://gitea.cloudron.example/user/settings/applications) and create a new application:

  • Name: Woodpecker CI
  • Redirect URI: https://woodpecker.cloudron.example/authorize

The redirect URI must match your Woodpecker domain exactly (scheme, host, /authorize path). Copy the generated Client ID and Client Secret.

2b. Configure the Woodpecker app

Open the Woodpecker app's File manager in the Cloudron dashboard and edit /app/data/env.sh, adding a Gitea block:

export WOODPECKER_GITEA=true
export WOODPECKER_GITEA_URL=https://gitea.cloudron.example
export WOODPECKER_GITEA_CLIENT=<client id from 2a>
export WOODPECKER_GITEA_SECRET=<client secret from 2a>

Restart the Woodpecker app. Notes from the Cloudron docs:

  • Only one auth provider may be active at a time — remove or set any other WOODPECKER_<forge> variables to false.
  • To keep strangers out, add export WOODPECKER_OPEN=false and add explicit users via WOODPECKER_ADMIN=<your-gitea-username,...> if desired.

2c. Log in

Open https://woodpecker.cloudron.example, choose the Gitea login, and authorize. The first user to sign in becomes a Woodpecker admin.

3. Start a Woodpecker agent

The Woodpecker app only provides the server; pipelines are run by agents that connect to it. An agent needs Docker and must run outside the Cloudron apps themselves:

From the Cloudron docs: Do not install the agent on the Cloudron server itself. This is dangerous because the agent has full access to docker and it can (accidentally) delete or corrupt your apps.

Two ways to get a running agent, given the first agent's shared secret is in the Woodpecker app at /app/data/env.sh (open the File manager, note the WOODPECKER_AGENT_SECRET value):

Option A — recommended: separate Docker host / VM

docker run --name=woodpecker-agent --restart=always --detach \
    -e WOODPECKER_SERVER="woodpecker.cloudron.example:9000" \
    -e WOODPECKER_MAX_WORKFLOWS=4 \
    -e WOODPECKER_GRPC_SECURE=true \
    -e WOODPECKER_LOG_LEVEL=info \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -e WOODPECKER_BACKEND=docker \
    -e WOODPECKER_AGENT_SECRET="value from /app/data/env.sh" \
    woodpeckerci/woodpecker-agent:latest

Any cheap VM works, since the agent only needs outbound access to the server.

Option B — same Cloudron server (accepting the risk flagged above): run the exact same command on the Cloudron host itself, via ssh. The agent talks to the Woodpecker server over its public gRPC endpoint (:9000, TLS) and spawns pipeline step containers (Hugo build, Surfer upload). For a one-person server this is a common trade-off; keep the exposure as small as it can be:

  • WOODPECKER_OPEN=false so only your Gitea users can ever log in.
  • Enable only repositories you control, and never turn on trusted mode for them: without trusted mode, pipeline steps run as ordinary throwaway containers — no docker socket inside them, no host mounts, no privileged access. The socket lives in the agent process only.
  • Keep secrets unavailable to pull requests (Woodpecker's default) and never store anything more powerful than the Surfer token in repo secrets.
  • Point Cloudron backups off-server (e.g. S3): the scenario this warning guards against is damage to the whole platform, which local backups on the same disk may not survive.
  • Rotate WOODPECKER_AGENT_SECRET if in doubt (it also lives in /app/data/env.sh).

Verify the agent registered: docker logs -f woodpecker-agent should show it polling without errors, and the Woodpecker UI's Agents page lists it.

4. Create the repo in Gitea and push

Create an empty repository in the Gitea web UI (e.g. <you>/auto-deploy), then push this directory:

git init
git add .
git commit -m "Hugo site with Woodpecker → Surfer deploy pipeline"
git branch -M main
git remote add origin https://gitea.cloudron.example/<you>/auto-deploy.git
git push -u origin main

5. Activate the repo in Woodpecker and add secrets

  1. In Woodpecker, open Repositories → New repository and enable your repo (you need admin rights on it in Gitea). Woodpecker installs a webhook on it.
  2. In the repo's Settings → Secrets, add:
Name Value
surfer_server surfer.cloudron.example — domain only, no https://
surfer_token the access token from step 1 (api-...)

When adding the secrets, make sure they are permitted for the events your pipeline uses — at minimum push; add manual as well if you plan UI-triggered redeploys. Woodpecker's default secrecy rules already exclude pull requests.

6. Deploy

Every push to main now triggers a build. If nothing runs (e.g. you want to re-deploy without a commit), use Pipelines → Run pipeline (the manual event). After the pipeline goes green, visit https://surfer.cloudron.example/ — the example site is there, complete with a footer noting how it got built.

How the pipeline works

.woodpecker.yml has two steps, triggered only by pushes to main and manual runs:

  1. build — hugomods/hugo:dart-sass-node-git runs hugo --minify --gc, producing public/. That image variant contains extended Hugo plus Node, Git and Dart Sass; other variants are listed at docker.hugomods.com. To pin an exact Hugo version, set image: hugomods/hugo:dart-sass-node-git-0.165.0 and drop the pull: true line (which only exists to keep a floating tag fresh).

  2. deploy — a small node:24-alpine step installs the Surfer CLI and runs:

    surfer put --all --delete --token ... --server ... public/* "$SURFER_DEST"
    

    Exactly why each part matters:

    piece effect
    public/* (not public) uploads the contents of public/ into the destination; surfer put public / would create a literal public/ folder
    --all include dotfiles in subdirectories
    --delete remove remote files that are no longer in public/, so renamed assets don't pile up; changed files upload incrementally (mtime/size), unchanged ones are skipped
    destination must be an absolute path like / or /mysite/, kept in sync with baseURL in hugo.toml

    Note that this makes Surfer's destination a managed mirror of public/: anything you upload manually to that folder will be deleted on the next deploy. The --server value may be the bare domain — the CLI adds https:// automatically.

Top-level dotfiles (.well-known/, .htaccess, …)

The shell glob public/* never expands to top-level dotfiles, and --delete would then treat them as stale. If you need one (e.g. .well-known/ for webmaster verification files), uncomment the optional line at the end of the deploy step — it re-uploads the dotdir right after the main sync, guarding on its existence so deploys without it keep working.

Adapting it

  • Another generator? Replace the build step and the output dir; the deploy step stays identical:

    # Astro                       # Eleventy                # Zola
    commands:                     commands:                 image: ghcr.io/getzola/zola:v0.20.2
      - npm ci                      - npm ci                commands:
      - npm run build               - npx @11ty/eleventy      - zola build
    # deploy step source:           # deploy step source:     # deploy step source:
    #   dist/*                      #   _site/*               #   public/*
    
  • Multiple sites on one Surfer in subfolders: give each repo its own SURFER_DEST (e.g. /blog/, /photos/) and match baseURL in each. Keep in mind the --delete sync only touches that destination folder.

  • Private repos work fine: Woodpecker clones via Gitea OAuth; nothing here assumes your Gitea repo is public.

  • Non-deploy jobs (lint, tests) just become a normal step before deploy — steps run in declaration order and a failure stops the pipeline.

Security notes

  • Never commit the Surfer token — it lives only in Woodpecker secrets. If it ever leaks, revoke it in the Surfer admin UI and update the secret, then re-run the pipeline manually.
  • The deploy step only exposes the two secrets to node:24-alpine; other steps never see them.
  • For extra paranoia you can restrict both secrets to specific images in the secret's settings, and consider Surfer's own access control (password-restricted sites) for staging sites.

Troubleshooting

Symptom Likely cause Fix
Pipeline never appears after push webhook missing or blocked re-check repo activation; check Gitea → repo → Webhooks for delivery errors; on Cloudron, Gitea config lives in /app/data/app.ini
Pipeline stuck in pending no agent connected docker logs woodpecker-agent; verify WOODPECKER_SERVER=<woodpecker-domain>:9000, WOODPECKER_GRPC_SECURE=true, and the agent secret from the Woodpecker app's /app/data/env.sh
Login fails / repo list empty OAuth setup redirect URI must be exactly https://<woodpecker-domain>/authorize; only one WOODPECKER_<forge> provider active
Deploy step says "Run surfer config first" secret missing / wrong name secrets must be named surfer_server / surfer_token (see .woodpecker.yml) and permitted for this event
Invalid token at deploy token rotated or revoked create a fresh token in Surfer's admin UI, update the surfer_token secret, run pipeline manually
Site deploys but CSS/links 404 baseURL and SURFER_DEST don't match make them identical (including path)
Site under a literal public/ path forgot the glob use public/*, not public
Old files still served after renaming assets sync flags missing keep --delete --all in the surfer put command
surfer: command not found npm install failed in the deploy step check the deploy step logs (registry access)

Local development

hugo server -D        # preview at http://localhost:1313
hugo --minify --gc    # same build as CI, output in public/

You can also dry-run the workflow with woodpecker-cli (woodpecker-cli exec .woodpecker.yml).

References

S
Description
A template for automatically deploying static websites from Git > Woodpecker > Surfer
Readme
38 KiB
0 Stars 5 Watchers 0 Forks
Languages
HTML 100%