From 346c859eb51085543b5e312b9290ef9f0512c14e Mon Sep 17 00:00:00 2001 From: Nathan Schneider Date: Fri, 2 Oct 2026 16:23:34 -0600 Subject: [PATCH] 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. --- .gitignore | 10 ++ .woodpecker.yml | 54 ++++++++ README.md | 292 ++++++++++++++++++++++++++++++++++++++++++++ content/_index.md | 14 +++ content/about.md | 19 +++ hugo.toml | 15 +++ layouts/home.html | 38 ++++++ layouts/single.html | 38 ++++++ 8 files changed, 480 insertions(+) create mode 100644 .gitignore create mode 100644 .woodpecker.yml create mode 100644 README.md create mode 100644 content/_index.md create mode 100644 content/about.md create mode 100644 hugo.toml create mode 100644 layouts/home.html create mode 100644 layouts/single.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..360cf7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Hugo build output +/public/ +/resources/_gen/ +/.hugo_build.lock + +# OS and editor cruft +.DS_Store +Thumbs.db +.vscode/ +.idea/ \ No newline at end of file diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..fb6d176 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,54 @@ +# Build the Hugo site and push the result to Surfer (both apps on the same Cloudron). +# +# Docs: +# Workflow syntax: https://woodpecker-ci.org/docs/usage/workflow-syntax +# Surfer CLI: https://docs.cloudron.io/packages/surfer/#cli-tool +# +# Repo secrets expected in Woodpecker (Settings -> Secrets): +# surfer_server - Surfer app domain, e.g. surfer.cloudron.example (no scheme) +# surfer_token - access token created in the Surfer admin UI (/_admin) + +when: + # Deploy on pushes to main only. Other branches never build or deploy. + - event: push + branch: main + # Also allow "Run pipeline" in the Woodpecker UI (redeploying without a push). + # If you use this, make sure the two secrets below are also allowed for the + # "manual" event, not just push. + - event: manual + +steps: + # 1) Build the site into ./public + # + # hugomods/hugo variants: https://docker.hugomods.com/ + # `dart-sass-node-git` is the maintained "everything" image: extended Hugo + + # Node/yarn + Git + Dart Sass. Pin a version with, for example, + # hugomods/hugo:dart-sass-node-git-0.165.0 (and drop `pull: true`). + - name: build + image: hugomods/hugo:dart-sass-node-git + pull: true + commands: + - hugo --minify --gc + + # 2) Upload ./public to Surfer + - name: deploy + image: node:24-alpine + environment: + # Where on the Surfer server the site lives. + # "/" - the site is served at the Surfer app's domain root + # "/mysite/" - served in a subdirectory (keep baseURL in hugo.toml in sync!) + SURFER_DEST: / + SURFER_SERVER: + from_secret: surfer_server + SURFER_TOKEN: + from_secret: surfer_token + commands: + - npm install --global cloudron-surfer + # public/* (not public) so the CONTENTS of public/ end up in $SURFER_DEST - + # `surfer put public /` would upload a literal "public" folder instead. + # --all also includes hidden files nested inside those dirs, and --delete removes remote files that are no longer in public/ + # (a real sync, so renamed assets don't pile up on the server). + - surfer put --all --delete --token "$SURFER_TOKEN" --server "$SURFER_SERVER" public/* "$SURFER_DEST" + # Optional: top-level dot directories (e.g. .well-known/) are skipped by the + # shell glob above, so re-sync them explicitly without --delete: + # - if [ -d public/.well-known ]; then surfer put --token "$SURFER_TOKEN" --server "$SURFER_SERVER" public/.well-known "$SURFER_DEST"; fi \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..15cf135 --- /dev/null +++ b/README.md @@ -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= +export WOODPECKER_GITEA_SECRET= +``` + +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_` variables to `false`. +- To keep strangers out, add `export WOODPECKER_OPEN=false` and add explicit + users via `WOODPECKER_ADMIN=` 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. +`/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//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=: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:///authorize`; only one `WOODPECKER_` 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/ \ No newline at end of file diff --git a/content/_index.md b/content/_index.md new file mode 100644 index 0000000..3a9f80f --- /dev/null +++ b/content/_index.md @@ -0,0 +1,14 @@ +--- +title: Home +--- + +# It is alive! + +This static site is deployed automatically: + +1. You push a commit to `main` on **Gitea** (same Cloudron server). +2. **Woodpecker CI** picks up the push, builds the site with Hugo. +3. The build output is uploaded to **Surfer**, which serves it. + +That's it — no credentials in the repo, no manual uploads. Push something to +`main` right now and watch the pipeline in Woodpecker. \ No newline at end of file diff --git a/content/about.md b/content/about.md new file mode 100644 index 0000000..5dad484 --- /dev/null +++ b/content/about.md @@ -0,0 +1,19 @@ +--- +title: About +--- + +This example shows a complete, self-hosted publishing pipeline on a single +Cloudron server: + +| Stage | App | Example location | +| ----- | --- | ---------------- | +| Source | Gitea | `https://gitea.cloudron.example` | +| Build | Woodpecker CI | `https://woodpecker.cloudron.example` | +| Serving | Surfer | `https://surfer.cloudron.example` | + +The pipeline is defined by a single checked-in file, [`.woodpecker.yml`](https://woodpecker-ci.org/docs/usage/workflow-syntax), and the only +secrets it needs are a Surfer domain and an access token. See the repository +README for full setup instructions. + +Delete this page or rewrite everything — the templates in `layouts/` are +deliberately small so you can replace them with a real design. \ No newline at end of file diff --git a/hugo.toml b/hugo.toml new file mode 100644 index 0000000..2b0a68c --- /dev/null +++ b/hugo.toml @@ -0,0 +1,15 @@ +# The final URL of the site, as served by Surfer. It must exactly match the +# deployment destination on the Surfer app, or CSS/JS/images will 404. +# +# Site at the Surfer app's root domain (SURFER_DEST = "/" in .woodpecker.yml): +baseURL = "https://surfer.cloudron.example/" +# +# Site in a subdirectory (SURFER_DEST = "/mysite/"): +# baseURL = "https://surfer.cloudron.example/mysite/" + +locale = "en-us" +title = "My Cloudron Site" + +# This minimal example uses no taxonomies; disabling them avoids empty +# /tags/ and /categories/ pages. +disableKinds = ["taxonomy", "term"] \ No newline at end of file diff --git a/layouts/home.html b/layouts/home.html new file mode 100644 index 0000000..0404784 --- /dev/null +++ b/layouts/home.html @@ -0,0 +1,38 @@ + + + + + + {{ .Title }} | {{ site.Title }} + + + +
+ {{ site.Title }} + +
+
+ {{ .Content }} +
+
+ + Served by Surfer · built by Woodpecker CI · source on Gitea + — one Cloudron server. + +
+ + \ No newline at end of file diff --git a/layouts/single.html b/layouts/single.html new file mode 100644 index 0000000..0404784 --- /dev/null +++ b/layouts/single.html @@ -0,0 +1,38 @@ + + + + + + {{ .Title }} | {{ site.Title }} + + + +
+ {{ site.Title }} + +
+
+ {{ .Content }} +
+ + + \ No newline at end of file