Bind a domain to any service on your VPS from your browser — no Nginx, no Caddy, no config files to hand-edit.
HostKnot is a single Rust binary that turns "I deployed a service on port 3000, now I want app.example.com pointing at it with HTTPS" into a two-minute browser workflow. It combines four things that normally require separate tools:
- A reverse proxy on ports 80/443 with exact-host and optional path-prefix routing, HTTP/2, WebSockets, server-sent events, and streaming.
- Automatic DNS through your Cloudflare account (OAuth-authorized, no API token pasting), with safe conflict handling and rollback on unbind.
- Automatic certificates from Let's Encrypt — including a short-lived IP certificate so even the admin UI at
https://<your-ip>:9443is served over trusted TLS. - A browser admin UI rendered entirely from the binary: no CDN, no Node.js, no frontend build.
Everything persists in one SQLite database with encrypted credentials, survives restarts, and installs as a hardened systemd service.
Browser ──▶ https://<VPS-IP>:9443 (admin UI: bind app.example.com → :3000)
│
├─▶ Cloudflare API creates A/AAAA records (OAuth)
└─▶ Let's Encrypt issues the certificate (HTTP-01)
Visitors ─▶ https://app.example.com ──▶ HostKnot :443 ──▶ 127.0.0.1:3000
└─▶ /assets/* ──▶ 127.0.0.1:8080
- A Linux VPS running systemd (
x86_64oraarch64) - Inbound TCP ports 80, 443, and 9443 open, with a public IPv4 or IPv6 address that reaches the machine
- A Cloudflare account with the target domain in an active zone
- A private Cloudflare OAuth client (created once during setup — guide)
1. Install. Download the release archive for your architecture (x86_64-unknown-linux-musl or aarch64-unknown-linux-musl), verify it against SHA256SUMS, then as root:
hostknot service install --public-ip <YOUR_VPS_IP><YOUR_VPS_IP> is the public IPv4 or IPv6 address the internet reaches your VPS on — it's published in DNS records and receives the admin UI's IP certificate. Unsure which it is? On the VPS:
curl -4 ifconfig.mesystemctl daemon-reload && systemctl enable --now hostknot2. Open the setup link. The service log prints a one-time setup URL:
journalctl -u hostknot | grep 'setup URL'It looks like https://<YOUR_VPS_IP>:9443/setup?token=.... The token is single-use and expires after one hour — restart the service or run hostknot admin reset for a fresh one. Before the URL appears, HostKnot obtains a trusted Let's Encrypt IP certificate over HTTP-01, so port 80 must already be reachable; if issuance fails, the UI still comes up on a self-signed fallback and keeps retrying.
3. Create the administrator. Open the URL, set an admin password (12+ characters) and an ACME contact email.
4. Connect Cloudflare. On the Cloudflare page in the UI:
- Create a private Authorization Code OAuth client in the Cloudflare dashboard.
- Register the exact callback URI the page displays, and grant Zone Read, DNS Write, and offline access.
- Paste the client ID and secret into HostKnot, then click Authorize with Cloudflare.
Full walkthrough: docs/cloudflare-oauth.md.
5. Bind a domain. Click New binding, pick a discovered local port (or type one), enter the hostname, and bind. HostKnot creates the DNS records, obtains the certificate, and starts routing — typically within seconds. Most local apps speak plain HTTP; leave the protocol on its HTTP default and HostKnot still serves the public side over HTTPS.
6. Add path routes when needed. Edit an active binding to send a path such as /assets to another loopback service. HostKnot preserves the full request path and query; all unmatched requests continue to use the binding's default upstream.
- Exact hostnames only.
app.example.commatchesapp.example.com— no wildcard hosts. Hostnames are immutable; to rename, create a replacement binding. - Optional longest-prefix routes.
/assetsmatches/assetsand/assets/..., but not/assets-old. Overlapping routes are allowed and the most specific match wins. Prefixes are preserved rather than stripped or rewritten. - Editable after creation: the default local port and protocol, Cloudflare proxied/DNS-only mode, and secondary path routes.
- Failures stay isolated. If a matched path route is unavailable, HostKnot returns an opaque 502 and does not retry against the default upstream. Each path route reports health independently.
- Certificate hiccups don't dead-end. If issuance is delayed (DNS propagation, a busy CA), the binding shows Certificate pending on the dashboard and retries in the background with backoff — no action needed.
- Conflicts need confirmation. If the hostname already has A/AAAA/CNAME records, HostKnot asks before replacing them, saves the originals, and restores them on unbind.
- Drift is never destroyed. If someone changes the records outside HostKnot, unbinding stops and reports the drift instead of overwriting external changes; once resolved, removal completes automatically.
- Apps see the real visitor IP. On Cloudflare-proxied bindings, HostKnot verifies the connection actually comes from Cloudflare's published ranges, then reports the visitor's address (from
CF-Connecting-IP) inX-Forwarded-ForandForwarded. Spoofed copies of those headers on direct connections are stripped before reaching your app. - Unbinding drains. DNS records are removed first, then the route keeps serving for five minutes so cached DNS doesn't hit a dead endpoint.
- Everything survives restarts — bindings, sessions, certificates, DNS receipts, and any half-finished work, which reconciliation resumes with backoff.
| Command | Purpose |
|---|---|
hostknot serve [--config <PATH>] |
Run the admin UI and proxy listeners (what the systemd unit runs) |
hostknot doctor [--config <PATH>] [--offline] |
Check IPs, ports, key permissions, database integrity, and outbound Cloudflare/ACME connectivity; exits non-zero if problems are found |
hostknot service install --public-ip <IP> [--binary <PATH>] [--root <PATH>] |
Install/upgrade the binary, default config, and hardened systemd unit |
hostknot admin reset [--state-dir <PATH>] |
Invalidate all sessions and print a new one-time setup token (bindings, credentials, and certificates are kept) |
hostknot version |
Print the version |
Defaults: --config /etc/hostknot/config.toml, --state-dir /var/lib/hostknot.
The installer writes /etc/hostknot/config.toml on first install only — re-running service install upgrades the binary and unit but never rewrites an existing config (it warns if --public-ip differs from what the config already says). Most deployments never need to touch it.
| Key | Default | Description |
|---|---|---|
state_dir |
/var/lib/hostknot |
Database, master key, and certificates |
admin_listen |
0.0.0.0:9443 |
Admin UI listener |
http_listen / https_listen |
0.0.0.0:80 / 0.0.0.0:443 |
Proxy listeners (80 also serves ACME challenges and redirects) |
public_ips |
from --public-ip |
Addresses published in DNS records and used for the admin IP certificate |
admin_public_url |
https://<IP>:9443/ |
Public admin URL; also the base of the OAuth callback |
drain_seconds |
300 |
How long a route keeps serving after its DNS records are removed |
acme_directory_url |
Let's Encrypt production | ACME directory (point at a staging/test CA if needed) |
acme_root_certificate |
(unset) | Extra root to trust for the ACME directory (test CAs) |
Run the new binary's installer against the live service, then restart and confirm the version actually changed:
sudo ./hostknot service install --public-ip <YOUR_VPS_IP> --binary ./hostknot && sudo systemctl restart hostknot && hostknot versionBindings, credentials, and certificates are preserved — upgrades only swap the binary (staged and renamed atomically, so a running service is safe to upgrade in place).
Reinstalls never touch an existing config, so a changed VPS address is a manual edit: update public_ips and admin_public_url in /etc/hostknot/config.toml, restart, and expect the admin certificate and DNS records to be re-issued for the new address on the next reconciliation.
/usr/local/bin/hostknot,/etc/hostknot/config.toml,/etc/systemd/system/hostknot.service— created by the installer./var/lib/hostknot/(master.key,hostknot.sqlite3) — created by the service on first start under systemd'sStateDirectory, owned by the service identity. The database and key are one backup unit; see docs/operations.md for backup, restore, and upgrade procedures.
- Least privilege. The systemd unit runs under a private dynamic user with
CAP_NET_BIND_SERVICEas its only capability, a read-only host filesystem, and a writable state directory. - Encrypted at rest. OAuth secrets and tokens, ACME account credentials, and certificate keys are sealed with XChaCha20-Poly1305 under a mode-
0600master key (context-bound so ciphertexts can't be swapped between columns). The service refuses to start if the key is readable by other users. Only Argon2id hashes are stored for the admin password. - Hardened admin surface. HTTPS with a trusted IP certificate, HTTP-only same-site session cookies, per-session CSRF tokens with origin checks, per-IP login throttling, expiring single-use setup/reset tokens, OAuth state bound to the initiating session (PKCE S256), and strict security headers.
- Contained proxying. Default and path-route upstreams are pinned to loopback — the proxy cannot be pointed at arbitrary hosts. Inbound
X-Forwarded-*headers are overwritten, hop-by-hop headers are stripped, and upstream failures return an opaque 502. For HTTPS upstreams, private and self-signed certificates are accepted automatically: that hop never leaves the machine, while the public-facing side always serves real, verified certificates.CF-Connecting-IPis honored only from verified Cloudflare edge addresses and stripped from everything else.
Start with the built-in diagnostics — it checks the same things that make installs fail:
hostknot doctor --config /etc/hostknot/config.tomlThe admin dashboard shows per-binding DNS/certificate/upstream status and an event history. docs/operations.md covers failure behavior, backup/restore, and upgrades.
Common gotchas:
- An upgrade "didn't take" — run
hostknot version; if it still shows the old version, the install step failed before the restart (its error is easy to miss in a&&chain). Re-run the installer and check its output. - Admin forms rejected, DNS records or the admin certificate using a wrong address — the config still holds the IP from the first install; see "Changing the public IP" above.
- A binding 502s immediately — check the upstream protocol on the binding: an HTTPS upstream setting against an app that speaks plain HTTP fails the TLS handshake. Most local apps want HTTP.
- Only one path returns 502 — edit the binding and inspect that path route's protocol, port, and independent health status. Matched route failures intentionally do not fall back to the default service.
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
cargo build --releaseThe test suite runs against real seams: actual HostKnot processes, real HTTP/TLS/WebSocket clients, and protocol-level fakes for Cloudflare and ACME. Time-based behavior (token/session expiry, throttling, certificate renewal) is tested through an injected clock.
Browser tests (Playwright, drives a real HostKnot process through setup, OAuth, binding, and unbind):
npm ci && npx playwright install chromium
npm run test:browserACME issuance and renewal against a local Pebble server:
docker run -d --name pebble --network host \
-e PEBBLE_VA_NOSLEEP=1 ghcr.io/letsencrypt/pebble:2.7.0
docker cp pebble:/test/certs/pebble.minica.pem /tmp/pebble.minica.pemHOSTKNOT_PEBBLE_DIRECTORY=https://localhost:14000/dir \
HOSTKNOT_PEBBLE_MANAGEMENT=https://localhost:15000 \
HOSTKNOT_PEBBLE_ROOT=/tmp/pebble.minica.pem \
HOSTKNOT_PEBBLE_HTTP_LISTEN=0.0.0.0:5002 \
cargo test --test acme_pebble -- --ignoredMIT — see LICENSE-MIT.