Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Repository files navigation

htmlctl

htmlctl logo

Agent Skill

htmlctl-publish is now located at: ./.agent/skills/htmlctl-publish/SKILL.md

Go 1.24+ SQLite Caddy Docker Docker E2E MIT License

htmlctl demo: diff, apply, rollback

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.


How it works

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.

Highlights

  • 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.

Quickstart

Local preview

make build
htmlctl render -f ./site -o ./dist
htmlctl serve ./dist --port 8080

Docker — full stack locally

The 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_hosts

3. 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.css

5. 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-demo

Site directory structure

site/
├── 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

Resources

Website

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: true

Website-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 (E12)

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/verify

Current 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/events

Current 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.

Page

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.com

Component

Components are plain HTML fragments (components/*.html):

<!-- components/hero.html -->
<section id="hero">
  <h1>Hello</h1>
  <p>No &lt;script&gt; tags or on* event handlers — validated at apply time.</p>
</section>

Rules:

  • Exactly one root element (section, header, footer, main, nav, article, or div)
  • No <script> tags; JS goes in scripts/site.js
  • No inline event handlers (onclick, onload, etc.)

Commands

Local

Command Description
htmlctl render -f ./site -o ./dist Render site to static HTML
htmlctl serve ./dist --port 8080 Serve rendered output locally

Remote (require --context)

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

Release lifecycle

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

Domains

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

Backends

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.

Auth Policies

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.

Context

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)

Diagnostics

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.


Running on a VPS

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 prod

htmlservd 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.

OpenTelemetry

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=production

Restart 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.


Install

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 changes

Docker images:

docker build --target htmlservd-ssh -t htmlservd-ssh:local .   # server with SSH
docker build --target htmlctl -t htmlctl:local .               # CLI-only image

Environment variables

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)

Documentation

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

Security Notes

  • /api/v1/* requires Authorization: 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.


License

MIT — see LICENSE.

About

Kubectl-style control plane for static HTML sites with deterministic rendering, atomic releases, and staged promotion.

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages