# 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/