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.
This commit is contained in:
Nathan Schneider committed 2026-10-02 17:37:36 -06:00
commit 346c859eb5
8 files changed
+480

No files matched your search

+292
View File
@@ -0,0 +1,292 @@
# 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](https://cloudron.io) server:
```text
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](https://docs.cloudron.io/packages/gitea/) |
| CI | Woodpecker | `https://woodpecker.cloudron.example` | [docs](https://docs.cloudron.io/packages/woodpecker/) |
| Serving | Surfer | `https://surfer.cloudron.example` | [docs](https://docs.cloudron.io/packages/surfer/) |
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:
```bash
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](https://docs.cloudron.io/packages/woodpecker/):
- 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**
```bash
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:
```bash
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](https://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:
```bash
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:
```yaml
# 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
```bash
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](https://woodpecker-ci.org/docs/usage/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/