Qlyne proxy on your own server
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, “Generate secret”. The same secret works for the Cloudflare connector.
1. Start the proxy
Section titled “1. Start the proxy”- In Connector, 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”.
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 gate (Pro)
Section titled “Access gate (Pro)”Under Access, lock a path like /admin* or a whole staging site without touching your app. Two ways in:
- Password: one shared password, stored only as a hash. Tries are limited per IP.
- Link by e-mail: the visitor types an address; if it is on your list (an address, or
@company.comfor everyone there), a link that works once, for 15 minutes, arrives by e-mail. Everyone sees the same “check your e-mail” message, so the page does not tell who has access.
Whoever gets in stays in for the time you choose, through a cookie signed by the connector, and your app gets Qlyne-Gate-User with the e-mail. The gate applies in observe mode too, since it is a lock you set, not a guess. Trusted addresses skip it.
Login protection (Pro)
Section titled “Login protection (Pro)”Under Logins and forms, “Login protection”, list the routes your login form (or app) posts to and the names of the username and password fields (user.email reaches into JSON). Forms, multipart and JSON are read.
- Leaked passwords: the connector computes the password’s SHA-1 and sends Have I Been Pwned only the first 5 characters, shared by hundreds of passwords; the password never leaves the connector. If it is in a known breach, the login reaches your app with
Qlyne-Leaked-Password: 1, and your app decides, for example by asking for a new password. If the check is slow or down, the login goes through without the header. - Tries per account: counted by the username, from any IP, so one account cannot be guessed from many addresses. Over the limit, the visitor gets a 429.
Managed rules (WAF)
Section titled “Managed rules (WAF)”Under Firewall, “Managed rules”, Qlyne looks for signatures of common attacks in the path, the query string and form or JSON bodies up to 16 KB, after decoding them (twice, for whoever encodes twice to slip through) and without SQL comments: SQL injection (UNION SELECT, 1=1 conditions, stacked statements, time delays, schema reads), cross-site scripting (script tags, event handlers, javascript: links, iframes), path traversal (../, system files, null bytes) and command injection ($(…) with system commands, chained curl or bash, Log4Shell lookups). Bodies in other formats (file uploads) and bodies without a declared size are not read.
Every site starts in “Observe only”: the matches show up in Events as “Would block by managed rules”, with the signature, and your app gets them in Qlyne-Rule. When only attacks show up there, switch to “Block” (Pro; on Free the rules keep observing). A page that sends HTML or code on purpose, like a rich text editor, goes in “Routes the managed rules skip”. The managed rules run after your rules, so a “let through” rule skips them too.
Signatures catch the common, automated attacks; they do not replace validating input and using parameterized queries in your app.
Bot score
Section titled “Bot score”Under Bots, “Bot score”, the proxy adds a small script, /__qlyne/s.js, to your HTML pages, served from your own domain. A few seconds after the page opens, after a click or a key, and when the visitor sends a form or leaves, it posts a summary to /__qlyne/s: whether the browser says it is automated, how many languages, plugins, processor cores and touch points it reports, the screen size, whether it has a time zone, and how many times the pointer moved, the visitor clicked, typed, scrolled or touched, and how many of those events a script fired. It sends counts only: never keys, text or where the pointer was.
From that summary, and from whether the request headers match the browser named in the user agent, the connector gives the visitor a score from 0 to 100: low below 30, medium from 30 to 59, high from 60. The score stays 30 minutes in the qlyne_bs cookie, signed by the connector and tied to the IP and the browser. Your app gets it in Qlyne-Bot-Score, with the reasons in Qlyne-Bot-Reasons (like automation,no_interaction), and Events lists medium and high scores with the reasons. A first page view has no score yet: the actions start with the next request.
On Pro, choose what a medium and a high score get: nothing, the request slowed down by 3 seconds, the challenge, the challenge with a click, 403, or the decoy page. The decoy is a page of your site that the bot gets in place of the one it asked for, under the same address, so it keeps scraping something worth nothing. In observe mode these only count.
“Routes that need the score” refuse a form or API call (anything but GET) from a browser that has not run the script, with 403 and {"code": "SENSOR_REQUIRED"}. Use it on what bots call directly, like a checkout or signup API. A visitor with a solved challenge goes through.
“Click to confirm”, under “Challenge”, sets how the challenge page and the form captcha behave: never (the browser does the work alone), when in doubt (a click on “I’m a person” only when the score is not low or the address asked for many challenges) or always. A click fired by a script, or on a computer without the pointer moving first, is refused.
Shared reputation
Section titled “Shared reputation”Under Firewall, “Shared reputation”, Qlyne lists the addresses that sites of other accounts saw attacking in the last 24 hours: caught by the scanner trap or the managed rules, with a high bot score, driving an automated browser or refused by the form captcha. An address goes on the list only when at least two accounts report it, so no single account can put someone there. For IPv6 the list holds the /64.
By default a listed address only reaches your app with Qlyne-Reputation: 1. On Pro, challenge its page views (API calls go through, as with Tor exits) or block it with 403; Googlebot and Bingbot are never blocked, and trusted addresses skip it. The list comes with the rules, within a minute.
“Report attacks on this site” (on by default) sends Qlyne only the address of those attacks, kept 24 hours, and never the path, the user agent or which site it was. Turning it off does not stop you from using the list. How this data is handled is in the terms and the privacy policy.
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 adds tls when the user agent claims a recent Chrome, Firefox or Safari but the connection doesn’t use TLS 1.3 or doesn’t offer HTTP/2, as scripts do. A company proxy that opens HTTPS to inspect it also changes the fingerprint, so this only raises the score to medium and never blocks on its own.
Your rules (Pro)
Section titled “Your rules (Pro)”Under Firewall, “Your rules”, each rule is a list of conditions that must all hold, and what to do: block, challenge, let through (skipping every other rule, the waiting room included) or only log. Conditions look at the path, host, method, country, network (ASN), IP or range, user agent, TLS fingerprint (JA4, see above), and whether the visitor is a verified search bot. For “or”, put several values in one condition (BR, PT) or write two rules. Rules run in order, after blocked addresses and the scanner trap; the first one that blocks, challenges or lets through decides.
Examples: block the login for networks of hosting companies (path starts with /login and ASN is any of AS14061, AS16509); challenge the checkout outside your countries (path matches /checkout* and country is none of BR, PT); block API calls with no user agent. The proxy does not know country and network yet: a condition on them never matches, so country is none of BR does not block anyone.
Scanner trap
Section titled “Scanner trap”Scanners ask for paths no visitor does: /.env, /.git/config, /phpmyadmin, /wp-login.php on a site without WordPress. Under Bots, “Scanner trap”, pick the groups of paths and add your own (an old admin address, a link only bots follow). Whoever requests one gets a plain 404, with no hint it was a trap, and is blocked on every route for 15 minutes, 1 hour or 24 hours. An IPv6 visitor is blocked by its /64. With several proxies, the others learn within a minute. Googlebot, Bingbot and trusted addresses are never blocked, and in observe mode the trap only counts. Leave the WordPress group off if your site runs WordPress.
Security headers and Content Security Policy
Section titled “Security headers and Content Security Policy”Under Page security, turn on HSTS, X-Content-Type-Options, Referrer-Policy, framing protection and Permissions-Policy. They are added to your pages only when your app does not send them already. The grade (A+ to F) comes from the pages that went through the connector, so it counts your own headers too.
“Content Security Policy” (Pro) takes your policy and a mode. In “Report only”, browsers send what the policy would block to the page itself with ?__qlyne=csp, which the connector receives and lists under Page security for 7 days; nothing breaks. When the list only shows what you expect, switch to “Enforce”. The connector replaces any report-uri in your policy with its own.
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, “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 |
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, “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_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_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"]}]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.