No description
  • Go 99.2%
  • Dockerfile 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Morten Olsen d81707d8ee
All checks were successful
ci/woodpecker/push/quality Pipeline was successful
ci/woodpecker/push/compliance Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
fix(ci): pin Go 1.26.2 + node for npm backend, bump staticcheck
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>
2026-05-05 21:26:48 +02:00
.woodpecker feat: initial pages server with admin API and CI pipeline 2026-05-05 21:23:01 +02:00
internal feat: initial pages server with admin API and CI pipeline 2026-05-05 21:23:01 +02:00
.gitignore feat: initial pages server with admin API and CI pipeline 2026-05-05 21:23:01 +02:00
Dockerfile fix(ci): pin Go 1.26.2 + node for npm backend, bump staticcheck 2026-05-05 21:26:48 +02:00
go.mod feat: initial pages server with admin API and CI pipeline 2026-05-05 21:23:01 +02:00
main.go feat: initial pages server with admin API and CI pipeline 2026-05-05 21:23:01 +02:00
mise.toml fix(ci): pin Go 1.26.2 + node for npm backend, bump staticcheck 2026-05-05 21:26:48 +02:00
README.md feat: initial pages server with admin API and CI pipeline 2026-05-05 21:23:01 +02:00
renovate.json feat: initial pages server with admin API and CI pipeline 2026-05-05 21:23:01 +02:00
Taskfile.yml fix(ci): pin Go 1.26.2 + node for npm backend, bump staticcheck 2026-05-05 21:26:48 +02:00

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 with 400.
  • 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 + VirtualService per 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 as pages-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.