- Go 99.2%
- Dockerfile 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The mise CI image needs npm available to resolve `npm:@go-task/cli`, so node has to be pinned in mise.toml even though this is a Go project. Without it `mise install` fails on the runner with `os error 2` while trying to query the npm registry. Bumping the Go toolchain (mise + Dockerfile) to 1.26.2 clears all 9 stdlib CVEs Trivy surfaced — the older patches included the recent crypto/tls cert validation, archive/tar GNU sparse, net/url, and crypto/x509 issues. staticcheck 2024.1.1 doesn't compile against Go 1.26's stdlib, so jump to v0.7.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
| .woodpecker | ||
| internal | ||
| .gitignore | ||
| Dockerfile | ||
| go.mod | ||
| main.go | ||
| mise.toml | ||
| README.md | ||
| renovate.json | ||
| Taskfile.yml | ||
pages
A tiny self-hosted replacement for GitHub Pages: push a tarball at a domain (or a sub-path inside one) and it gets served as static content. Built for homelab CI pipelines that just want a simple "publish a directory of files" endpoint with atomic, zero-downtime deploys.
Concepts in 60 seconds
Domains — every site lives under a domain. Requests are routed by the
HTTP Host header.
Mounts — a domain has one or more mounts, each owning a URL path. The
apex (/) is one mount; /blog and /blog/2024 can be additional, fully
independent mounts. A mount fully owns its path: a request that prefix-
matches a mount but is missing in that mount's content returns 404. There
is no fallback to the apex.
Releases — every upload produces a new release directory. The mount's
current symlink is atomically swapped over once the upload is fully
extracted, so readers never see a half-deployed site. Old releases stick
around (5 by default) for emergency rollback and as a cushion against
filesystem races.
data/
└── example.com/
└── mounts/
├── _root/ # apex (URL "/")
│ ├── releases/20260505-103022-abc1234/
│ └── current -> releases/20260505-103022-abc1234
└── blog%2F2024/ # URL "/blog/2024"
├── releases/20260430-120000-bbb2222/
└── current -> releases/20260430-120000-bbb2222
What it isn't
- A general-purpose web server. No reverse-proxy, no rewrites, no auth on the public side, no per-mount config. TLS lives at your ingress.
- A multi-tenant API. One bearer token, one operator. Anyone with the token can publish to any domain.
- Server-side rendering. Static files only.
Two ports, two roles
| Port | Default | Purpose |
|---|---|---|
| 8080 | public | Serves static content. Routed by Host. |
| 9090 | admin | Upload/delete/list API. Bearer-token gated. |
Expose only the public port through your ingress. The admin port should be
reachable from CI runners only — typically a ClusterIP Service inside the
cluster.
Configuration
Environment variables:
| Var | Required | Default | Meaning |
|---|---|---|---|
PAGES_ADMIN_TOKEN |
yes | — | Bearer token clients send to the admin API. |
PAGES_DATA_DIR |
no | ./data |
Where domains and releases are stored. |
PAGES_PUBLIC_ADDR |
no | :8080 |
Listen address for the public port. |
PAGES_ADMIN_ADDR |
no | :9090 |
Listen address for the admin port. |
PAGES_KEEP_RELEASES |
no | 5 |
Old releases to keep per mount (besides current). |
The server refuses to start if PAGES_ADMIN_TOKEN is unset.
Quick start (local)
mise install
task build
PAGES_ADMIN_TOKEN=dev-token \
PAGES_DATA_DIR=./data \
./bin/pages
In another terminal, package a directory and upload it:
tar czf site.tar.gz -C ./my-site .
curl -fsS -X POST \
-H "Authorization: Bearer dev-token" \
-H "Content-Type: application/gzip" \
--data-binary @site.tar.gz \
http://localhost:9090/sites/example.test
Then hit it on the public port with a matching Host:
curl -H "Host: example.test" http://localhost:8080/
Admin API
Authorisation: Authorization: Bearer <PAGES_ADMIN_TOKEN> on every request.
POST /sites/{domain} · POST /sites/{domain}/{path...}
Upload an archive. The body is the raw archive bytes — application/gzip
for .tar.gz (the common case) or application/x-tar for plain tar.
The path after {domain}/ is the URL path the mount will own. Omit it (or
use /) to publish the apex. A path like /blog/2024 creates a mount that
owns /blog/2024/* independently of any /blog mount.
Path rules:
- No
..segments, no leading or trailing slashes (the URL form trims a single leading slash for you). - Empty segments (
//) are rejected. - The path is case-sensitive, matching the URL exactly.
Archive rules:
- Only regular files and directories are extracted. Symlinks, hardlinks, and devices are silently skipped.
- Entries that would escape the destination via
..or absolute paths are rejected with400. - Per-file size limit: 1 GiB.
Response (201 Created):
{
"domain": "example.com",
"mount": "/blog",
"release": "20260505-103022-abc1234",
"previous": "20260504-181500-def5678"
}
previous is omitted on the first release for a mount.
DELETE /sites/{domain}/{path...}
Remove a single mount. 204 on success, 404 if the mount doesn't exist.
DELETE /sites/{domain}
Remove the entire domain (every mount). 204 on success, 404 if the
domain has no entry.
GET /sites
List all domains with at least one entry.
{ "domains": ["example.com", "blog.example.com"] }
GET /sites/{domain}
List the mounts of one domain along with the current release id and content size.
{
"domain": "example.com",
"mounts": [
{ "path": "/", "release": "20260505-103022-abc1234", "size_bytes": 1429883 },
{ "path": "/blog", "release": "20260501-090000-aaa1111", "size_bytes": 204113 }
]
}
Status codes
| Code | When |
|---|---|
| 201 | Successful upload |
| 204 | Successful delete |
| 400 | Malformed archive, bad domain, or invalid mount path |
| 401 | Missing or wrong bearer token |
| 404 | Mount/domain does not exist (DELETE), or public-side resource not found |
| 405 | Wrong HTTP method |
| 500 | Internal error (disk, etc.) |
Using it from CI (Woodpecker example)
Build artefacts in an earlier step, then upload from a step with curl:
when:
- event: [push, manual]
branch: main
steps:
build:
image: node:22
commands:
- pnpm install --frozen-lockfile
- pnpm build # produces ./dist
publish:
image: alpine:3.20
secrets: [pages_token]
commands:
- apk add --no-cache curl tar
- tar czf /tmp/site.tar.gz -C ./dist .
- >-
curl -fsS -X POST
-H "Authorization: Bearer $PAGES_TOKEN"
-H "Content-Type: application/gzip"
--data-binary @/tmp/site.tar.gz
https://pages-admin.internal/sites/blog.example.com
depends_on: [build]
For sub-path uploads (e.g. publishing /api-docs from a separate repo):
curl -fsS -X POST \
-H "Authorization: Bearer $PAGES_TOKEN" \
-H "Content-Type: application/gzip" \
--data-binary @docs.tar.gz \
https://pages-admin.internal/sites/example.com/api-docs
A push to /api-docs does not affect the apex (and vice versa). To remove
the sub-path entirely:
curl -fsS -X DELETE \
-H "Authorization: Bearer $PAGES_TOKEN" \
https://pages-admin.internal/sites/example.com/api-docs
Deployment notes (Kubernetes + Istio)
The image is FROM scratch and runs as UID/GID 65532. Mount a
PersistentVolumeClaim at PAGES_DATA_DIR; set fsGroup: 65532 on the
pod so the volume is writable. A minimal pod skeleton:
spec:
securityContext:
fsGroup: 65532
runAsNonRoot: true
containers:
- name: pages
image: code.olsen.cloud/homelab/pages:latest
env:
- name: PAGES_ADMIN_TOKEN
valueFrom:
secretKeyRef: { name: pages-admin, key: token }
- name: PAGES_DATA_DIR
value: /data
ports:
- { name: public, containerPort: 8080 }
- { name: admin, containerPort: 9090 }
volumeMounts:
- { name: data, mountPath: /data }
readinessProbe:
httpGet: { path: /, port: public, httpHeaders: [{ name: Host, value: _readiness }] }
# Returns 404 on _readiness which still proves the server is up.
# Use a TCP probe if you'd rather not rely on a 404.
volumes:
- name: data
persistentVolumeClaim:
claimName: pages-data
Two Services, two VirtualServices:
- Public Service exposes port 8080. Behind it, an Istio
Gateway+VirtualServiceper domain you serve. TLS termination lives on the gateway. - Admin Service exposes port 9090, reachable only inside the cluster
(no
Gateway). CI runners hit it aspages-admin.namespace.svc.cluster.local.
The single replica model keeps the symlink swap straightforward; if you
need HA, run multiple replicas behind the same ReadWriteMany volume —
per-mount serialisation is in-memory, so two pods receiving simultaneous
uploads to the same mount is theoretically last-write-wins. In practice
your CI almost never does that, and the failure mode (one upload's content
visible) is benign.
Layout
.
├── main.go # entry point + env config + graceful shutdown
├── internal/
│ ├── store/ # on-disk layout, mount keys, atomic swap, GC
│ ├── archive/ # safe tar/tar.gz extraction
│ └── server/ # public + admin HTTP handlers
├── Dockerfile # multi-stage scratch image
├── Taskfile.yml # task build / test / ci:quality / ...
├── mise.toml # pinned tool versions (Go, Task)
├── .woodpecker/ # CI workflows (quality, compliance, build, renovate)
└── renovate.json
Development
mise install # install pinned tool versions
task build
task test
task ci:quality # full PR-equivalent gate
task ci:quality runs format check, go vet, staticcheck, build, and race
tests with coverage. Same script Woodpecker runs.