Self-host hosti

Hosti runs as one container on one VPS. One named volume at /data holds both the bundles and the SQLite database, and a reverse proxy in front gives it HTTPS.

The image

Each release publishes an image to GitHub's container registry:

ghcr.io/jakubszwajka/hosti:latest    the newest release
ghcr.io/jakubszwajka/hosti:v0.1.0    one release, pinned

A merge to main builds no image and moves no tag. Only a release moves latest. The image runs Node as the node user, listens on port 3000 and declares /data as a volume. It already sets HOSTI_DATA_DIR=/data and PORT=3000.

If the package is private, the pull needs a GitHub token with read:packages, never your account password. Log in first with docker login ghcr.io -u YOUR_GITHUB_USERNAME, and keep the token out of the repository and out of .env.

Compose

The repository's docker-compose.yml is a working deployment: one service, web, and one named volume. Without its comments and its healthcheck it reads:

services:
  web:
    image: ghcr.io/jakubszwajka/hosti:latest
    pull_policy: always
    restart: unless-stopped
    environment:
      HOSTI_DATA_DIR: /data
      HOSTI_PUBLIC_URL: ${HOSTI_PUBLIC_URL:-http://localhost:3000}
      HOSTI_OWNER_PASSWORD_HASH: ${HOSTI_OWNER_PASSWORD_HASH:?set HOSTI_OWNER_PASSWORD_HASH in .env, make it with pnpm --silent owner:hash}
      HOSTI_SECRET: ${HOSTI_SECRET:?set HOSTI_SECRET in .env, generate with openssl rand -hex 32}
      PORT: "3000"
    expose:
      - "3000"
    volumes:
      - hosti_data:/data

volumes:
  hosti_data:

Put the settings in a .env file next to it:

HOSTI_OWNER_PASSWORD_HASH='scrypt:16384:8:1:<salt>:<key>'
HOSTI_SECRET='replace-with-output-from-openssl-rand-hex-32'
HOSTI_PUBLIC_URL=https://hosti.example

Hosti stores the owner password only as a scrypt hash. Make the hash on your own machine, from a clone of the repository, with pnpm --silent owner:hash. It asks for the password twice without showing it (at least 8 characters) and prints only the hash. The older plain HOSTI_OWNER_PASSWORD is ignored, so remove it. Make the secret with openssl rand -hex 32. Then start it:

docker compose up -d

Compose refuses to start while HOSTI_OWNER_PASSWORD_HASH or HOSTI_SECRET is missing. Because of pull_policy: always, every docker compose up -d asks the registry for the current latest. Pin a version tag such as :v0.1.0 to stay on one release.

Single-quote every secret. Compose reads $name in an unquoted value as a variable and puts its value in, usually nothing, so secr$tone arrives as secr. Double quotes do not protect a $. A password ending in # has also failed login when set unquoted. Single quotes cover both cases.

HTTPS and the reverse proxy

The service publishes no host port. It only exposes port 3000 to the Docker network. Point a reverse proxy on the same network, such as Traefik or Caddy, at service web on port 3000, and give it an HTTPS domain.

HOSTI_PUBLIC_URL must be that exact HTTPS origin, for example https://hosti.example. The approval link an agent prints is built from it.

First login

Open the domain and log in at /login with the password you hashed. Without the password hash and the secret, or with a hash that is not a well-formed scrypt hash, /login names the missing value and the catalog serves no admin page.

The session is a signed cookie, hosti_admin, good for 30 days. The login form takes five wrong passwords per caller per fifteen minutes, then locks that caller out for ten. The count lives in memory, so a restart clears it.

Next, connect an agent. The agents page in the docs nav walks through it.

Environment variables

The repository's .env.schema lists and types every variable Hosti reads. Only variables listed in the Compose service's environment reach the container, so add an optional one there as well as to .env.

HOSTI_OWNER_PASSWORD_HASH
A scrypt hash of the owner password for /login, made with pnpm owner:hash. Compose requires it. The older plain HOSTI_OWNER_PASSWORD is obsolete and ignored.
HOSTI_SECRET
Signs the admin session, the pin unlock cookie and the catalog's preview grants. Setting a pin needs it. Compose requires it. Rotating it ends every outstanding preview grant.
HOSTI_PUBLIC_URL
The exact public HTTPS origin. Compose defaults it to http://localhost:3000, but a deployment must set it.
HOSTI_KEEP_REVISIONS
How many revisions a push keeps per bundle. Defaults to 5. A value below 1 is read as 1, and anything that is not a number falls back to 5. The current revision is never deleted. Not in the Compose file by default.
HOSTI_DATA_DIR
Where bundles and hosti.db live. Defaults to ./data; the image sets /data.
PORT
The port the server listens on. 3000 in the image and in Compose.
APP_COMMIT
A build argument. /api/health reports its first 12 characters, or unknown when it is empty.

After you change a variable, recreate the container. A restart keeps the old environment. To see what the running container holds without printing a secret, run:

docker compose exec web node scripts/env-check.mjs

It prints lengths and digest prefixes, never a value.

Health

GET /api/health answers without login. The Compose healthcheck calls it inside the container every 10 seconds.

{"status":"ok","commit":"0123456789ab"}

The data volume

Everything Hosti keeps is under /data:

$HOSTI_DATA_DIR/
  hosti.db
  bundles/squad-2026/
    current -> r2
    r1/index.html
    r2/index.html

hosti.db holds the metadata, push token digests included. Each revision is a directory, and current is a symlink that a push flips only after the new revision unpacked and passed the entry-file check.

docker compose down keeps hosti_data. docker compose down -v removes it, and with it every bundle and the database.

Backups

A tar archive over /data captures both the bundles and the database. Take one before you touch the volume by hand. These docs do not give a backup schedule or a restore procedure.

Upgrades

With latest, docker compose up -d moves you to the newest release. There is no separate migration step: Hosti checks the schema version when it opens hosti.db and upgrades the file in place, forward only. An older image would then start against a newer schema, so going back means restoring a backup, not only running the older image. Take a fresh backup of /data before you move to a new release.

Build it yourself

The image is built from apps/web/Dockerfile with the repository root as the build context, because the Dockerfile needs the whole workspace. An image built without the APP_COMMIT build argument reports unknown as its commit on /api/health.