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.
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
- Open the Surfer admin UI:
https://surfer.cloudron.example/_admin/. - Go to Settings and create an Access Token (it looks like
api-7e6d90ff-...). You'll add it to Woodpecker in the next sections. - Decide where the site lives on the Surfer app:
- At the root —
https://surfer.cloudron.example/serves the site. KeepSURFER_DEST: /in.woodpecker.ymlandbaseURL = "https://surfer.cloudron.example/"inhugo.toml. - In a subdirectory — e.g.
.../mysite/. SetSURFER_DEST: /mysite/andbaseURL = "https://surfer.cloudron.example/mysite/". If these don't match, CSS/JS/links will 404.
- At the root —
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 tofalse. - To keep strangers out, add
export WOODPECKER_OPEN=falseand add explicit users viaWOODPECKER_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=falseso 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_SECRETif 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
- In Woodpecker, open Repositories → New repository and enable your repo (you need admin rights on it in Gitea). Woodpecker installs a webhook on it.
- 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:
-
build —
hugomods/hugo:dart-sass-node-gitrunshugo --minify --gc, producingpublic/. 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, setimage: hugomods/hugo:dart-sass-node-git-0.165.0and drop thepull: trueline (which only exists to keep a floating tag fresh). -
deploy — a small
node:24-alpinestep installs the Surfer CLI and runs:surfer put --all --delete --token ... --server ... public/* "$SURFER_DEST"Exactly why each part matters:
piece effect public/*(notpublic)uploads the contents of public/into the destination;surfer put public /would create a literalpublic/folder--allinclude dotfiles in subdirectories --deleteremove remote files that are no longer in public/, so renamed assets don't pile up; changed files upload incrementally (mtime/size), unchanged ones are skippeddestination must be an absolute path like /or/mysite/, kept in sync withbaseURLinhugo.tomlNote 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--servervalue may be the bare domain — the CLI addshttps://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 matchbaseURLin each. Keep in mind the--deletesync 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
deploystep only exposes the two secrets tonode: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
- Cloudron Surfer docs & CLI: https://docs.cloudron.io/packages/surfer/
- Cloudron Woodpecker docs (forge setup, agent): https://docs.cloudron.io/packages/woodpecker/
- Woodpecker workflow syntax: https://woodpecker-ci.org/docs/usage/workflow-syntax
- Woodpecker secrets: https://woodpecker-ci.org/docs/usage/secrets
- Woodpecker × Gitea: https://woodpecker-ci.org/docs/administration/configuration/forges/gitea
- Hugo Docker images: https://docker.hugomods.com/
- Hugo layout rules: https://gohugo.io/templates/lookup-order/