- Go 99.2%
- Go Template 0.3%
- Makefile 0.2%
- Dockerfile 0.2%
- Ruby 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
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
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 |
||
| .forgejo/workflows | ||
| cmd | ||
| deploy/charts/thwap-pagesd | ||
| docs | ||
| errors | ||
| internal | ||
| logging | ||
| packaging | ||
| pkg/version | ||
| vendor | ||
| .dockerignore | ||
| .gitignore | ||
| .golangci.yml | ||
| .pre-commit-config.yaml | ||
| CLI-ROADMAP.md | ||
| DOCKER-ROADMAP.md | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
| ROADMAP.md | ||
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
kubectlcredentials 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.enabledand provideingress.tls(asecretNameplus hosts) so the controller serves the daemon over HTTPS. To provision certificates automatically, add a cert-manager annotation such ascert-manager.io/cluster-issuerviaingress.annotations:ingress: enabled: true annotations: cert-manager.io/cluster-issuer: "letsencrypt-prod" tls: - secretName: pages-tls hosts: - pages.example.comSet
webhook.public_urlto the resultinghttps://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 adns01solver) 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_usernameonly. - Members of
authentik.admin_groupmay 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 createdSite(see below)400 Bad Request— invalid body or validation error401 Unauthorized— missing or invalid bearer token (when auth enabled)403 Forbidden— caller may not register for the requested owner409 Conflict— site name already registered429 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 (highestv*tag) - Deletion of
refs/tags/v*→ rollback to highest remainingv*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, andgolangci-lint. - See ROADMAP.md for the phased implementation plan and issue tracker links.