htmlctl-publish is now located at:
./.agent/skills/htmlctl-publish/SKILL.md
Deploy static HTML/CSS/JS sites like infrastructure.
htmlctl (CLI) pairs with htmlservd (daemon) to give you immutable releases, atomic rollback, exact promotion, preview URLs, runtime path controls, and optional extensions such as a same-origin newsletter service or browser telemetry collector — on any VPS.
Write your site as declarative YAML resources. htmlctl renders them deterministically, bundles them into a content-addressed release, and activates it atomically on the server over an SSH tunnel. Rollback is a symlink switch that takes under a second.
htmlctl (CLI)
│
│ SSH tunnel · Bearer auth
▼
htmlservd (daemon)
├── SQLite (desired state, release history, domains, audit log)
├── Filesystem (immutable release artifacts, content-addressed blobs)
└── Caddy (Caddyfile managed by htmlservd · automatic TLS via ACME)
Environments (staging, prod) each have their own active release pointer. Promotion copies the exact artifact bytes — no rebuild, guaranteed hash parity.
Per-environment backends are managed separately from release content, so /api/* can point at different upstreams in staging and prod without changing the promoted static artifact.
Optional dynamic companion services are handled as extensions (for example newsletter or browser telemetry collection). Extensions are separate deployable services integrated via environment backends, not plugins loaded into htmlctl or htmlservd runtime. See extensions/README.md and docs/reference/extensions.md.
Run htmlctl extension validate extensions/<name> --remote --context <ctx> before wiring an extension into a live environment.
- Pinned Git input: deploy from a local folder or a pinned commit SHA with
htmlctl apply --from-git. - Preview URLs: create expiring review hosts pinned to a specific release with
htmlctl preview create. - Exact promotion: promote the exact artifact bytes from staging to prod. No rebuild, no drift.
- Runtime controls: add environment-specific backends and auth policies without baking them into site content.
- Extensions: keep dynamic services independent and route them through explicit backend prefixes such as
/newsletter/*and/site-telemetry/*. - Operational safety:
htmlctl doctor, instant rollback, release retention, and stricter backend rollback semantics keep live changes auditable and reversible. - Built-in discoverability: favicon publication,
robots.txt,sitemap.xml,llms.txt, structured data, and automatic OG image generation are part of the release build.
make build
htmlctl render -f ./site -o ./dist
htmlctl serve ./dist --port 8080The fastest way to try the whole system. No VPS required.
1. Build images
docker build --target htmlservd-ssh -t htmlservd-ssh:local .2. Start the server
API_TOKEN="$(htmlctl context token generate)"
mkdir -p .tmp/demo/{data,caddy}
docker run -d --name htmlservd-demo \
-p 23222:22 -p 19420:9400 -p 18080:80 \
-e SSH_PUBLIC_KEY="$(cat ~/.ssh/id_ed25519.pub)" \
-e HTMLSERVD_API_TOKEN="$API_TOKEN" \
-e HTMLSERVD_CADDY_AUTO_HTTPS=false \
-v "$PWD/.tmp/demo/data:/var/lib/htmlservd" \
-v "$PWD/.tmp/demo/caddy:/etc/caddy" \
htmlservd-ssh:local
curl -sf http://127.0.0.1:19420/healthz # → {"status":"ok"}
ssh-keyscan -p 23222 -H 127.0.0.1 > .tmp/demo/known_hosts3. Configure a context
cat > .tmp/demo/config.yaml << YAML
apiVersion: htmlctl.dev/v1
current-context: demo
contexts:
- name: demo
server: ssh://[email protected]:23222
website: mysite
environment: staging
port: 9400
token: $API_TOKEN
knownHostsPath: $PWD/.tmp/demo/known_hosts
YAML
export HTMLCTL_CONFIG="$PWD/.tmp/demo/config.yaml"4. Create a minimal site
mkdir -p .tmp/demo/site/{pages,components,styles}
cat > .tmp/demo/site/website.yaml << 'YAML'
apiVersion: htmlctl.dev/v1
kind: Website
metadata:
name: mysite
spec:
defaultStyleBundle: default
baseTemplate: default
YAML
cat > .tmp/demo/site/pages/index.page.yaml << 'YAML'
apiVersion: htmlctl.dev/v1
kind: Page
metadata:
name: index
spec:
route: /
title: My Site
description: Built with htmlctl
layout:
- include: hero
YAML
cat > .tmp/demo/site/components/hero.html << 'HTML'
<section id="hero">
<h1>Hello from htmlctl</h1>
</section>
HTML
printf ':root { --bg: #f8f8f8; }\n' > .tmp/demo/site/styles/tokens.css
printf 'body { font-family: sans-serif; background: var(--bg); }\n' > .tmp/demo/site/styles/default.css5. Deploy and verify
htmlctl apply -f .tmp/demo/site --context demo
# Bind a domain so Caddy serves it
htmlctl domain add 127.0.0.1.nip.io --context demo
open http://127.0.0.1.nip.io:18080/Cleanup
docker rm -f htmlservd-demosite/
├── website.yaml # Website resource (required)
├── branding/
│ ├── favicon.svg # Optional website icons (published to /favicon.svg, etc.)
│ └── favicon.ico
├── pages/
│ ├── index.page.yaml # One file per page
│ └── about.page.yaml
├── components/
│ ├── nav.html # HTML fragment — one root element
│ ├── hero.html
│ └── footer.html
├── styles/
│ ├── tokens.css # CSS custom properties
│ └── default.css # Base styles
├── scripts/
│ └── site.js # Optional global JS (single file)
└── assets/
└── logo.svg # Images, fonts — content-addressed by SHA-256
apiVersion: htmlctl.dev/v1
kind: Website
metadata:
name: mysite # [a-zA-Z0-9][a-zA-Z0-9_-]*, max 128 chars
spec:
defaultStyleBundle: default
baseTemplate: default
head:
icons:
svg: branding/favicon.svg
ico: branding/favicon.ico
seo:
publicBaseURL: https://example.com
displayName: Example Studio
description: Notes and product updates.
robots:
enabled: true
sitemap:
enabled: true
llmsTxt:
enabled: true
structuredData:
enabled: trueWebsite-level metadata supports favicon publication plus generated robots.txt, sitemap.xml, and llms.txt, plus website-level Organization/WebSite JSON-LD injection.
See docs/technical-spec.md for the full model and docs/guides/first-deploy-docker.md for the end-to-end workflow.
Extensions are optional companion services packaged in extensions/ and routed through environment backends.
Newsletter example:
# service health on host
curl -sf http://127.0.0.1:9501/healthz
# route newsletter path on staging
htmlctl backend add website/mysite \
--env staging \
--path /newsletter/* \
--upstream http://127.0.0.1:9501 \
--context staging
# route probe
curl -s -o /dev/null -w '%{http_code}\n' https://staging.example.com/newsletter/verifyCurrent route expectation: /newsletter/verify and /newsletter/unsubscribe return 400 when routing is correct but the token is missing, and healthy POST /newsletter/signup requests return 202.
Note: backend path /newsletter/* routes subpaths, not bare /newsletter.
Telemetry collector example:
# service health on host
curl -sf http://127.0.0.1:9601/healthz
# route browser telemetry path on staging
htmlctl backend add website/mysite \
--env staging \
--path /site-telemetry/* \
--upstream http://127.0.0.1:9601 \
--context staging
# route probe
curl -i -X POST \
-H 'Content-Type: application/json' \
-H 'Origin: https://staging.example.com' \
--data '{"events":[{"name":"page_view","path":"/"}]}' \
https://staging.example.com/site-telemetry/v1/eventsCurrent telemetry route expectation: site JavaScript posts to /site-telemetry/v1/events, a valid same-origin event returns 202, and the stored event is then queryable through the telemetry API for the matching website/environment.
See extensions/README.md, docs/reference/extensions.md, docs/guides/newsletter-extension-vps.md, and docs/guides/telemetry-collector-extension-vps.md.
apiVersion: htmlctl.dev/v1
kind: Page
metadata:
name: index
spec:
route: /
title: "My Site"
description: "A short description for search engines"
layout:
- include: nav
- include: hero
- include: footer
head: # optional — server-rendered into <head>
canonicalURL: https://example.com/
openGraph:
type: website
title: My Site
description: A short description
image: https://example.com/og.png
twitter:
card: summary_large_image
title: My Site
image: https://example.com/og.png
jsonLD:
- id: org
payload:
"@context": https://schema.org
"@type": Organization
name: My Org
url: https://example.comComponents are plain HTML fragments (components/*.html):
<!-- components/hero.html -->
<section id="hero">
<h1>Hello</h1>
<p>No <script> tags or on* event handlers — validated at apply time.</p>
</section>Rules:
- Exactly one root element (
section,header,footer,main,nav,article, ordiv) - No
<script>tags; JS goes inscripts/site.js - No inline event handlers (
onclick,onload, etc.)
| Command | Description |
|---|---|
htmlctl render -f ./site -o ./dist |
Render site to static HTML |
htmlctl serve ./dist --port 8080 |
Serve rendered output locally |
| Command | Description |
|---|---|
htmlctl apply -f ./site |
Upload and activate a new release |
htmlctl apply -f ./site --dry-run |
Show diff without deploying |
htmlctl apply --from-git <repo> --ref <commit-sha> [--subdir site] |
Resolve a pinned Git commit locally, then upload the resulting site |
htmlctl diff -f ./site |
Show file-level diff against current desired state |
htmlctl status [website/<name>] |
Show active release and environment status; omit website ref to use the active context website |
htmlctl get <resource-type> |
List inventory for websites, environments, releases, domains, or backends |
htmlctl logs [website/<name>] |
Show audit log; omit website ref to use the active context website |
| Command | Description |
|---|---|
htmlctl rollout history [website/<name>] |
List release history; omit website ref to use the active context website |
htmlctl rollout undo [website/<name>] |
Rollback to the previous release (< 1 second); omit website ref to use the active context website |
htmlctl promote website/<name> --from staging --to prod |
Copy release to another environment without rebuild |
| Command | Description |
|---|---|
htmlctl domain add <domain> |
Bind a domain; triggers Caddyfile regen + TLS cert |
htmlctl domain list |
List bound domains |
htmlctl domain verify <domain> |
Check DNS propagation and TLS readiness |
htmlctl domain remove <domain> |
Remove a domain binding |
| Command | Description |
|---|---|
htmlctl backend add [website/<name>] --path /api/* --upstream <url> [--env <env>] |
Declare an environment-scoped reverse proxy for a path prefix; omit website ref and --env to use the active context defaults |
htmlctl backend list [website/<name>] [--env <env>] |
List configured backends for an environment; omit website ref and --env to use the active context defaults |
htmlctl backend remove [website/<name>] --path /api/* [--env <env>] |
Remove a backend mapping by path; omit website ref and --env to use the active context defaults |
Backend paths must use the canonical prefix form /<segment>/*. They are runtime routing config, not bundle content, so promote does not copy or mutate backend definitions.
In human output, backend add now warns about obviously risky prefixes such as /styles/*, /scripts/*, /assets/*, and /favicon..., and suggests the next verification commands.
| Command | Description |
|---|---|
htmlctl authpolicy add [website/<name>] --path /docs/* --username <user> --password-stdin [--env <env>] |
Add or update an environment-scoped Basic Auth policy for a path prefix; omit website ref and --env to use the active context defaults |
htmlctl authpolicy list [website/<name>] [--env <env>] |
List configured auth policies for an environment; omit website ref and --env to use the active context defaults |
htmlctl authpolicy remove [website/<name>] --path /docs/* [--env <env>] |
Remove an auth policy by path; omit website ref and --env to use the active context defaults |
Auth policies are environment-scoped runtime config, not bundle content, so promote does not copy or mutate them. Passwords are hashed client-side with bcrypt before upload, list never returns hash material, overlapping auth-policy prefixes are rejected, and backend overlap is allowed only on an exact same-prefix match.
| Command | Description |
|---|---|
htmlctl context create <name> --server <ssh://user@host> --website <website> --environment <env> |
Create a context entry without editing YAML manually |
htmlctl context list |
List configured contexts |
htmlctl context use <name> |
Switch active context |
htmlctl context token generate |
Generate a 32-byte hex API token |
htmlctl config view |
Print current config with tokens redacted by default |
htmlctl config view --show-secrets |
Print current config including tokens |
htmlctl config use-context <name> |
Switch active context (legacy alias; prefer context use) |
| Command | Description |
|---|---|
htmlctl version |
Print the local htmlctl build version |
htmlctl version --remote |
Print both the local CLI version and the selected remote htmlservd version |
htmlctl doctor |
Check context resolution, SSH transport, authenticated API access, health, readiness, and version skew for the selected context |
All commands accept --output json or --output yaml for machine-parseable output.
The full operator guide is in docs/setup/hetzner-htmlservd.md.
The short version:
# 1. Build for your server architecture
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o htmlservd-linux-arm64 ./cmd/htmlservd
# 2. Upload and install on the server
scp htmlservd-linux-arm64 user@host:/tmp/htmlservd
ssh user@host "sudo mv /tmp/htmlservd /usr/local/bin/htmlservd && sudo chmod 755 /usr/local/bin/htmlservd"
# 3. Run the setup script (creates htmlservd OS user, SSH config, systemd service)
scp scripts/setup-htmlservd-hetzner.sh user@host:/tmp/
ssh user@host "HTMLSERVD_SSH_PUBKEY='$(cat ~/.ssh/id_ed25519.pub)' bash /tmp/setup-htmlservd-hetzner.sh"
# 4. Set the API token
ssh user@host "sudoedit /etc/htmlservd/env" # set HTMLSERVD_API_TOKEN=<strong-token>
ssh user@host "sudo systemctl restart htmlservd"
# 5. Configure your local context (see ~/.htmlctl/config.yaml)
# 6. Apply your site
htmlctl apply -f ./site --context staging
# 7. Add your domain (Caddy issues TLS automatically)
htmlctl domain add example.com --context prodhtmlservd is designed to run behind nothing — Caddy handles TLS termination directly. Port 9400 (the API) stays loopback-only; htmlctl reaches it via SSH port-forward.
htmlservd exports HTTP server spans and structured slog records through standard OpenTelemetry OTLP/HTTP when OTEL_EXPORTER_OTLP_ENDPOINT is set. No vendor SDK or application token configuration is required. Copy the placeholder-based docs/setup/htmlservd.env.example values into /etc/htmlservd/env; never put a real ingest token in source control.
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer <OTLP_INGEST_TOKEN>
OTEL_SERVICE_NAME=htmlservd
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=productionRestart htmlservd after changing its environment file. With no OTLP endpoint, telemetry remains disabled and no exporter is started. Use https for remote collectors; an http endpoint is appropriate only for a trusted local collector because OTLP headers can carry credentials.
Requires Go 1.24+. Always rebuild after pulling new code.
make build # → bin/htmlctl, bin/htmlservd
make test # unit + integration tests
go test -race ./... # required before merging server changesDocker images:
docker build --target htmlservd-ssh -t htmlservd-ssh:local . # server with SSH
docker build --target htmlctl -t htmlctl:local . # CLI-only image| Variable | Description |
|---|---|
HTMLCTL_CONFIG |
Config file path (default: ~/.htmlctl/config.yaml) |
HTMLCTL_SSH_KNOWN_HOSTS_PATH |
known_hosts override |
HTMLCTL_SSH_KEY_PATH |
Private key path (fallback if agent key is rejected) |
| Document | Description |
|---|---|
docs/guides/first-deploy-docker.md |
Full Docker quickstart and end-to-end deployment flow |
docs/guides/showcase-demo.md |
Repeatable showcase demo recording flow |
docs/setup/hetzner-htmlservd.md |
VPS setup runbook |
docs/setup/htmlservd.env.example |
Placeholder-only systemd environment file with standard OTLP settings |
docs/technical-spec.md |
Architecture, API, and resource model |
docs/reference/docker-images.md |
Docker image reference |
/api/v1/*requiresAuthorization: Bearer <token>when an API token is configured.- Telemetry ingest (
POST /collect/v1/events) is bearer-authenticated and intended for trusted collectors, not public browser JavaScript. - SSH auth prefers the agent, then falls back to a private key file constrained to the local user’s home directory.
See docs/technical-spec.md and docs/reference/docker-images.md.
MIT — see LICENSE.

