Qlyne proxy on your own server
Deze inhoud is nog niet vertaald.
For sites that do not use Cloudflare: a small container that runs on your server, between your web server (nginx, Caddy or Traefik) and your application, and applies the same rules you set in the Qlyne dashboard:
- a waiting room on the pages you pick, and a cap on how many people are on the site at once;
- its own challenge (invisible proof-of-work), an “under attack” mode, and browsers driven by automation tools refused;
- rate limits per page, counted per IP (IPv6 by its /64) or per API key, token or session;
- blocked and trusted IP addresses, and a rule for Tor exit nodes;
- AI crawlers blocked by category, robots.txt included;
- form protection with no captcha;
- an “observe only” mode that records what would be blocked without blocking anyone.
Your traffic does not go through Qlyne. The proxy fetches your rules every minute and sends counters every 10 seconds; with a cap on people at once, it also asks Qlyne for a slot when someone without a pass arrives. If Qlyne is unreachable, the proxy keeps the last rules it had, or lets everything through.
Before you start
Section titled “Before you start”- The Qlyne free plan covers observe mode, IP addresses, AI crawlers and rate limits. The waiting room, the cap on people at once, the challenge, under attack mode, forms and Tor need the Pro plan.
- A Linux server with Docker and the
composeplugin, where your site runs. - A web server in front of your application handling HTTPS: nginx, Caddy or Traefik.
- The connector secret: in the dashboard, your site, Connector › Configuration, “Generate secret”. The same secret works for the Cloudflare connector.
1. Start the proxy
Section titled “1. Start the proxy”- In Connector › Configuration, click “Download docker-compose.yml” and put the file in a folder on your server. It already carries your account ID and the Qlyne server address.
- Next to it, create a
.envfile with one line:QLYNE_SECRET=<your connector secret>. - In the file, set
QLYNE_UPSTREAMto your application as seen from inside Docker. An application on port 3000 of the same machine ishttp://host.docker.internal:3000; an application in the same compose project ishttp://<service>:<port>. - Run
docker compose up -d. The proxy listens on127.0.0.1:8080, reachable only from the same machine.
2. Put it in front of your application
Section titled “2. Put it in front of your application”Point your web server to the proxy and keep your application as the fallback: if the proxy stops, requests go straight to your application and the site stays up, only without protection. Replace 3000 with your application’s port.
nginx:
map $http_upgrade $connection_upgrade { default upgrade; '' close;}
upstream app_with_qlyne { server 127.0.0.1:8080 max_fails=1 fail_timeout=10s; # Qlyne proxy server 127.0.0.1:3000 backup; # your application}
server { listen 443 ssl; server_name shop.example.com; # your ssl_certificate and ssl_certificate_key
location / { proxy_pass http://app_with_qlyne; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_connect_timeout 2s; proxy_next_upstream error timeout; }}Caddy (it sets the X-Forwarded-* headers on its own):
shop.example.com { reverse_proxy 127.0.0.1:8080 127.0.0.1:3000 { lb_policy first lb_try_duration 2s fail_duration 10s }}Traefik (file provider), with the proxy’s health check telling Traefik when to fall back:
http: services: shop-with-qlyne: failover: service: qlyne fallback: app qlyne: loadBalancer: healthCheck: { path: /__qlyne/health, interval: 5s, timeout: 2s } servers: [{ url: "http://127.0.0.1:8080" }] app: loadBalancer: servers: [{ url: "http://127.0.0.1:3000" }]Check it: curl http://127.0.0.1:8080/__qlyne/health answers ok, and your site still opens through your web server.
3. Watch, then enforce
Section titled “3. Watch, then enforce”Leave the mode on “Observe only”, browse your site and check the last 24 hours in the site Overview. When the numbers make sense, switch to “Enforce” in Connector › Configuration.
How it differs from the Cloudflare connector
Section titled “How it differs from the Cloudflare connector”- The visitor IP comes from the last item of
X-Forwarded-For, which your web server sets. If the proxy is exposed directly, without a web server in front, setQLYNE_CLIENT_IP: "peer". - Rate limits are counted by the proxy itself, per IP or per header or cookie as the rule says (the value is kept only as a hash). With more than one proxy (several servers), each one counts its own share.
- WebSockets and large uploads and downloads pass straight through.
- Static files (scripts, styles, images, fonts) get neither the waiting room, the challenge nor the form check.
Access (Pro)
Section titled “Access (Pro)”A password or a sign-in link by e-mail in front of a path such as /admin* has its own page: Access.
Account Protection (Pro)
Section titled “Account Protection (Pro)”Leaked passwords, tries per account and forms that refuse robots are on their own page: Account Protection.
Managed rules (WAF)
Section titled “Managed rules (WAF)”Managed rules, IP lists and rate limits are set the same way on every connector: see WAF & Firewall. The proxy reads form, JSON, multipart and XML bodies up to 16 KB, with or without a declared size.
Bot Management
Section titled “Bot Management”The bot score and what each score band gets, the challenge settings, verified bots and impostors, AI crawlers, the scanner trap, Tor and shared reputation are on Bot Management. Everything there works with the proxy.
TLS fingerprint (JA4)
Section titled “TLS fingerprint (JA4)”When a connection opens, the browser tells the server which TLS versions, ciphers and extensions it supports. That list, summarized as a JA4 fingerprint like t13d1516h2_8daaf6152771_d8a2da3f94cd, depends on the program, not on the user agent it claims: a script that copies Chrome’s user agent still connects like a script. The proxy only sees it if the HTTPS connection ends at the proxy. Behind nginx, Caddy or Traefik it doesn’t, so there are two ways:
- HTTPS in the proxy itself: set
QLYNE_TLS_LISTEN(0.0.0.0:8443, published as port 443),QLYNE_TLS_CERTandQLYNE_TLS_KEY, and mount the certificate files (the downloadeddocker-compose.ymlhas the lines, commented). With certbot, copy the files on each renewal to a folder the proxy can read, since the image runs as user 65532:certbot renew --deploy-hook 'install -m 0644 $RENEWED_LINEAGE/fullchain.pem /srv/qlyne/certs/ && install -m 0640 -g 65532 $RENEWED_LINEAGE/privkey.pem /srv/qlyne/certs/'. The proxy rereads the files when they change. In this mode the proxy speaks HTTP/2 to browsers that offer it (HTTP/1.1 to the rest), takes the visitor IP from the connection (anX-Forwarded-Forsent by the visitor is ignored there) and keeps answering onQLYNE_LISTEN; keep port 80 on your web server to redirect to HTTPS. - HTTPS in the proxy with certificates on demand: set
QLYNE_TLS_LISTEN,QLYNE_ACME_DIR(a mounted folder the proxy can write, where the Let’s Encrypt account and the certificates live) andQLYNE_ACME_EMAIL, and list your hosts inQLYNE_HOSTS(shop.example,www.shop.example; withQLYNE_SITES, each site’s hosts). Publish port 80 toQLYNE_LISTENtoo: the proxy proves the host is yours by answering/.well-known/acme-challenge/there. On the first visit to each host the certificate is requested with that connection waiting (a few seconds), then it is saved and renewed after 60 days. A host that is not yours gets no certificate and no request is made, so nobody spends your Let’s Encrypt quota by pointing a DNS name at you. SetQLYNE_ACME_URL=stagingto try it against Let’s Encrypt’s staging server first (browsers do not trust those certificates, but the flow is the same). - Your HTTPS server computes it: with the JA4 module for nginx or a JA4 plugin for Caddy, send it in a header and set
QLYNE_JA4_HEADERto its name. Only do this when the proxy is reachable only by that server, otherwise anyone can send the header.
With the JA4, your app gets Qlyne-JA4, Events shows it next to the user agent, your rules can use it (“TLS fingerprint (JA4)”, like starts with t13d1812h1), and the bot score takes it into account: see TLS fingerprint on the Bot Management page.
Your rules (Pro)
Section titled “Your rules (Pro)”Your own rules (block, challenge, let through or only log, by path, country, network, IP and more) are in WAF & Firewall. On the proxy, country and network come from a local GeoIP base: mount it and set QLYNE_GEOIP_DIR, as that page explains. Without it, a condition on them never matches.
Page Shield
Section titled “Page Shield”Security headers and their grade, the Content Security Policy with its violation list, the consent banner, the page cache and purge are on Page Shield. Everything there works with the proxy.
Signals to your app
Section titled “Signals to your app”Requests that reach your site carry headers your code can use (turn them off in Connector › Configuration, “Signals to your app”):
| Header | Value |
|---|---|
Qlyne-Country | Two-letter country code (Cloudflare connector only; the proxy does not send it) |
Qlyne-ASN | The visitor’s network number (Cloudflare connector only; the proxy does not send it) |
Qlyne-Verified-Bot | googlebot or bingbot, confirmed by IP; chatgpt-agent, confirmed by its signature |
Qlyne-Challenge-Passed | 1 when the visitor solved the challenge, on any page |
Qlyne-Trusted-IP | 1 for an address in your trusted list |
Qlyne-Rule | In observe mode, what would have happened, like would_challenge=/checkout*, would_rate_limit=/api/* |
Qlyne-Leaked-Password | 1 on a login whose password is in a known breach (login protection, sent even with the other signals off) |
Qlyne-Gate-User | The e-mail of who signed in at an access gate (sent even with the other signals off) |
Qlyne-Bot-Score | With the bot score on, the visitor’s score from 0 to 100 |
Qlyne-Bot-Reasons | What raised the score, like automation,no_interaction |
Qlyne-Reputation | 1 for an address other Qlyne accounts saw attacking (shared reputation) |
Qlyne-JA4 | The TLS fingerprint, when the proxy has it (HTTPS in the proxy, or QLYNE_JA4_HEADER) |
Headers with these names sent by the visitor are always removed. Trust them only if the application is reachable only through the proxy.
Rules as code and metrics
Section titled “Rules as code and metrics”Every rule of a site is also a YAML (or JSON) file you can keep in git, authenticated with the account API key from Plan and API key (one key for every site of the account; the site goes in X-Tenant-ID):
# downloadcurl -H "Authorization: Bearer $QLYNE_API_KEY" -H "X-Tenant-ID: your-account" \ "https://app.qlyne.com/api/protection?format=yaml" > qlyne.yaml# check without saving, then apply (drop ?dry_run=1)curl -X PUT -H "Authorization: Bearer $QLYNE_API_KEY" -H "X-Tenant-ID: your-account" \ -H "Content-Type: application/yaml" --data-binary @qlyne.yaml \ "https://app.qlyne.com/api/protection?dry_run=1"PUT replaces the rules: a section missing from the file goes back to its default, and a list in the file replaces the whole list. Errors come back as 422 with the field that failed (challenge.routes, rules.0.conditions.1.values), and an unknown setting is an error too, so a typo does not pass silently. In a CI, run the ?dry_run=1 call on pull requests and the real one on merge.
GET /api/metrics, with the same key, returns the Prometheus metrics of your account only (decisions by action, queue sizes and the rest), for your own Grafana. The scrape config is under Connector › Configuration, “Rules as code”.
Settings
Section titled “Settings”| Variable | Default | What |
|---|---|---|
QLYNE_TENANT, QLYNE_CORE_URL | in the downloaded file | Your account and the Qlyne server |
QLYNE_SECRET | none | The connector secret, from the .env file |
QLYNE_UPSTREAM | http://host.docker.internal:3000 | Your application, http://host:port |
QLYNE_CLIENT_IP | x-forwarded-for | Where the visitor IP comes from: x-forwarded-for, x-real-ip, cf-connecting-ip or peer |
QLYNE_LISTEN | 0.0.0.0:8080 | Address inside the container |
QLYNE_TLS_LISTEN | none | HTTPS in the proxy itself, like 0.0.0.0:8443; needs the two below |
QLYNE_TLS_CERT, QLYNE_TLS_KEY | none | Certificate chain and private key, PEM files; reread when they change |
QLYNE_GEOIP_DIR | none | Folder with the GeoIP base (ip2asn-v4.tsv and ip2asn-v6.tsv from iptoasn.com), for Country and ASN in your rules; without it they are unknown |
QLYNE_JA4_HEADER | none | Header in which your HTTPS server sends the JA4, like X-JA4 |
QLYNE_ACME_DIR | none | Certificates on demand: folder for the Let’s Encrypt account and the certificates; with it, QLYNE_TLS_CERT and QLYNE_TLS_KEY are not needed |
QLYNE_ACME_EMAIL | none | E-mail of the Let’s Encrypt account (expiry notices); required with QLYNE_ACME_DIR |
QLYNE_ACME_URL | Let’s Encrypt | staging for Let’s Encrypt’s test server, or another ACME directory URL |
QLYNE_HOSTS | none | Hosts that get a certificate, comma separated (with one site; with QLYNE_SITES, the hosts of each site) |
QLYNE_COMPRESS | off | 1: compresses text responses (gzip, brotli, zstd) the origin sent uncompressed, for browsers that accept it |
QLYNE_ORIGIN_CA | none | PEM file with your own certificate authority, trusted for https:// origins besides the public roots |
QLYNE_CACHE_MB | 64 | Memory for the cache of static files and of pages, in MB; 0 turns it off |
QLYNE_CACHE | off | 1: with one site, keep its public static files (with QLYNE_SITES, cache per site) |
QLYNE_HOSTED_KEY | none | Qlyne’s own front proxy: the key that reads the hosted sites from the Qlyne server and joins them to QLYNE_SITES |
QLYNE_HTTP_REDIRECT | off | 1: requests on the HTTP port get a 308 to HTTPS (the certificate challenge and /__qlyne/health still answer there) |
QLYNE_SITES | none | Several sites in one proxy: path to a JSON file (below). With it, QLYNE_TENANT, QLYNE_SECRET and QLYNE_UPSTREAM are per site |
LOG_FORMAT | json | json or text |
Several sites in one proxy
Section titled “Several sites in one proxy”One proxy in front of several applications, like a Traefik: each request goes to the site whose host it names, with that site’s own account, secret, rules and origin. Mount a JSON file and point QLYNE_SITES at it:
[ {"tenant": "shop", "secret": "…", "upstream": "http://shop:3000", "hosts": ["shop.example", "www.shop.example"]}, {"tenant": "blog", "secret": "…", "upstream": "http://blog:8080", "hosts": ["blog.example"]}, {"upstream": "http://api:9000", "hosts": ["api.example"], "deny": ["/internal/*"], "headers": {"strict-transport-security": "max-age=31536000"}, "remove": ["server"]}, {"redirect": "https://shop.example", "hosts": ["example.com", "www.example.com"]}]An entry can have several origins instead of one, with weights, like nginx’s upstream: "upstreams": [{"url": "http://app1:3000", "weight": 3}, {"url": "http://app2:3000"}]. Requests are spread in proportion to the weights; an origin that stops answering leaves the rotation at once (a GET or HEAD that hit it is retried on the next one, so the visitor sees nothing) and comes back when a probe, every few seconds on the path in "health" (default /), gets any answer below 500. An origin can be https://: the proxy checks its certificate against the public roots (plus the file in QLYNE_ORIGIN_CA, for your own authority) using the site’s host name as the TLS server name, even when the origin is an IP address, like Cloudflare’s “Full (strict)”; "tls_verify": false accepts any certificate (the connection stays encrypted, but nothing proves who is on the other side). A path can go to another group of origins, like nginx’s location: "routes": [{"path": "/api/*", "upstream": "http://api:9000"}] (with upstreams, weights and health as above); the first route that matches wins, and everything else goes to the site’s origins. "timeouts": {"connect": 5, "response": 30} (seconds) says how long an origin has to accept the connection and to start answering; past that it counts as down, and a GET or HEAD moves to the next origin. "sticky": true (on the site or on a route) pins each visitor to the origin that served them first, through the qlyne_origin cookie, for apps that keep sessions in memory; if that origin goes down, the visitor moves and the cookie changes. "cache": true keeps the site’s public static files (CSS, JS, images, fonts the origin sends with Cache-Control: public, max-age=N, up to 2 MB each, without cookies) in the proxy’s memory for that time (a day at most), so the origin is not hit on every visit; the response says X-Qlyne-Cache: HIT or MISS. "origin_key": "…" is sent to the origin in every request as Qlyne-Origin-Key (the visitor’s own copy is always dropped), so your server can refuse anything that did not come through the proxy. Any entry can also carry deny (paths that answer 404 from outside, * matching anything), headers (put on every response that does not have them) and remove (taken off every response, like the origin’s Server). Three kinds of entry: with tenant and secret, the site is protected with that account’s rules; with only upstream, the proxy just forwards (an API, a status page); with redirect instead of upstream, every request gets a 308 to that address with the same path and query (the bare domain, an old domain). The file is reread when it changes, without a restart; a site that did not change keeps its rules and counters. A host that is in no site gets 421 Misdirected Request. An invalid file is logged and the previous list stays.
Messages in the proxy logs
Section titled “Messages in the proxy logs”docker compose logs qlyne-proxy shows them.
| Message | Meaning |
|---|---|
the config is not signed by the Qlyne server | QLYNE_CORE_URL does not point to the Qlyne server. Nothing is blocked until you fix it. |
the Qlyne server refused the config (401) | QLYNE_SECRET is not the current connector secret. Nothing is blocked until you fix it. |
QLYNE_TENANT, QLYNE_CORE_URL or QLYNE_SECRET is not set | Every request goes straight to your application. |
could not reach the application at ... | QLYNE_UPSTREAM is wrong or your application is down: the proxy answers 502. |
could not reach the Qlyne server | The proxy keeps the last rules it had; with none, it lets traffic through. |
To remove the proxy, point your web server back to your application and run docker compose down.