Self-hosted static site hosting platform with server-side JavaScript workers. Upload a zip of your built site and get a live subdomain — like Cloudflare Pages, but on your own server with single-command deploys.
- Subdomain routing — each site gets
<name>.yourdomain.com, served instantly - Single-command deploys —
hostedat deploy my-site ./distfrom your terminal or CI - Server-side workers — deploy
_worker.jsfor dynamic request handling (Cloudflare Workers-compatible API) - KV storage — per-site key-value namespaces accessible from workers
- Cron triggers — schedule worker execution with standard cron expressions
- Netlify-compatible
_redirects— redirects, rewrites, and SPA fallback rules - Custom
_headers— per-path response headers - Custom
404.html— drop one in your upload and it just works - SPA mode — auto-detected on deploy or toggled per site
- Automatic HTTPS — wildcard certs via Let's Encrypt + CertMagic (DNS-01 with Cloudflare)
- User management — roles (superadmin, admin, user), invite system, API keys
- Dashboard — React frontend for managing sites, deployments, users, and settings
- CLI client — deploy from anywhere, integrates with CI/CD
- Reproducible builds — deterministic binaries with
-trimpathand zero build IDs - Portable — single binary, SQLite by default, swap to Postgres/MySQL via config
cp config.example.yaml config.yamlEdit config.yaml — at minimum set domain, jwt_secret, and cloudflare.api_token for production:
domain: hostedat.example.com
listen: ":443"
jwt_secret: "your-random-secret" # openssl rand -hex 32
storage_path: ./data/sites
database:
driver: sqlite
dsn: ./data/hostedat.db
cloudflare:
api_token: "your-cloudflare-token"For local development, leave cloudflare.api_token empty and use listen: ":8080".
go run ./cmd/serverOr build and run:
go build -o hostedat-server ./cmd/server
./hostedat-serverThe first user to register becomes the superadmin.
Install the CLI:
go install github.com/cryguy/hostedat/cmd/hostedat@latestThen:
hostedat login
hostedat sites create my-site
hostedat deploy my-site ./distYour site is live at my-site.yourdomain.com.
hostedat login # Authenticate via browser
hostedat sites list # List your sites
hostedat sites create <name> # Create a new site
hostedat sites delete <name> # Delete a site
hostedat deploy <site> <dir> # Deploy a directory
hostedat version # Print version info
The CLI auto-detects SPA projects (single index.html with scripts, few HTML files) and suggests enabling SPA mode. Override with --spa.
Place a _redirects file in the root of your upload:
/old-path /new-path 301
/blog/* /blog/:splat 200
/* /index.html 200 # SPA fallback
Static files always take precedence. First matching rule wins.
Custom response headers per path pattern:
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
/*.js
Cache-Control: public, max-age=31536000, immutable
Include a 404.html in your upload and it will be served for requests that don't match any file or rewrite rule.
Workers let you run server-side JavaScript on your site. Include a _worker.js file in your upload to handle requests dynamically — the API is compatible with Cloudflare Workers.
Workers run in a sandboxed QuickJS (ES2023) runtime compiled to WASM via Wazero — pure Go, zero CGO dependencies.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/api/hello") {
return Response.json({ message: "Hello from the edge!" });
}
// Fall through to static assets
return env.ASSETS.fetch(request);
},
};Workers have access to standard Web APIs:
fetch()— outbound HTTP requestsRequest,Response,Headers,URLcrypto.getRandomValues(),crypto.subtle,crypto.randomUUID()ReadableStream,WritableStream,TransformStreamFormData,Blob,FileAbortController,AbortSignalsetTimeout,setInterval,clearTimeout,clearIntervalatob,btoastructuredCloneconsole.log/warn/error(captured to worker logs)
Set environment variables and secrets via the API. They're available on the env object in your worker:
export default {
async fetch(request, env) {
const apiKey = env.MY_SECRET;
// ...
},
};KV namespaces provide per-site key-value storage accessible from workers:
export default {
async fetch(request, env) {
// Read
const value = await env.MY_KV.get("key");
// Write (with optional TTL in seconds)
await env.MY_KV.put("key", "value", { expirationTtl: 3600 });
// Delete
await env.MY_KV.delete("key");
// List keys (with optional prefix filter)
const { keys } = await env.MY_KV.list({ prefix: "user:" });
return new Response("OK");
},
};Schedule periodic worker execution with standard 5-field cron expressions:
export default {
async fetch(request, env) {
return new Response("OK");
},
async scheduled(event, env, ctx) {
console.log(`Cron fired: ${event.cron} at ${event.scheduledTime}`);
// Run background tasks, cleanup, etc.
},
};Configure worker resource limits in config.yaml:
worker:
pool_size: 4 # Pre-warmed JS runtimes per site
memory_limit_mb: 128 # Max memory per runtime
execution_timeout: 30000 # Max execution time in ms
max_fetch_requests: 50 # Max outbound fetch() calls per invocation
fetch_timeout_sec: 10 # Timeout per fetch() call
max_response_bytes: 10485760 # 10 MB max response body
max_log_retention: 7 # Days to keep worker logs
max_script_size_kb: 1024 # Max _worker.js file sizemake all # Build frontend + server + CLI
make build-all # Cross-compile for all platforms
make test # Run tests
make release # Full release (binaries + checksums + docs)- Go 1.25+
- Node.js 18+ (for frontend)
cmd/server/ Server entry point
cmd/hostedat/ CLI entry point
internal/api/ HTTP handlers and routing
internal/models/ Database models
internal/storage/ File storage and rule processing
internal/auth/ Authentication (JWT, API keys)
internal/config/ Configuration loading
internal/client/ API client (used by CLI)
internal/certs/ TLS certificate management
internal/worker/ Server-side JS engine (QuickJS/WASM)
web/ React + Vite frontend
docs/ Documentation site (Astro)
scripts/ Build and release scripts
Full documentation is available at docs.hostedat.ditto.moe.