Drop a folder.
Get an HTTPS site.

Author static sites as plain local folders — each named after its domain. A tiny client mirrors them to your own server, which serves each over HTTPS and renews the certificates for you. No build step, no dashboard, no database.

sites/
  example.com/        →  https://example.com
  blog.example.com/   →  https://blog.example.com
  shop.example.com/   →  https://shop.example.com

Folders are domains

A folder named example.com is served at example.com. Subdomains are folders too. www redirects to the bare domain unless a www. folder exists.

Automatic HTTPS

Caddy gets a real certificate for every domain whose DNS points at the server, renews it forever, and redirects plain HTTP straight to HTTPS — no configuration.

Stateless sync

A small client runs on a timer, compares metadata, and pushes only what changed. It keeps no state and never reads a file it needn't.

Install

Five short steps — a server, the two dependencies, the app, your DNS, then the client.

a · Get a server & note its IP. Any Linux box with systemd (a small VPS is plenty). Note its public IP address — you'll point the client and your domains at it.

b · Install Caddy & Node v20+. The installer checks for these but never installs them (so it behaves the same on every distro). Get them from your package manager: Caddy (from a package, so its systemd unit exists) and Node.

If either is missing, the installer lists them and stops, changing nothing:

[localhoster] missing required dependencies:
  - caddy  — install from https://caddyserver.com/docs/install
  - node   — install from https://nodejs.org  (v20 or newer)
  Install them with your OS's package manager (install Caddy from a package, so its systemd unit exists), then re-run deploy/install.sh.

c · Install localhoster. Two ways — both run the same installer and produce an identical server. Choose by whether you're happy to pipe a remote script straight to root:

Either — one line (convenient): it downloads, verifies the checksum, and runs

curl -fsSL https://localhoster.org/install.sh | sudo bash

Or — download & verify it yourself first (no need to trust the script)

curl -fsSLO https://localhoster.org/downloads/localhoster.tar.gz
curl -fsSLO https://localhoster.org/downloads/localhoster.tar.gz.sha256
sha256sum -c localhoster.tar.gz.sha256
tar xzf localhoster.tar.gz && sudo localhoster/deploy/install.sh

Either way it generates one token file (/etc/localhoster/token), a self-signed cert for the IP, installs the code under /opt/localhoster/<version>, and starts the service + Caddy. Copy the token it prints.

Output (abridged):

[localhoster] generating API token...
[localhoster] installing code to /opt/localhoster/0.1.0...
[localhoster] opened firewall ports 80, 443
[localhoster] installing service (version 0.1.0)...
[localhoster] done.
[localhoster] API token: 8f3c1a9b27e04d65f1aa90c3b7e2d4598c61f0ab73d529e4   ← copy this

d · Point your domains at it. Add an A record for each domain → your server's IP. That's all Caddy needs to issue a real certificate on the first visit.

e · Run the client (on your own computer). Point it at the server and your local folder of site folders.

cp client/config.example.json client/config.json
# apiBase:    "https://<your-server-ip>"
# apiToken:   "<token from the installer>"
# contentRoot:"./sites"
node client/sync.js

Output (first run, then a no-op run):

[localhoster] not verifying the server certificate (self-signed / reached by IP)
[localhoster] syncing /home/me/sites -> https://203.0.113.5
+ example.com
[localhoster] plan: 3 upload(s), 0 delete(s)
[localhoster] sync complete

[localhoster] up to date (root hash match, 1 request)

Schedule it — cron every minute, or launchd on macOS. Each run pushes whatever changed; the first run uploads everything.

Encrypted by IP, no DNS for the API. The API listens only on localhost; Caddy fronts it and proxies your server's IP to it at https://<ip> over a self-signed certificate (the bearer token authenticates you, and the client skips cert verification for a bare IP automatically — no flag to set). Hit that IP without the token and you get a plain 404 — nothing reveals an API is there. Prefer a verified certificate? Point a hostname you own at the box and use it as apiBase.

Manage it

sudo deploy/start.sh     # run now + enable at boot
sudo deploy/stop.sh      # stop now + disable at boot

Output:

[localhoster] running (and enabled at boot): localhoster-api + caddy
[localhoster] stopped (and disabled at boot): localhoster-api + caddy

Both need root. Upgrade by re-running sudo deploy/install.sh — it installs the new version alongside, repoints the service, and keeps your token and sites. To remove everything (sites included), sudo deploy/uninstall.sh.

Testing runs in Docker

Testing never touches your machine's ports or /srv — it stands up Caddy + the API in a container with the same fixed paths as production.

docker compose -f docker/compose.yaml up --build

# serves https://127.0.0.1:8443 with token "testtoken"; point the client at it:
printf '{ "apiBase":"https://127.0.0.1:8443", "apiToken":"testtoken", "contentRoot":"'"$PWD"'/sites" }\n' > client/config.json
node client/sync.js --verbose

Same API, client, and Caddy routing as production — the container just uses Caddy's internal CA instead of Let's Encrypt.

How the protocol works

Synchronisation is a recursive comparison of two Merkle trees built from file metadata. The client only ever sends the differences.

1 · A tree built from metadata, not contents

Each file's hash is sha256(size : mtime-seconds) — derived from stat() alone, so building the tree reads no file data. A directory's hash is the hash of its sorted child name:hash lines. This makes the whole tree a Merkle hash: two directories share a hash iff every file beneath them has identical size and timestamp. Empty directories and dotfiles are omitted, so the two sides always describe the same thing.

2 · Compare the root, then descend only where it differs

1
Ask for the root hash. GET /api/v1/tree?path=&depth=0 If it equals the local root hash, nothing changed — done in a single request.
2
Descend the differences. GET /api/v1/tree?path=<dir>&depth=1 For each directory whose hash differs, fetch one level and compare child by child. Identical subtrees are skipped entirely; only changed branches are walked.
3
Push new & changed files. PUT /api/v1/file?path=<rel>&mtime=<ms> The body is streamed with backpressure, so a multi-gigabyte file uses near-constant memory on both ends. The client sends the file's modification time; the server applies it to the written file so the two metadata hashes match next time.
4
Remove what's gone. DELETE /api/v1/file?path=<rel> Anything present on the server but absent locally is deleted; a directory delete removes its whole subtree and prunes empty parents.

3 · Why it stays correct and cheap

4 · Access logs, mirrored incrementally

Caddy writes one rotating access log. The client mirrors it by timestamp — GET /api/v1/logs lists sizes and rotated-file timestamps; GET /api/v1/logs/file?which=active fetches only the newly-appended tail via an HTTP Range. A new rotated file signals the active log restarted. Filenames never cross the wire, so the server can't influence where the client writes.

The endpoints

CallPurpose
GET /api/v1/treeMerkle node at a path/depth
PUT /api/v1/fileUpload a file + apply its mtime
DELETE /api/v1/fileDelete a file or a whole site
GET /api/v1/logsList active size + rotated timestamps
GET /api/v1/logs/fileStream a log (Range supported)
GET /api/versionServer's API version (skew check)

Security

Not publicly listening

The API binds localhost only — hardcoded, not overridable — and Caddy is the single public listener that proxies the server's IP to it. No browser surface, so no CSRF, sessions, or cookies.

Reveals nothing

Hit the IP without a valid token, or any unknown URL, and you get a plain 404 — identical to a missing file. Nothing advertises that an authenticated API exists.

One secret, no config

Every path is fixed; the only per-install input is one token in /etc/localhoster/token (a file, not an env var, which would leak via /proc), compared in constant time.

Strict paths

Paths are split on / and every segment must be a non-dot, URL-safe name; traversal, absolute paths, empty segments and odd bytes are rejected before touching disk — then confined to the content root and refused through symlinks.

No cert abuse

Caddy only issues a certificate for a domain the API confirms has a folder, via a localhost-only gate — so nobody can force issuance for domains you don't host.