XTQ/Web/DocsDownload

Examples

Copy-paste configs for common setups, from a single reverse proxy to PHP sites and maintenance pages.

Each example is a complete config unless it says otherwise. Check it with xtq-web check <file> and load it with systemctl reload xtq-web.

A single reverse proxy

One app on port 3000, for one domain and its www:

bind 0.0.0.0:80

site example.com www.example.com {
    proxy 127.0.0.1:3000
}

The app gets the client's Host, plus X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host.

The same with HTTPS

Certificates from Let's Encrypt, with plain HTTP redirected to HTTPS. Set XTQW_ACME=1 in the service environment.

bind 0.0.0.0:80
https 0.0.0.0:443

site example.com www.example.com {
    bind 0.0.0.0:80
    redirect https://{host}{uri} 301
}

site example.com www.example.com {
    bind 0.0.0.0:443
    proxy 127.0.0.1:3000
}

API and single-page app on one domain

The API on port 3000 under /api, the built frontend from disk, every other path loading the app:

bind 0.0.0.0:80

site app.example.com {
    route /api/* {
        proxy 127.0.0.1:3000
    }
    route /assets/* {
        header Cache-Control "public, max-age=31536000, immutable"
        files /var/www/app/dist
    }
    files /var/www/app/dist {
        not_found /index.html
    }
}

This assumes the API's own routes start with /api, as in app.get('/api/users'). If the API serves /users at its root instead, add strip:

    route /api/* {
        strip
        proxy 127.0.0.1:3000
    }

With strip, /api/users reaches the API as /users. Without it, as /api/users. Pick the one that matches your API, see strip.

Deep links like /settings get index.html with status 404, which browsers render normally. A missing file under /assets/ gets a plain 404, so a broken asset link is not answered with HTML.

Several sites on one server

bind 0.0.0.0:80
https 0.0.0.0:443

site example.com www.example.com {
    proxy 127.0.0.1:3000
}

site api.example.com {
    proxy 127.0.0.1:4000
}

site docs.example.com {
    files /var/www/docs
}

# Any other subdomain.
site *.example.com {
    respond 404 "no such app"
}

# Hostnames that are not ours: close the connection.
site * {
    abort
}

With XTQW_ACME=1, certificates are issued for example.com, www.example.com, api.example.com and docs.example.com. The wildcard and * sites get the fallback certificate.

www to the apex

bind 0.0.0.0:80
https 0.0.0.0:443

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

site example.com {
    proxy 127.0.0.1:3000
}

Certificates are still issued for www.example.com, because challenges are answered before the redirect.

Load balancing three instances

bind 0.0.0.0:80

site app.example.com {
    proxy 127.0.0.1:3001 127.0.0.1:3002 127.0.0.1:3003 {
        health /api/health
        health_interval 5s
        health_timeout 2s
        fail_duration 30s
    }
}

Requests go to the instance with the fewest requests in flight. Each instance is checked every 5 seconds with a GET /api/health, and needs a 2xx within 2 seconds to stay in rotation. An instance that refuses a connection is also taken out for 30 seconds right away, and the request goes to another one. When you restart instances one at a time, new requests go to the others. Requests that were in flight on the restarted instance fail with 502.

For strict rotation instead, add lb round_robin. For a primary with standbys, lb first.

A WebSocket app

bind 0.0.0.0:80
https 0.0.0.0:443

site chat.example.com {
    proxy 127.0.0.1:4000 {
        health /health
        timeout 30s
    }
}

WebSockets need no extra config. timeout limits how long the backend may take to accept the handshake. Open connections have no time limit.

xTQ Web has no sticky sessions. If your WebSocket library falls back to long polling and runs on several instances, it usually needs every request of one client on the same instance. Run one instance, or configure the client to use WebSockets only.

If the same app also serves server-sent events or long polls, each open one holds a slot of the pool's in-flight cap. Raise it for many concurrent listeners:

    proxy 127.0.0.1:4000 {
        health /health
        limit 4096 3s
    }

A static site with SPA fallback

bind 0.0.0.0:80

site example.com {
    route /assets/* {
        header Cache-Control "public, max-age=31536000, immutable"
        files /var/www/example
    }
    files /var/www/example {
        not_found /index.html
    }
}

For a plain static site with its own error page, use not_found /404.html instead.

Docs under a path, from their own folder

The files live in /var/www/docs, but should appear under /docs/:

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

/docs/guide/intro.html is read from /var/www/docs/guide/intro.html. A request for the folder /docs/guide redirects to /docs/guide/.

An admin area for some addresses only

The addresses live in a file, one per line, hostnames allowed:

# /etc/xtq-web/admins.list
203.0.113.7
198.51.100.0/24
office.example.com
bind 0.0.0.0:80

site admin.example.com {
    header X-Robots-Tag "noindex, nofollow"
    route * {
        not ip @/etc/xtq-web/admins.list
        rewrite /blocked.html
        files /var/www/blocked {
            status 403
        }
    }
    proxy 127.0.0.1:3000
}

Visitors not in the list get /var/www/blocked/blocked.html with status 403. The file is read again every minute, so edits apply without a reload.

The same for one path of a larger site:

site example.com {
    route /admin/* {
        ip 203.0.113.7 @/etc/xtq-web/admins.list
        proxy 127.0.0.1:3000
    }
    route /admin/* {
        respond 403 "forbidden"
    }
    proxy 127.0.0.1:3000
}

The first /admin/* route matches only for listed addresses. Everyone else falls through to the second.

If a proxy or CDN sits in front, add it to trust, or every visitor has the proxy's address.

Login check by another service

A small service on port 9000 decides who may see /admin/ and /account/:

bind 0.0.0.0:80

site app.example.com {
    route /admin/* {
        auth 127.0.0.1:9000 /check/admin
        proxy 127.0.0.1:3000
    }
    route /account/* {
        auth 127.0.0.1:9000 /check/user
        proxy 127.0.0.1:3000
    }
    proxy 127.0.0.1:3000
}

The service gets a GET /check/admin or GET /check/user with the visitor's cookies and headers. It answers 204 to let the request through, 302 with a Location to send the visitor to a login page, 401 or 403 to refuse, or 444 to close the connection. It does not see the original path, so use a different check path per area, as here.

Each proxy line is its own pool, so this config has three pools to the same app, each with its own in-flight cap.

A PHP app with xTQ PHP

xTQ PHP listening on /run/xtq/php.sock, the app in /var/www/blog:

bind 0.0.0.0:80
https 0.0.0.0:443

site blog.example.com {
    php /run/xtq/php.sock /var/www/blog
}

Static files are served from the docroot directly, anonymous pages come from the page cache, and the rest goes to PHP through the waiting room. Set XTQW_SECRET so browser check passes survive restarts:

[Service]
Environment=XTQW_ACME=1
Environment=XTQW_SECRET=change-me-to-a-long-random-string

PHP and a Node API side by side:

site example.com {
    route /api/* {
        proxy 127.0.0.1:3000
    }
    php /run/xtq/php.sock /var/www/example
}

A maintenance page

Everyone gets a 503 while you work, and the health check keeps answering:

bind 0.0.0.0:80

site example.com www.example.com {
    route /health {
        respond 200 "ok"
    }
    route * {
        header Retry-After 600
        respond 503 "Down for maintenance. Back in a few minutes."
    }
}

Let your own address through to the real app:

site example.com www.example.com {
    route * {
        ip 203.0.113.7
        proxy 127.0.0.1:3000
    }
    route * {
        header Retry-After 600
        respond 503 "Down for maintenance. Back in a few minutes."
    }
}

With an HTML page instead of text:

site example.com www.example.com {
    rewrite /maintenance.html
    files /var/www/maintenance {
        status 503
    }
}

Switch between the normal config and the maintenance one with systemctl reload xtq-web. Nobody is disconnected.

Behind Cloudflare

Cloudflare proxies every request, and xTQ Web should see the real visitor:

trust 127.0.0.1 ::1 cloudflare
bind 0.0.0.0:80
https 0.0.0.0:443

site example.com {
    proxy 127.0.0.1:3000
}

With Cloudflare's SSL mode set to "Full", the self-signed fallback certificate is enough. For "Full (strict)", create an Origin CA certificate in Cloudflare and point XTQW_CERT and XTQW_KEY at it. Your app finds the visitor's address in X-Forwarded-For, followed by the Cloudflare hop, and ip rules check the visitor, not Cloudflare.