Skip to content

Serve an instance on a public URL

Serve a Henceforth instance at a public https:// URL behind a reverse proxy, claim it as its first administrator with hf, and log in again later with the same URL.

  • A host name that resolves to your proxy, and a TLS certificate for it. The steps use hf.example.com.
  • A reverse proxy that terminates TLS and forwards to henceforth serve (nginx, Caddy, a cloud load balancer, and so on).
  • A PostgreSQL database for the instance, owned by the role in DATABASE_URL, the henceforth binary, and HENCEFORTH_MASTER_KEY set in the server’s environment. At every start the server revokes CONNECT on that database from PUBLIC (which only the owner can do), so any other role that connects (a backup or monitoring role) needs GRANT CONNECT ON DATABASE "<db>" TO "<role>";.
  • An ed25519 or ECDSA P-256 SSH key pair on the machine you will run hf from, for example ~/.ssh/id_ed25519. RSA keys are not accepted.
  • The hf binary on your PATH. hf --version confirms it is installed.

1. Put a TLS-terminating reverse proxy in front of the server

Section titled “1. Put a TLS-terminating reverse proxy in front of the server”

Forward https://hf.example.com to the address henceforth serve listens on (--port, default 8080). The server speaks plain HTTP to the proxy.

Decide where the proxy runs, because step 2 asks the server for the interface to listen on (--bind):

  • Proxy on the same host as the server: bind 127.0.0.1. Only processes on that host can reach the port, so nothing gets past the proxy’s TLS.
  • Proxy on another machine: bind the private address of the server’s network interface that the proxy reaches, and firewall the port so only the proxy connects. 0.0.0.0 also works, with the same firewall.
  • Server or proxy in a container: bind 0.0.0.0 inside the container and publish the port to the interface you want (for example -p 127.0.0.1:8080:8080 for a proxy on the host). A proxy container on a bridge network cannot reach the host’s 127.0.0.1; put both containers on one network and point the proxy at the server container’s name.

The server never learns its own URL from the request: it ignores Host, X-Forwarded-Host and every other forwarded header. Step 2 is what tells it which URL is its own.

2. Start the server with its public origin and the claim key

Section titled “2. Start the server with its public origin and the claim key”
Terminal window
henceforth serve \
--database-url "$DATABASE_URL" \
--metadata-path ./metadata \
--public-origin https://hf.example.com \
--bind 127.0.0.1 \
--claim-key-file ~/.ssh/id_ed25519.pub

--public-origin takes an origin: scheme, host and an optional port, with no path and no trailing slash. It is repeatable, and HENCEFORTH_PUBLIC_ORIGINS takes the same values comma-separated. https:// is required except for loopback hosts. The --claim-key-file (or HENCEFORTH_CLAIM_KEY_FILE) names the public key that may claim a new instance.

The example binds 127.0.0.1 for a proxy on the same host; use the value from step 1 for your layout. --bind takes the IP address of the interface to listen on, not a host name and not host:port (the port is --port). HENCEFORTH_BIND is the same setting. 0.0.0.0 is every IPv4 interface; :: is every IPv6 interface and, on Linux with the default net.ipv6.bindv6only=0, IPv4 too. Production has no default: henceforth serve without --dev refuses to start, with an error naming the flag, if --bind is missing. The server logs the address it bound at startup, in the bound_addr field of the henceforth listening on line.

--public-origin is required: henceforth serve without --dev refuses to start, with an error naming the flag, if no public origin is configured. The server accepts signatures only for the origins you list. It never treats localhost as its own unless you say so, because a reverse proxy on the same host makes every request look local. To serve from this machine only, declare the loopback origin yourself, for example --public-origin http://localhost:8080 together with --bind 127.0.0.1; a loopback origin is accepted only from a loopback connection.

hf dev and henceforth serve --dev need no --public-origin and no --bind: with --dev the server listens on 127.0.0.1, and with no public origin it accepts loopback origins on the port it listens on. Once you configure any origin, that default is off. --dev --bind 0.0.0.0 opens a development server to your network on purpose.

From the machine that holds the matching private key:

Terminal window
hf login prod https://hf.example.com --ssh

hf signs for exactly the URL you typed, so type the public URL, not the address the proxy forwards to. It shows what it is about to claim before it signs anything:

Claim https://hf.example.com as administrator 'alice', signing with key SHA256:... ? This cannot be undone. [y/N]

Check the origin and the key fingerprint, then answer y. The fingerprint is the SHA256: value that ssh-keygen -lf <key>.pub prints for the same key. Pass --name <name> to choose the administrator name and --yes to skip the prompt in a script.

Terminal window
hf login prod --ssh

With no URL, hf uses the one stored for the target prod. Logging in with a different spelling of the address (another host name, http://, the proxy’s internal address) signs for a different origin, which the server refuses with ORIGIN_MISMATCH unless it is also configured as a public origin.

Terminal window
hf whoami

prints your principal’s id (a UUID) followed by @ prod.

  • ORIGIN_MISMATCH: the URL you typed is not one of the server’s public origins. Start the server with --public-origin set to exactly that URL, or log in with the URL it is configured for. The error message shows the origin hf signed for. GET /status lists the origins the server accepts under public_origins. A loopback origin you configured is refused from a non-loopback connection by design.
  • A redirect error (the server answered 301 ... hf does not follow redirects when authenticating): the URL redirects somewhere else, for example from http:// to https://. Log in with the final URL.
  • The proxy answers 502, or the port refuses connections from another host: the server listens only on the interface --bind names. Point the proxy at that address and port, or change --bind as step 1 describes. The address is in the server’s startup log, in the bound_addr field.
  • The server exits with requires an explicit bind address: you started it without --dev and without --bind. Pass --bind (or set HENCEFORTH_BIND) as step 1 describes.
  • The server exits with requires at least one public origin: you started it without --dev and without --public-origin. Pass the public URL, or --public-origin http://localhost:<port> for a box-local instance.
  • INSTANCE_ALREADY_CLAIMED: someone claimed the instance first. hf login handles this itself by logging in with a fresh signature. If the key you signed with is not registered on the instance, that fallback login fails with UNKNOWN_KEY; log in with a registered key, or ask an administrator to enroll yours.
  • SSH_KEY_ALREADY_ENROLLED: the key you claimed with is already stored on a principal in the instance’s database. A key belongs to one principal, once, whatever its comment, and hf does not fall back to a login for this error. The claim created nothing, so the instance is still unclaimed. Restart the server with --claim-key or --claim-key-file naming a key no principal holds, then claim again with the matching private key.
  • Authentication architecture — why the signature binds the origin, and what an attacker could do without it.
  • Auth threat model — what the instance defends against, the known gaps, and what an operator must do beyond these steps.
  • Run henceforth serve --help and hf login --help for every flag.