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:
commit
346c859eb5
8 files changed
+480
No files matched your search
@@ -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/
|
||||
Reference in new issue
Block a user