Commands
| Command | Does |
|---|---|
xtq-web |
Runs the server, configured by XTQW_CONFIG |
xtq-web check [file] |
Validates a config file and exits |
xtq-web --version |
Prints the version, like xtq-web 1.0.1. Also -V or version |
xtq-web --help |
Prints the usage. Also -h or help |
Any other argument is an error: unknown argument "x", see xtq-web --help.
xtq-web check
xtq-web check /etc/xtq-web/xtq-web.conf
Loads the file exactly as a start or a reload would, without opening any ports, and reports what it found:
/etc/xtq-web/xtq-web.conf: OK
listeners: 0.0.0.0:80,0.0.0.0:443(tls)
sites: example.com, api.example.com, *
proxies: 3 routes, 5 backends
php: 0
proxies counts proxy handlers and the backends they list. php counts php handlers.
Without a file argument it checks the file in XTQW_CONFIG. With neither, it fails with usage: xtq-web check <config file>, or set XTQW_CONFIG.
On a mistake it prints the error, with the file and line, and exits with a non-zero status:
Error: /etc/xtq-web/xtq-web.conf:9: proxy takes lb, health, health_interval, health_timeout, fail_duration, timeout, host, header_up, limit and max_body, not "retries"
The check also verifies things outside the file that a load needs, such as php docroots existing. Such errors name the line and then the cause:
Error: /etc/xtq-web/xtq-web.conf:5
Caused by:
0: docroot /var/www/blog
1: No such file or directory (os error 2)
IP list files that can't be read produce a warning, not a failure, just as at runtime. It does not need root, but it reads the files with your permissions, which may differ from the service user's.
Starting
At startup xTQ Web prints one line that sums up what it is doing:
xtq-web: config=/etc/xtq-web/xtq-web.conf listeners=0.0.0.0:80,0.0.0.0:443(tls) sites=example.com,* backends=2 cache=true max_conns=16384 max_handshakes=8 nofile=1048576
config=environment means XTQW_CONFIG was not set and xTQ Web is running in environment-only mode.
If a listen address is taken, xTQ Web does not fail. It retries every 100 ms and logs once:
xtq-web: can't listen on 0.0.0.0:80 yet (Address already in use (os error 98)), retrying every 100 ms
xtq-web: now listening on 0.0.0.0:80
That lets you start xTQ Web while the server it replaces is still running. Stop the old one and xTQ Web picks the port up within 100 ms. Other listeners work in the meantime.
Signals and reloads
| Signal | Effect |
|---|---|
SIGHUP |
Reloads the config |
SIGTERM, SIGINT (Ctrl-C) |
Stops, after letting open requests finish for up to XTQW_DRAIN_SECS seconds |
Reload
sudo systemctl reload xtq-web
Under the packaged service, reload runs xtq-web check first and sends SIGHUP only if the config passes. If the check fails, systemctl reload reports the failure and the running server is not touched.
Outside systemd, send the signal yourself:
sudo kill -HUP "$(pidof xtq-web)"
docker kill -s HUP xtq-web
On SIGHUP xTQ Web reads the config file again, and the files it names. If the new config loads, it logs:
xtq-web: reloaded /etc/xtq-web/xtq-web.conf: listeners=0.0.0.0:80,0.0.0.0:443(tls) sites=example.com,*
and from then on:
- New requests use the new sites and routes. Requests already in flight finish on the old ones.
- New listen addresses are opened. Listeners that are gone stop accepting (
stopped listening on ...). Listeners that stay are not touched, so nobody is disconnected. - Proxy pools start fresh: health checks run again at once, and passive failure marks, idle backend connections and in-flight counts start from zero.
- Certificate management restarts, so missing certificates are tried again at once.
- The page and file cache, issued certificates and open connections are kept.
- Without
XTQW_SECRET, browser check passes stop being valid.
If the new config has a mistake, it is refused and the running config keeps serving:
xtq-web: reload refused, still serving the previous config: /etc/xtq-web/xtq-web.conf:14: unknown directive "proxi"
Environment variables are not read again on reload. After changing one, restart.
Windows has no SIGHUP. There, restart to apply a change.
Stop
SIGTERM (what systemctl stop and docker stop send) and SIGINT stop the process gracefully:
- xTQ Web stops accepting new connections at once.
- Each open connection finishes the request it is working on. Keep-alive connections are closed after that request instead of waiting for another.
- It waits until every connection is done, or
XTQW_DRAIN_SECSseconds have passed (default 10), and exits.
While connections are open, it logs:
xtq-web: stopping, letting 12 open connection(s) finish (up to 10s)
With no open connections it exits at once. XTQW_DRAIN_SECS=0 exits at once in any case. WebSockets that were already handed off to their backend are not waited for, and are closed when the process exits. A long download that takes more than the drain time is cut.
The packaged unit gives the process 20 seconds to stop (TimeoutStopSec=20), and the Docker installer passes --stop-timeout 20, so the default drain fits. If you raise XTQW_DRAIN_SECS past that, raise those too, or systemd and Docker kill the process before it is done.
A restart therefore lets requests in flight finish, but new connections are refused from the moment the old process stops listening until the new one listens. For config changes, reload is still better: nothing is refused at all.
Logs
xTQ Web writes to standard error. Under systemd that ends up in the journal, in Docker in the container log:
journalctl -u xtq-web -f
docker logs -f xtq-web
xTQ Web does not write an access log. It logs events:
| Message | Meaning |
|---|---|
xtq-web: config=... listeners=... sites=... |
Startup summary |
xtq-web: no certificate configured, using a self-signed one for [...] |
No XTQW_CERT and XTQW_KEY, see HTTPS |
xtq-web: reloaded <file>: ... |
A reload succeeded |
xtq-web: reload refused, still serving the previous config: <error> |
A reload failed, nothing changed |
xtq-web: can't listen on <addr> yet (<error>), retrying every 100 ms |
A listen address is not available yet |
xtq-web: now listening on <addr> |
It became available |
xtq-web: stopped listening on <addr> |
A reload removed that listener |
xtq-web: stopping, letting <n> open connection(s) finish (up to <s>s) |
SIGTERM or SIGINT arrived while connections were open, see Stop |
xtq-web: <pool>: <backend> failed (<error>), out of rotation for <n>s |
Passive health check, see Reverse proxy |
xtq-web: <pool>: <backend> failed its health check |
Active health check failed |
xtq-web: <pool>: <backend> passes its health check again |
And recovered |
xtq-web: <pool>: under heavy load, browser check on (<n>% of its in-flight cap) |
challenge switched on |
xtq-web: <pool>: load back to normal, browser check off (<n>% of its in-flight cap) |
And off again |
xtq-web: WARNING: ip list ...; it matches no one until it can be read |
An @file could not be read at load |
xtq-web: <file>: can't resolve <hostname>: <error> |
A hostname in an IP list did not resolve |
xtq-web: acme: managing certificates for [...] |
Automatic HTTPS is on, for these names |
xtq-web: acme: certificate issued for <host> |
A certificate was issued or renewed |
xtq-web: acme: <host>: <error> |
Issuing failed, retried at the next check |
accept: <error> |
The operating system refused to hand over a new connection, often for lack of file descriptors |
Dashboard, stats and metrics
Three paths show what xTQ Web is doing:
| Path | Content |
|---|---|
/.xtq/dashboard |
A live page, updated every 2 seconds |
/.xtq/stats.json |
The same numbers as JSON |
/.xtq/metrics |
Prometheus text format |
They are off until you set XTQW_ADMIN_TOKEN to a secret of at least 16 characters. A shorter token leaves them off. Use letters and digits only, for example:
openssl rand -hex 32
Put it in /etc/xtq-web/env and restart:
XTQW_ADMIN_TOKEN=3f9c...
Pass the token as a token query parameter or in an X-Xtq-Token header:
https://example.com/.xtq/dashboard?token=3f9c...
curl -H "X-Xtq-Token: $TOKEN" https://example.com/.xtq/stats.json
curl "https://example.com/.xtq/metrics?token=$TOKEN"
Without the token, or with a wrong one, these paths answer 404 like any unknown path. They work on every listener and every hostname, before any site is chosen, so you can also reach them on 127.0.0.1. Use HTTPS when you open them from outside, since the token travels in the URL or a header.
The dashboard shows requests per second, cache hit rate, browser checks, busy answers, connections, TLS handshakes, backend errors, proxied requests and open WebSockets. Below that come tables of PHP sites, proxy backends with their state and requests in flight, and issued certificates with the days they have left.
stats.json
| Field | Meaning |
|---|---|
time |
Unix time of the snapshot |
requests |
Requests received since start |
cache.hit, cache.stale, cache.miss |
PHP page cache results |
static_files |
Files up to 8 MB served by files and PHP docroots |
browser_checks.shown, browser_checks.passed |
Browser check pages shown and solved |
busy_answers.edge_full |
503 Server busy answers from pool limits, the WebSocket cap and the PHP waiting room |
busy_answers.fair_share |
503 answers from the PHP per-address limit |
upstream_errors |
502 and 504 answers, and PHP errors |
proxied |
Requests handed to proxy handlers, WebSockets included |
probes |
Scanner probes answered |
out_of_ports |
Backend connections refused by this machine for lack of ports or descriptors |
websockets |
WebSockets open now |
connections.open, connections.max |
Client connections open now, and the cap |
handshakes.active, handshakes.max |
TLS handshakes running now, and the cap |
sites[] |
Per php handler: name, php_up, busy, workers, queue, queue_cap, waiting_at_edge, overloaded, browser_check |
upstreams[] |
Per proxy backend: route (the pool name), server, up, in_flight |
certificates[] |
Per issued certificate: host, days_left |
Counters count since the process started. A reload does not reset them.
Prometheus
| Metric | Labels |
|---|---|
xtq_requests_total |
|
xtq_cache_total |
result = hit, stale, miss |
xtq_static_files_total |
|
xtq_browser_checks_shown_total |
|
xtq_browser_checks_passed_total |
|
xtq_busy_answers_total |
reason = edge_full, fair_share |
xtq_upstream_errors_total |
|
xtq_proxied_total |
|
xtq_probes_total |
|
xtq_out_of_ports_total |
|
xtq_connections_open |
|
xtq_handshakes_active |
|
xtq_websockets_open |
|
xtq_upstream_up |
route, server. 1 when usable |
xtq_upstream_in_flight |
route, server |
xtq_php_busy, xtq_php_workers, xtq_php_queue, xtq_edge_waiting, xtq_overloaded |
site |
xtq_certificate_days_left |
host |
A scrape job with the token as a parameter:
scrape_configs:
- job_name: xtq-web
metrics_path: /.xtq/metrics
params:
token: ["3f9c..."]
static_configs:
- targets: ["127.0.0.1:80"]
Server and X-Powered-By
Every response gets Server: xtq-web and X-Powered-By: xTQ, replacing whatever the app sent. That also hides version banners like Next.js or PHP/8.3. A header line in a route can't change these two, because they are set last.
| Variable | Default | off |
|---|---|---|
XTQW_SERVER_HEADER |
xtq-web |
Leaves the app's own Server header as it is, or none |
XTQW_POWERED_BY |
xTQ |
Leaves the app's own X-Powered-By as it is, or none |
Any other value is sent as the header. With off, header lines can set or remove these headers like any other.
The X-Xtq-Cache header
Some responses say where they came from:
| Value | Meaning |
|---|---|
HIT |
From memory. A cached page or file |
STALE |
A PHP page past its fresh time, while a new copy renders |
MISS |
Rendered or read now, and stored |
BYPASS |
Rendered or read now, not stored |
CHALLENGE |
The browser check page |
FAIR-SHARE |
Refused by the PHP per-address limit |
EDGE-FULL |
Refused by the PHP waiting room |
Reserved paths
| Path | Belongs to |
|---|---|
/.well-known/acme-challenge/<token> |
Answered by xTQ Web while that token is pending with Let's Encrypt. Other tokens go to your site as usual |
/.xtq/ and everything under it |
xTQ Web on every site: the dashboard paths, /.xtq/verify for the browser check, 404 for the rest |
