No description
  • Go 99.2%
  • Go Template 0.3%
  • Makefile 0.2%
  • Dockerfile 0.2%
  • Ruby 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mike 'Fuzzy' Partin aeff2dc428
All checks were successful
CI / test (pull_request) Successful in 10m10s
CI / build (pull_request) Successful in 4m44s
CI / docker (pull_request) Successful in 3m17s
CI / lint (pull_request) Successful in 4m28s
CI / build (push) Successful in 10m43s
CI / lint (push) Successful in 11m20s
CI / test (push) Successful in 17m5s
CI / docker (push) Successful in 29m17s
feat(docker): implement container inspection and health checking
Add InspectContainer and WaitContainerRunning primitives to the
StandaloneDeployer, plus a ContainerStatusReporter that surfaces the
active deployment's container status through the site status API.

Closes #354
Closes #355
Closes #356
Closes #357
2026-08-14 02:35:15 -07:00
.forgejo/workflows ci: pin actions and harden release packaging 2026-08-10 11:41:42 -07:00
cmd feat(docker): implement site container creation 2026-08-13 08:41:04 -07:00
deploy/charts/thwap-pagesd docs(tls): document ingress controller TLS termination 2026-08-04 13:26:51 -07:00
docs docs(standalone): resolve storage mechanism and review feedback 2026-08-12 16:31:48 -07:00
errors feat(docker): implement container inspection and health checking 2026-08-14 02:35:15 -07:00
internal feat(docker): implement container inspection and health checking 2026-08-14 02:35:15 -07:00
logging feat(deps): add core dependencies 2026-08-02 00:32:23 -07:00
packaging ci: pin actions and harden release packaging 2026-08-10 11:41:42 -07:00
pkg/version test(unit): cover tag selection, deployment state, and preview cleanup 2026-08-04 01:14:51 -07:00
vendor feat(docker): add Docker client integration 2026-08-12 17:44:18 -07:00
.dockerignore feat(build): add Makefile and Dockerfile 2026-08-02 05:52:04 -07:00
.gitignore chore: ignore backup files in git 2026-08-03 17:39:18 -07:00
.golangci.yml chore: update goimports local-prefixes to thwap-pagesd 2026-08-02 00:04:05 -07:00
.pre-commit-config.yaml feat(helm): add Helm chart for Kubernetes deployment 2026-08-04 02:43:54 -07:00
CLI-ROADMAP.md feat(tpagectl): add context support for multiple instances 2026-08-10 07:59:47 -07:00
DOCKER-ROADMAP.md feat(docker): implement container inspection and health checking 2026-08-14 02:35:15 -07:00
Dockerfile feat(build): add Makefile and Dockerfile 2026-08-02 05:52:04 -07:00
go.mod feat(docker): implement site container removal 2026-08-13 21:06:19 -07:00
go.sum feat(docker): add Docker client integration 2026-08-12 17:44:18 -07:00
Makefile test(tpagectl): add comprehensive test suite 2026-08-08 08:58:11 -07:00
README.md docs(tls): document ingress controller TLS termination 2026-08-04 13:26:51 -07:00
ROADMAP.md docs(roadmap): mark storage backends and build hooks as won't fix 2026-08-04 13:40:44 -07:00

thwap-pagesd

thwap-pagesd is a self-hosted static site hosting daemon for Forgejo. It registers sites, serves their content from tagged releases, and offers per-branch preview deployments — an open alternative to GitHub Pages for a Forgejo + Kubernetes environment.

Overview

thwap-pagesd automates the lifecycle of a static site backed by a Forgejo repository:

  • Register a site: creates the Forgejo repository, installs a push webhook, configures a Kubernetes Ingress, and persists the site configuration.
  • Publish: when a v* tag is pushed, the daemon selects the highest semantic version and updates the Kubernetes resources that serve the site.
  • Preview: pushes to test/* branches deploy a preview alongside production, served under /preview/<branch>.
  • Rollback: when a v* tag is deleted, the daemon reselects the highest remaining version and republishes.

Architecture

Forgejo ──webhook──▶ thwap-pagesd ──client-go──▶ Kubernetes
   ▲                    │                          │
   │  repo/tags         │  clone/checkout          │  Deployment / Service /
   │                    ▼                          │  Ingress / ConfigMap
   └──────────◀── git ──┴──────────────────────────┘
Component Role
internal/forgejo Forgejo API: repository creation, webhooks, tag listing
internal/git Git operations via go-git: clone, fetch, checkout, asset verification
internal/kubernetes Ingress/Deployment/Service/ConfigMap management, site persistence
internal/site Domain models and the SiteService orchestration
internal/webhook Forgejo push webhook receiver
internal/httpapi Gin HTTP server and REST API
internal/metrics Lightweight /metrics endpoint

Sites are served from Kubernetes ConfigMaps (registration survives restarts); site content lives in a shared volume mounted at /var/www/site.

Setup

Prerequisites

  • Go 1.26 or later
  • Access to a Kubernetes cluster (e.g. k3s) with kubectl credentials or in-cluster permissions
  • An ingress controller (e.g. nginx, traefik) — see TLS termination
  • A Forgejo instance and a personal access token with repository + webhook rights

TLS termination

thwap-pagesd terminates TLS at the ingress controller. The daemon itself listens on plain HTTP (:8080) inside the cluster and never serves TLS directly; the controller proxies external HTTPS traffic to it. TLS applies to two surfaces:

  • Daemon API and webhook — when deploying with the Helm chart, set ingress.enabled and provide ingress.tls (a secretName plus hosts) so the controller serves the daemon over HTTPS. To provision certificates automatically, add a cert-manager annotation such as cert-manager.io/cluster-issuer via ingress.annotations:

    ingress:
      enabled: true
      annotations:
        cert-manager.io/cluster-issuer: "letsencrypt-prod"
      tls:
        - secretName: pages-tls
          hosts:
            - pages.example.com
    

    Set webhook.public_url to the resulting https:// URL (e.g. https://pages.example.com) so the webhook target and preview URLs are correct.

  • Served sites — the site and preview Ingresses the daemon creates ({site}.{base_domain}, custom domains, and /preview/*) rely on the same ingress controller for TLS. A wildcard certificate for *.{base_domain} (or cert-manager with a dns01 solver) covers the default site hosts; custom domains need their own certificates.

Configuration

Configuration is resolved in order of precedence: defaults → config file → environment variables. Config files are YAML or TOML, looked up in the working directory, $HOME/.config/thwap-pagesd, and /etc/thwap-pagesd (or given explicitly with --config <path>). Environment variables use the THWAP_PAGESD_ prefix and _ in place of . (e.g. THWAP_PAGESD_FORGEJO_TOKEN).

Example thwap-pagesd.yaml

listener:
  address: ":8080"

forgejo:
  url: "https://forgejo.example.com"
  token: "your-token"

kubernetes:
  namespace: "thwap-pagesd"
  base_domain: "pages.example.com"
  ingress_class: "traefik"
  static_image: "nginx:alpine"
  storage_type: "emptyDir"        # or "pvc"
  storage_class: ""               # empty = cluster default
  preview_max_age: "720h"         # 30 days
  preview_gc_interval: "1h"

webhook:
  secret: "shared-webhook-secret"
  public_url: "https://pages.example.com"

authentik:
  issuer: "https://authentik.example.com"
  audience: "pages-client"
  jwks_url: "https://authentik.example.com/application/o/pages/jwks/"
  admin_group: "pages-admins"

rate_limit:
  enabled: true
  rps: 2
  burst: 5

logging:
  level: "info"

Configuration reference

Key Default Description
listener.address :8080 HTTP listener address
forgejo.url Forgejo API base URL
forgejo.token Forgejo personal access token
kubernetes.namespace thwap-pagesd Namespace for site resources
kubernetes.kubeconfig Explicit kubeconfig path (default: in-cluster or ~/.kube/config)
kubernetes.base_domain pages.example.com Base domain for {site}.{base_domain} hosts
kubernetes.ingress_class nginx IngressClass used for site Ingresses
kubernetes.static_image nginx:alpine Static file server image
kubernetes.storage_type emptyDir Site content volume: emptyDir or pvc
kubernetes.storage_class StorageClass for PVCs (empty = cluster default)
kubernetes.preview_max_age 720h Max age of preview deployments before GC
kubernetes.preview_gc_interval 1h Preview garbage collection interval
webhook.secret Shared secret for webhook signature verification
webhook.public_url Public base URL; webhook target is <public_url>/webhook
authentik.issuer Expected JWT iss claim (empty = skip check)
authentik.audience Expected JWT aud claim / Authentik client ID
authentik.jwks_url Authentik JWKS endpoint for signature validation
authentik.admin_group Authentik group granting admin (any-owner) access
rate_limit.enabled false Enable per-IP rate limiting on the registration API
rate_limit.rps 2 Allowed requests per second per client IP
rate_limit.burst 5 Token bucket burst size
rate_limit.cleanup_interval 10m How often idle rate-limit buckets are pruned
rate_limit.cleanup_idle 1h Max idle before a rate-limit bucket is dropped
logging.level info Log level: debug, info, warn, error

Building and running

make build          # build dist/thwap-pagesd
make test           # go test -race -count=1 ./...
make run            # go run ./cmd/thwap-pagesd
make lint           # golangci-lint run
make vendor         # refresh vendor/ (THWAP vendors all dependencies)

Run with a config file:

./dist/thwap-pagesd --config /etc/thwap-pagesd/thwap-pagesd.yaml

Container

docker build -t thwap-pagesd:latest .
docker run --rm -p 8080:8080 \
  -e THWAP_PAGESD_FORGEJO_URL=https://forgejo.example.com \
  -e THWAP_PAGESD_FORGEJO_TOKEN=... \
  thwap-pagesd:latest

Graceful shutdown

On SIGINT or SIGTERM the daemon stops accepting new connections, flips /readyz to unavailable so ingress/k8s stop routing new traffic, drains in-flight requests (up to a 10s timeout), waits for the preview GC goroutine to exit, and releases background resources (e.g. the JWKS refresher) before exiting.

API Reference

All responses are JSON. The API is served on the configured listener address.

Authentication

When authentik.jwks_url is configured, the site endpoints (/api/v1/sites*) require an Authorization: Bearer <token> header carrying a JWT signed by Authentik. Tokens are validated against the configured JWKS with their iss and aud claims checked when set. Health (/healthz, /readyz), metrics (/metrics), and the webhook (/webhook) endpoints are always public.

Authorization is by user and owner:

  • Non-admin users may register sites for and view sites owned by their preferred_username only.
  • Members of authentik.admin_group may act on any owner.

Without auth configuration the site endpoints remain open for backward compatibility.

Rate limiting

When rate_limit.enabled is set, the registration endpoint (POST /api/v1/sites) is limited per client IP with a token bucket of rate_limit.rps requests per second and a rate_limit.burst burst. Exceeding the limit returns 429 Too Many Requests with a Retry-After: 1 header. Idle IP buckets are pruned every rate_limit.cleanup_interval after being idle for rate_limit.cleanup_idle.

Health

GET /healthz

Liveness probe.

{"status": "ok"}

GET /readyz

Readiness probe.

{"status": "ready"}

Metrics

GET /metrics

Plain-text metrics in Prometheus text format.

thwap_pagesd_sites_total 3
thwap_pagesd_deployments_total 12
thwap_pagesd_errors_total 2

Sites

POST /api/v1/sites

Register a new site.

Request body:

Field Type Description
name string Site name; lowercase DNS label, ≤ 63 chars
owner string Forgejo owner (user or organization)
visibility string public, limited, or private
custom_domain string Optional custom domain
preview_enabled bool Enable test/* preview deployments
{
  "name": "blog",
  "owner": "fuzzy",
  "visibility": "public",
  "preview_enabled": true
}

Responses:

  • 201 Created — the created Site (see below)
  • 400 Bad Request — invalid body or validation error
  • 401 Unauthorized — missing or invalid bearer token (when auth enabled)
  • 403 Forbidden — caller may not register for the requested owner
  • 409 Conflict — site name already registered
  • 429 Too Many Requests — rate limit exceeded (when enabled)
  • 500 Internal Server Error — downstream failure

GET /api/v1/sites/{name}

Return a site and its current deployment.

{
  "name": "blog",
  "owner": "fuzzy",
  "repo_url": "https://forgejo.example.com/fuzzy/blog.git",
  "visibility": "public",
  "preview_enabled": true,
  "created_at": "2026-08-02T00:00:00Z",
  "current_deployment": {
    "site_name": "blog",
    "version": "v1.2.3",
    "commit_sha": "",
    "status": "active",
    "deployed_at": "2026-08-02T01:00:00Z"
  }
}

Responses: 200 OK, 401 Unauthorized (when auth enabled), 403 Forbidden, 404 Not Found, 500 Internal Server Error.

GET /api/v1/sites/{name}/deployments

Return the deployment history, newest first.

[
  {
    "site_name": "blog",
    "version": "v1.2.3",
    "commit_sha": "",
    "status": "active",
    "deployed_at": "2026-08-02T01:00:00Z"
  }
]

Responses: 200 OK, 401 Unauthorized (when auth enabled), 403 Forbidden, 404 Not Found, 500 Internal Server Error.

Webhook

POST /webhook

Forgejo push webhook receiver. Requests must carry the X-Gitea-Signature header (HMAC-SHA256 of the body keyed by webhook.secret; verification is skipped when the secret is empty).

Payload (push event):

Field Description
ref Pushed ref, e.g. refs/heads/main, refs/tags/v1.2.3, or refs/heads/test/foo
before SHA before the push
after SHA after the push (all zeros on ref deletion)
repository.name Repository name
repository.full_name Owner/repository

Behavior:

  • Push to refs/heads/test/* → preview deployment (/preview/<branch>)
  • Deletion of refs/heads/test/* → preview cleanup
  • Push to refs/tags/v* → production publish (highest v* tag)
  • Deletion of refs/tags/v* → rollback to highest remaining v* tag

Responses: 200 OK, 400 Bad Request, 401 Unauthorized (bad signature), 404 Not Found, 500 Internal Server Error.

Models

Site

Field Type Description
name string Site name
owner string Forgejo owner
repo_url string Repository clone URL
visibility string public, limited, private
custom_domain string Optional custom domain
preview_enabled bool Preview deployments enabled
created_at string (RFC3339) Registration time

Deployment

Field Type Description
site_name string Site name
version string Deployed version tag or branch
commit_sha string Commit SHA (empty for tag-list-driven deploys)
status string pending, building, active, failed
deployed_at string (RFC3339) Deployment time

Development

  • All dependencies are vendored per THWAP policy (vendor/).
  • Pre-commit hooks enforce gofmt, go mod tidy, go test, and golangci-lint.
  • See ROADMAP.md for the phased implementation plan and issue tracker links.