XTQ/Web/DocsDownload

Config file

The config language, the top-level directives, and how sites and routes are matched.

xTQ Web reads the file named in the XTQW_CONFIG environment variable. The packaged service uses /etc/xtq-web/xtq-web.conf. Check a file at any time with xtq-web check <file>. Errors name the file and the line, like /etc/xtq-web/xtq-web.conf:12: rewrite target must start with /.

If XTQW_CONFIG is not set, xTQ Web runs in an older environment-only mode meant for xTQ PHP. See Environment variables.

A complete example:

# Proxies in front of us that may name the client.
trust 127.0.0.1 ::1

bind 0.0.0.0:80
https 0.0.0.0:443

site example.com www.example.com {
    route /api/* {
        strip
        proxy 127.0.0.1:3000 127.0.0.1:3001 {
            health /health
        }
    }
    files /var/www/example {
        not_found /index.html
    }
}

site * {
    respond 404 "unknown site"
}

Syntax

A directive is a name followed by its arguments, separated by spaces or tabs. One directive per line. The end of the line ends the directive.

A directive can open a block with { at the end of its line. The block holds more directives, one per line, and ends with }. Blocks nest.

site example.com {
    proxy 127.0.0.1:3000 {
        health /health
    }
}

A block that holds a single directive can sit on one line:

proxy 127.0.0.1:3000 { health /health }

Braces

{ and } are block delimiters only when they stand alone as a word, separated from everything else by whitespace. Inside a word they are ordinary characters. That is how placeholders work:

redirect https://example.com{uri} 301

Here https://example.com{uri} is one argument. A { that does not follow a directive on the same line is an error ({ must follow a directive on the same line), and so is a } without a matching {.

Comments

# starts a comment that runs to the end of the line, when it begins a word. A # inside a word is part of the word, so https://example.com/#top stays whole.

# A whole-line comment.
bind 0.0.0.0:80   # A comment after a directive.

Quoting

Put an argument in double quotes when it contains spaces or a #:

respond 503 "Down for maintenance, back soon."
header X-Robots-Tag "noindex, nofollow"

Inside quotes, \n is a newline and a backslash before any other character keeps that character, so \" is a quote and \\ is a backslash. A quoted string may span lines. A quote that is never closed is an error (unterminated quote).

Durations

Durations are a whole number followed by a unit:

Written Means
500ms 500 milliseconds
10s 10 seconds
2m 2 minutes
1h 1 hour
7 7 seconds (a bare number is seconds)

Fractions and combinations such as 1.5s or 1m30s are not accepted. Write 90s.

Addresses

Listener addresses are an IP address and a port: 0.0.0.0:80, 127.0.0.1:8081, [::1]:80, [::]:443. Hostnames are not accepted for listeners.

Top-level directives

Only four directives are allowed outside a site block: trust, bind, https and site. Anything else is an error. A config needs at least one site.

bind

bind <address>...

The listeners every site uses unless it names its own. The default is 0.0.0.0:80. If you write bind more than once, the last one counts.

bind 0.0.0.0:80 [::1]:80

https

https <address>...

Listeners that speak TLS. Each address here is also added to the default listeners, so sites without their own bind answer on it too. Any site that listens on one of these addresses speaks TLS there. Certificates are covered in HTTPS. The last https line counts.

bind 0.0.0.0:80
https 0.0.0.0:443

trust

trust <address|cidr|cloudflare>...

Proxies in front of xTQ Web that may name the real client in X-Forwarded-For. The word cloudflare adds Cloudflare's published edge ranges. Without a trust line, only 127.0.0.1 and ::1 are trusted.

A trust line replaces that default. If you write trust cloudflare alone, loopback is no longer trusted. Write trust 127.0.0.1 ::1 cloudflare to keep both. If you write trust more than once, the last one counts.

How the client address is worked out is in HTTPS.

site

site <hostname>... {
    ...
}

A site answers for one or more hostnames. The first hostname is the site's name in logs and on the dashboard. Hostnames can be:

  • an exact name: example.com, api.example.com, or an IP address,
  • a wildcard: *.example.com, with the * only as the whole leftmost label,
  • *: every hostname no other site on that listener answers.

Hostnames are lowercased and a trailing dot is dropped. Write the hostname alone, without http://, a port or a path (write just the hostname; listeners are set with bind).

A site needs a block. The block holds routes and at most one handler. A site with neither is an error.

A bind directly inside a site block replaces the default listeners for that site:

site admin.example.com {
    bind 127.0.0.1:8080
    proxy 127.0.0.1:9000
}

That site answers only on 127.0.0.1:8080. Addresses from the top-level https line are not added to it, but if it binds an address listed in https, it speaks TLS there.

probes off directly inside a site block lets scanner-looking paths such as /.env or /actuator/health reach that site's routes, instead of the short 404 every site gives them by default. See Protection.

The same hostname can appear in two sites as long as they listen on different addresses. The same hostname twice on the same listener is an error: example.com on 0.0.0.0:80 is already served by the site at line 4.

How a site is chosen

Every request arrives on a listener and carries a hostname, from the Host header or, in HTTP/2, the :authority. The hostname is lowercased, its port is removed and a trailing dot is dropped. Then:

  • Only sites that listen on that listener are considered.
  • An exact hostname match wins first.
  • Otherwise the longest matching wildcard wins. *.example.com matches a.example.com and a.b.example.com, but not example.com itself. If both *.example.com and *.eu.example.com match, the longer one wins.
  • Otherwise a * site answers.
  • If nothing matches, the answer is 404 with the body Not Found.

What happens to a request

In this order:

  • A pending Let's Encrypt challenge at /.well-known/acme-challenge/<token> is answered, on any listener, for any hostname.
  • /.xtq/dashboard, /.xtq/stats.json and /.xtq/metrics are answered when the admin token is right. See Operations.
  • The site is chosen. A hostname no site answers gets 404 Not Found.
  • Scanner probes such as /.env or /../etc/passwd get a 404, unless the site has probes off. See Protection.
  • The route is chosen, applying strip and rewrite along the way.
  • Any other path under /.xtq/ answers 404. The whole /.xtq/ prefix belongs to xTQ Web on every site, so your apps can't use it.
  • The browser check runs, if a challenge covers the route.
  • auth checks run, outermost first.
  • The handler answers.
  • header lines are applied to the response, and the Server and X-Powered-By headers are set.

How routes are matched

Routes are written inside a site block, or inside another route:

route <pattern> {
    ...
}

The pattern is one of:

Pattern Matches
/about Exactly /about. Not /about/, not /about/team
/api/* Everything under /api/: /api/, /api/users, /api/a/b. Not /api
/api* Everything that starts with /api: /api, /api/, /api/users, also /apis and /api-docs
* Every path

A * is allowed only at the end. Patterns must start with / (or be *) and must be ASCII. Write other characters percent-encoded.

Matching ignores case, and works on a cleaned-up copy of the path: percent escapes are decoded, . and .. segments are resolved, and repeated slashes are collapsed. So /API/Users, /api//users and /api/./users all match /api/*. Paths with .. in them are answered as scanner probes before routing, unless the site has probes off. A trailing slash is kept, which is why /about does not match /about/. The query string plays no part in matching.

Routes are tried in the order they are written. The first one that matches is the only one considered at that level. A route with ip or not ip matches only for those clients, so a route whose address check fails is skipped and the next route is tried.

Inside the matching route the same happens again with its own nested routes. Nested routes match against the path as it is after the parent's strip and rewrite:

site example.com {
    route /docs/* {
        strip
        route /old/* {
            redirect /docs/new/ 301
        }
        files /var/www/docs
    }
    proxy 127.0.0.1:3000
}

/docs/old/page is stripped to /old/page, which matches the nested /old/*.

When a route has no answer

A block answers with its first matching nested route if that route can answer, and otherwise with its own handler. If a route matches but has neither a handler nor a nested route that answers, it backs out. Its strip, rewrite, header, auth and compress are undone, and the enclosing block's handler answers instead. Later sibling routes are not tried.

site example.com {
    route /a/* {
        header X-Area a
    }
    respond 200 "site handler"
}

/a/x is answered by the site's respond, without the X-Area header.

If no route matches and the block has no handler, the answer is 404 Not Found.

What nested routes inherit

Setting Inside nested routes
strip, rewrite The changed path is what nested routes see and what the handler gets
header Accumulates. Outer lines are applied first, so an inner line for the same header wins
auth Accumulates. Every auth on the way down runs, outermost first
compress The nearest one counts
challenge The nearest one counts

A site block itself behaves like a route that matches everything. rewrite, header, auth, compress and challenge can go directly in a site block and then apply to every request of that site. strip and ip only work inside a route, and bind and probes only directly in a site.

Handlers

Each block has at most one handler: proxy, files, php, redirect, respond or abort. A second one is an error (this block already has a handler; put the other one in a route). See Handlers.

Placeholders

redirect targets can contain placeholders. They are not expanded anywhere else.

Placeholder Expands to For https://Example.com:8443/a/b?x=1
{uri} Path and query string /a/b?x=1
{path} Path only /a/b
{query} Query string without the ?, empty if there is none x=1
{host} The request's hostname, lowercased, without the port example.com

{path} and {uri} are the path after any strip or rewrite in the routes above the redirect, still percent-encoded as the client sent it.

site www.example.com {
    redirect https://example.com{uri} 301
}

Reloading

Send SIGHUP, or run systemctl reload xtq-web, to load the file again. New requests use the new config at once. Connections already open finish what they are doing. If the new file has a mistake, it is refused and the running config keeps serving. Details are in Operations.