Guide
Hosting
A Quillo program in production is one process, quillo run main.qwe, answering HTTP on a
port. Hosting it takes four things: a machine that can keep a process running, a proxy in
front for HTTPS, a few environment variables, and a database on a disk that lasts. This
page goes through each, with a checklist at the end.
What the server needs
The quillo binary | One file of 20 to 25 MB, nothing else: SQLite and the time zone database are built in (the system's copy of the zones is read first, and the built-in one for a name it leaves out, so US/Pacific and EST work on Ubuntu 24.04, which moved them to its tzdata-legacy package), and TLS is rustls, not OpenSSL. The Linux release builds from 0.7.0 on are linked against glibc 2.17, so they start on CentOS 7, CloudLinux 7 and 8, AlmaLinux 8 and anything newer (0.6.0's need glibc 2.35). They are glibc builds, not static ones, since a plugin is a library the binary loads. Build it on (or for) the same system and CPU as the server: a Linux binary built on a newer distribution than the server's may not start there |
| The program's files | main.qwe and the files it uses, migrations/, public/ |
| Rust files, built | A program with .qwer files builds them the first time it starts, which needs Rust. Run quillo build main.qwe beforehand on the same kind of machine, and deploy .quillo/ with the program: .quillo/native/ and .quillo/plugins.lock, each library's SHA-256. The server then needs no Rust. With --no-build it never builds: a library the lock does not name is refused rather than built again |
| A long-running process | Any VPS, cloud VM or container service. On shared hosting with mod_fcgid (cPanel), the web server starts it: see Shared hosting. Hosting that runs only PHP cannot run it; see static hosting |
Quillo needs no Node, no package install and no build step on the server.
Running it
quillo run main.qwe --port 8080 --log jsonThe words after the program's file are options of quillo run, given when the program
is started. The table has the ones that matter in production; the
options lists every one, --watch and --split included.
| Option | In production |
|---|---|
--port N | the port, 0 to 65535 (default $PORT, then 8080); 0 takes any free port |
--listen unix:PATH | a Unix socket instead of a TCP port, for a web server on the same machine: mode 0600 (--socket-mode 660 for the web server's group), removed when the server stops, at most 100 bytes. Give --trust-proxy 127.0.0.1 with it |
--no-build | never build a .qwer file on the server: load only what quillo build made |
--policy FILE | narrow what the program may do (see the policy) |
--host H | the address to listen on. The default, 127.0.0.1, takes connections from this machine only, which is right behind a proxy on the same machine; localhost is 127.0.0.1 too. 0.0.0.0 in a container |
--instances N | the program N times over, a core each (see several cores) |
--log json | a JSON line per request, for log collectors |
--reload | reload when the program's files change, without dropping requests (see new versions); with --instances, each instance loads the change, and --split passes it to its services |
--max-body 50MB | the largest request body (default 10 MiB): raise it for big uploads. Past its first 30 s a body must keep coming at 1 KiB/s (since 0.7), and one that sends nothing for 30 s is answered 408, with a line in the log. A gzipped body (Content-Encoding: gzip) is decoded, and the limit counts what it decodes to |
--max-connections N | connections open at once (default: what the file descriptors allow, less 4,608); past it one more is closed as it comes (since 0.7) |
--connections-per-client N | connections one client (an address, or an IPv6 /64) may hold open (default 512); this machine and --trust-proxy's proxies are not held to it (since 0.7) |
--base /shop | when the program is served under a path rather than at the root: its links, redirects, scripts and cookies carry the prefix, and the proxy strips it from requests (Quillo routes the path it is given) |
--metrics | answer /_quillo/metrics (keep it private: see below) |
A program learns where it listens from platform().server: { host, port, url, base },
with the port it got under --port 0, and a url this machine can reach (0.0.0.0
becomes 127.0.0.1). It is null in a script and while the program loads.
route GET /about { let s = platform().server <p>Listening at {s.url}{s.base}</p>}Never --watch in production (quillo run main.qwe --watch is for your own machine,
reloading the program as it is edited): it shows errors on pages, refreshes them, and leaves
Secure off cookies, since it expects plain HTTP on your own machine. Under --watch the
server answers only localhost, addresses and the names --allowed-hosts gives
(myapp.test, .dev.test for its subdomains too), and 421 to any other name (since
0.7): a page elsewhere whose name was made to point at your machine (DNS rebinding)
cannot call it.
Environment
| Variable | What for |
|---|---|
QUILLO_SECRET | the key that signs sessions. Set it: without it a key is made at start, and every restart signs everyone out. A long random string (crypto.token() makes one), the same on every server |
PORT | the port, when --port is not given |
DATABASE_URL, or any name | read with env("DATABASE_URL") in the program |
QUILLO_DB | the database when the program does not call db.open |
QUILLO_SYSTEM_DB | where Quillo keeps its own tables when each tenant has a database |
QUILLO_SERVICE_SECRET | signs calls between services split across servers: the same on all of them; new,old (or QUILLO_SERVICE_SECRET_PREVIOUS) while it is rotated: the first signs, any is taken |
QUILLO_SERVICE_KEY | a service's own key, which a copy joins the app with and which proves it is that service (see authentication between services) |
QUILLO_SERVICE_<NAME> | where service NAME runs, as quillo run main.qwe --service-url NAME=URL says |
LIMER_ADMIN_SECRET | for quillo run main.qwe --register URL, joining limer's pool |
PUBLIC_... | variables components may read, written into the pages' scripts |
Keep them out of the program's files: in the service manager's environment file, or the
container platform's secrets. quillo run also reads a .env beside the program (or the file
quillo run main.qwe --env-file prod.env names), handy on your own machine; a variable the environment has wins over it,
so a .env left on a server cannot override production's. Only a .env that is not
there is nothing: one that cannot be read, is not UTF-8, or is a link to nothing stops
the start with exit 2, saying where and the ways out (save it as UTF-8, remove it, or
give --env-file /dev/null). A line that is not KEY=value is skipped and said at the
start; KEY= # set me is "", while KEY=#fff is #fff. The one other
configuration is a policy, which only takes away: /etc/quillo/policy.toml,
--policy FILE and quillo.policy beside the program (see the
policy).
The database
db.open("data/app.db") | unwrap // SQLite, in a folder that lastsdb.open(env("DATABASE_URL")) | unwrap // PostgreSQL or MySQLdb.migrate("migrations")- End
db.openwith| unwrap. When a database cannot be opened,db.opengives an error value and the program goes on with SQLite in memory, empty: fine while trying things out, a loss of data in production. Withunwrap, the program stops and says why. A program that never callsdb.open(and has noQUILLO_DB) is on SQLite in memory too, and loses it all at each restart. - SQLite lives in a file, so it must be on a disk that outlasts the process: a
volume, in a container. The folder must exist (
mkdir -p data); a path is counted from the main file's folder. - Back SQLite up with its own command, not by copying the file while it runs:
sqlite3 data/app.db ".backup backup.db". - Migrations run when the program starts, each once, in order.
:memory:is one database per instance: with--instances, use a file or PostgreSQL.
HTTPS: a proxy in front
Quillo answers plain HTTP/1.1: it has no HTTPS or HTTP/2 of its own. A proxy in front
gets the certificate, answers HTTPS, and passes requests on. Session cookies are
Secure and named __Host-quillo_session (unless --watch), so the site must be served
over HTTPS: a browser sends them to http://localhost, but curl and other cookie jars
do not, so test those under --watch or through the proxy.
With Caddy, which gets and renews the certificate itself and passes WebSockets as they are:
example.com { reverse_proxy 127.0.0.1:8080}With nginx, add what WebSockets need (live data and channels use them) and the host name (tenants are told apart by it):
map $http_upgrade $connection_upgrade { default upgrade; '' close;}server { listen 443 ssl; server_name example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 1h; }}- The visitor's address. Behind a proxy,
request.ipis the proxy's, and every visitor counts as one: only 256 pages then get live updates.--trust-proxy 127.0.0.1names the proxy, andrequest.ipand each client's limits are the visitor's, from theX-Forwarded-Forit adds. It is read from no other address, as a client can write it. - Caching at the proxy or a CDN. A page made from who the visitor is (it called
session()orcookie(), or set a cookie) is sent withCache-Control: privateandVary: Cookie, unless the route set its owncache-control, so a shared cache does not give one visitor's page to another. Every HTML page getsX-Frame-Options: SAMEORIGIN, unless the route sets one, or aContent-Security-Policywithframe-ancestors. - Headers for the whole site.
site_headers, at the top of the program, sets headers on every answer that does not set them itself, or on those under a path; a longer path's rule wins, and a null takes away one Quillo sends:
site_headers({ "content-security-policy": "default-src 'self'" })site_headers("/embed", { "x-frame-options": null })- What
site_headerstakes. Names are checked and values are one line.content-length,transfer-encoding,connection,set-cookieandcontent-typeare refused, and so is alocationthat is ajavascript:,vbscript:ordata:URL. - WebTransport (channels over HTTP/3) goes over UDP, which these proxies do not pass.
Open the UDP port, the HTTP port's number unless
quillo run main.qwe --wt-port 4433says another, to the server itself: Quillo presents a certificate of its own, which pages take by its hash, or yours with--tls-cert cert.pem --tls-key key.pem(the two go together, and must be a pair: a key that is not the certificate's is refused). Otherwise pages use a WebSocket, which works through the proxy;quillo run main.qwe --no-wtturns WebTransport off.
Keeping it running
With systemd, /etc/systemd/system/app.service:
[Unit]Description=My Quillo appAfter=network.target[Service]User=quilloWorkingDirectory=/srv/appExecStart=/usr/local/bin/quillo run main.qwe --port 8080 --log jsonEnvironmentFile=/etc/quillo/app.envRestart=on-failureTimeoutStopSec=15[Install]WantedBy=multi-user.target/etc/quillo/app.env holds the environment (QUILLO_SECRET=...), readable by root only
(chmod 600). Then systemctl enable --now app starts it now and at every boot, and
journalctl -u app -f follows its log.
On SIGTERM (what systemctl stop and docker stop send), Quillo stops taking
connections, answers the requests in progress for up to 10 seconds, and exits with status
0; give the service manager a little longer than that, as TimeoutStopSec=15 does.
In a container
A Dockerfile that builds quillo from its source, then runs the program in a small image
as a user of its own, with the database on a volume:
FROM rust:1-bookworm AS quilloRUN cargo install --locked --git https://github.com/quillolang/quillo quilloFROM debian:bookworm-slimCOPY --from=quillo /usr/local/cargo/bin/quillo /usr/local/bin/quilloRUN useradd --system --home /app quillo \ && mkdir -p /app/data && chown quillo /app/dataWORKDIR /appCOPY app/ .USER quilloVOLUME /app/dataEXPOSE 8080CMD ["quillo", "run", "main.qwe", "--host", "0.0.0.0", \ "--port", "8080", "--log", "json"]docker build -t my-app .docker run -d --name my-app -p 127.0.0.1:8080:8080 -v my-app-data:/app/data \ -e QUILLO_SECRET=... --stop-timeout 15 my-appThe program opens its database at data/app.db, on the volume, so it outlasts the
container. The first build takes a few minutes (about two on a laptop); later ones reuse
the quillo stage while the Dockerfile's first two lines stay the same. The image is
about 170 MB, most of it Debian; quillo needs only the C library from it.
New versions
- The program's files. With
--reload, copying new files over the old ones reloads the program: requests in progress finish on the old version, later ones get the new one, and none is dropped. A version that fails to load or check does not replace the one running; the errors are in the log. With--instances, each instance loads the change, the first checking it, so a version that does not check reaches none. Copy all files at once (rsync -a --delay-updates), so it does not load half of them. Global variables start afresh; sessions, being signed cookies, stay valid. - A new
quillo. Replace the binary and restart the service. Requests in progress finish first; new ones are refused for the moment it takes to start again (there is no handing the port from one process to the next). With several servers behind a load balancer, restart them one at a time. A newquillowhose glue changed (the libraries' names carry its version) loads none of the libraries an older one built: runquillo build main.qwewith the new binary as part of the deploy that brings it, and deploy.quillo/again. Under--no-buildthe server says which file wasbuilt by an older Quillo. The older libraries are kept, so going back to the old binary still runs. - What a restart keeps. The database,
quillo-system.dbbeside the program, and sessions (withQUILLO_SECRET). Global variables, channels and what is cached in memory start empty.
Several cores, several servers
--instances Nruns the program N times in one process, a core each. Each has its own variables; the database is shared, and its changes reach live pages on all of them.- Several servers share a database (PostgreSQL or MySQL) and sit behind a load
balancer. With limer,
--registerjoins its pool, so a change made through one server wakes live pages on all of them (see Live data). Use the sameQUILLO_SECRETon all. - Scheduled jobs run once per due time across servers on one database;
quillo run main.qwe --no-jobsturns them off on a server.
Health, logs and metrics
GET /_quillo/healthanswers200while the server takes requests, and503while it stops or when its program failed to load: give it to the load balancer.- A health check that sends
OPTIONS / HTTP/1.0, as HAProxy'soption httpchkdoes unless told otherwise, gets204withAllowon any path a route has;/_quillo/healthis still the one to name. HTTP/1.0 clients (ab, some monitors) get no chunked answers and no100 Continue. GET /_quillo/readyalso says whether what the program needs is there (its database, its services, checks of its own), 200 or 503: give it to an orchestrator's readiness probe. Each part, as JSON, is said only with the health token (Authorization: BearerandQUILLO_HEALTH_TOKEN); anyone else gets{"ready": ...}alone (since 0.7).--log jsonwrites a JSON line per request (time, method, path with its query, status, ms, ip, tenant, andrid, the request's id) on stderr, where systemd's journal or the container's log collects it. A malformed request is an object of its own,{"time", "status", "error", "line"}.ipis the proxy's, behind one, unless--trust-proxynames it.- Quillo's own notices (
listening on, warnings,stopping) and errors are text lines on stderr (quillo: ...), with--log jsonor without. In a text line, line breaks and control characters inside an error's text are written as\nand\x1b, so one error is one line. --metricsanswersGET /_quillo/metricsin Prometheus's text format. Anyone who can reach it can read it, so keep it from the public at the proxy: in nginx,location /_quillo/metrics { deny all; }; in Caddy,respond /_quillo/metrics 403.quillo run main.qwe --describeanswersGET /_quillo/describewith the program's API. It is public too: it lists every route, those no page links to as well, eachserver fn's signature, the file and line of every route, and the version. Keep it private as/_quillo/metricsis, with the same rules at the proxy, or leave it off in production.
Static hosting
Quillo can be the static host too. quillo serve site serves a folder of files as they
are, with the options of quillo run (--port, --host, --log): a folder's
index.html for / and /docs/, /about for about.html, /docs redirected to
/docs/, the folder's 404.html for a miss (sent no-cache), and no file whose name
starts with a dot (but .well-known/), no program source and no SQLite database,
whatever its name. A file asked for with a / after it (/about/) is moved to its path
without one (301), when it would be served there. Inside a program, route GET /*path = files("site", path) does the same beside its other routes, but serves /about/ as
/about, since a program's URLs are its own. Each file goes out as it is on disk, with
its ETag, Last-Modified, ranges and gzip.
quillo serve dist --port 8080 --host 0.0.0.0The other way round, a site whose components call nothing on the server (no server fn, database, sessions or
live data) can be rendered to files and put on any static host, shared hosting included.
This site is one: its export script
runs it, fetches every page its sitemap lists and the component bundles they load, and
writes them to a folder that is uploaded as it is.
Before going live
quillobuilt for the server's system;.qwerfiles built withquillo build, and.quillo/plugins.lockdeployed with them (and--no-build)QUILLO_SECRETset, the same on every serverdb.open(...) | unwrap, and SQLite's folder on a disk that lasts, with backups- HTTPS from a proxy, which passes WebSockets and the
Hostheader --host 127.0.0.1behind a proxy on the same machine; not--watch- a service manager that restarts it, and waits 15 seconds when stopping it
/_quillo/healthfor the load balancer,/_quillo/readyfor readiness;/_quillo/metricsand/_quillo/describekept private, or offquillo run main.qwe --max-compute 2s, so one request that computes for ever cannot hold an instance, and limits of your own where you need them: Quillo has no rate limiting
Thread 0 posts
On Hosting: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.