qhost
qhost publishes a directory of static files — a built site, a rendered report, a coverage tree — at a URL that requires access and expires on a schedule. The operator runs one container, points a DNS record at their own reverse proxy, and manages shares from the command line. It is access-controlled hosting for a single trusted publisher per instance; it does not isolate mutually distrusting shares from one another.
Self-host the server
qhostd ships as ghcr.io/janfasnacht/qhost-server. Drop a
docker-compose.yml and .env somewhere on your box and start it:
# docker-compose.yml
services:
qhostd:
image: ghcr.io/janfasnacht/qhost-server:0.1.0
restart: unless-stopped
environment:
QHOST_DOMAIN: ${QHOST_DOMAIN:?}
QHOST_TRUSTED_PROXY: ${QHOST_TRUSTED_PROXY:?}
QHOST_TOKEN: ${QHOST_TOKEN:-}
volumes:
- qhost_data:/data
ports:
- "127.0.0.1:8000:8000"
volumes:
qhost_data:
# .env
QHOST_DOMAIN=share.example.com
QHOST_TRUSTED_PROXY=127.0.0.1 # whatever IP / CIDR your proxy connects from
QHOST_TOKEN= # leave blank to auto-generate on first start
docker compose up -d
docker compose exec qhostd cat /data/operator-token # if you left it blank
qhostd is now on http://127.0.0.1:8000 of the host, ready to be
proxied. The full env-var surface is in
deploy/.env.example and the canonical compose
is in deploy/docker-compose.yml.
Wire a reverse proxy
qhostd doesn't terminate TLS, doesn't strip incoming X-Forwarded-For,
and doesn't enforce a body-size cap before the request reaches Python.
A proxy in front of it has to do those three things, and qhostd has to
trust that proxy via QHOST_TRUSTED_PROXY (an IP or CIDR — see above).
Full ready-to-edit examples are in deploy/proxies/.
The interesting line in each:
Caddy (deploy/proxies/Caddyfile)
{$QHOST_DOMAIN} {
reverse_proxy 127.0.0.1:8000 {
header_up X-Forwarded-For {http.request.remote.host} # overwrite, not append
}
request_body { max_size 110MB }
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
nginx (deploy/proxies/nginx.conf)
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, not append
client_max_body_size 110m;
Cloudflare Tunnel (deploy/proxies/cloudflared.md)
ingress:
- hostname: share.example.com
service: http://127.0.0.1:8000
cloudflared connects from loopback, so QHOST_TRUSTED_PROXY=127.0.0.1
is what you want.
Install the CLI
pip install qhost
qhost login https://share.example.com
qhost login prompts for the operator token and writes it to
~/.config/qhost/config.toml (mode 0600).
Publish
cd path/to/built/site && qhost publish
qhost publish from a directory containing index.html publishes that
directory as-is. From a Quarto project (_quarto.yml present) it runs
quarto render first. Otherwise it refuses; pass --dir <path> to
point at a built output or --build <name> to force a specific builder.
qhost --help covers the rest: publish, ls, info, extend, rm,
open, doctor. Default expiry is seven days. --password gates a
share behind HTTP Basic with a generated password; --public makes a
guessable URL with no secret; the default link mode treats the URL
itself as the credential — /s/<slug>/ carries ~80 bits of entropy and
is not enumerable.
qhost publish --sandbox serves a share under
Content-Security-Policy: sandbox allow-scripts, the only
intra-instance isolation primitive. qhost doctor flags content that
probably wants it.
Trust model
One operator token authorizes all publishing on an instance; every visitor is untrusted. All shares serve from the same browser origin, so a share's JavaScript can reach any other share on the same instance with the visitor's ambient Basic-auth credentials. For mutually distrusting publishers, run separate instances on separate origins.
Configuration: deploy/.env.example. Reverse
proxy examples: deploy/proxies/. Vulnerability
reports: SECURITY.md. MIT.
Metadata
Release files for qhost 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| qhost-0.1.0.tar.gz | 69.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| qhost-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 151.5 kB
Release files / qhost-0.1.0.tar.gz
| Download URL | qhost-0.1.0.tar.gz |
|---|---|
| Size | 69.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
62992d974b73afe75be4eda6f45450bb0e2b67847cc8296199fecd60caaa738a
|
|
BLAKE2b-256 checksum How to use checksums |
9da3a127736d2cf2b3a0cf8a8c70990df96ad4063c14b945a867bfe8c2592c52
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 24, 2026.
Transparency logRelease files / qhost-0.1.0-py3-none-any.whl
| Download URL | qhost-0.1.0-py3-none-any.whl |
|---|---|
| Size | 81.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
09114fbf82c80b4b01e5a1458da31c81ecfa3f3cbf0ee466d5b7d971efb339b7
|
|
BLAKE2b-256 checksum How to use checksums |
db17b0645a32117bfda5d4a3e933bdd443d64f0a8d933cd4d005f8ceb537eecd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 24, 2026.
Transparency log