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.
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
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.
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.
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.
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.
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.
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 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.
Synchronisation is a recursive comparison of two Merkle trees built from file metadata. The client only ever sends the differences.
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.
GET /api/v1/tree?path=&depth=0
If it equals the local root hash, nothing changed — done in a single
request.
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.
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.
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.
stat() each run, on both sides. The client is stateless;
the server keeps nothing — even after a restart the recomputed hashes
still match, because the upload preserved the timestamps.200 with an object describing the outcome — even
"that path is absent". Anything else — wrong token, unknown URL, wrong API
version — returns an identical 404 <h1>Not Found</h1>,
so a probe of the IP can't tell an API is there at all./api/v1/…; a client/server version mismatch just hits the
404, then the client checks GET /api/version to say "upgrade
the client" instead of failing silently.
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.
| Call | Purpose |
|---|---|
GET /api/v1/tree | Merkle node at a path/depth |
PUT /api/v1/file | Upload a file + apply its mtime |
DELETE /api/v1/file | Delete a file or a whole site |
GET /api/v1/logs | List active size + rotated timestamps |
GET /api/v1/logs/file | Stream a log (Range supported) |
GET /api/version | Server's API version (skew check) |
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.
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.
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.
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.
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.