Files
auto-deploy/README.md
T
Nathan Schneider 346c859eb5 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.
2026-10-02 17:37:36 -06:00

292 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/