unmask

docs

JA4 acquisition, LB / CDN setup, supported distros, FAQ.

Running unmask in containers

One image, published with every release: unmask.sh/unmask — the official nginx image plus the unmask module (built against that exact nginx version), and the unmask daemon, kept up by one entrypoint. It is the same shape as a host install in one container — nginx terminates TLS and classifies, the daemon verifies and keeps state — and it stands in front of any HTTP server as a JA4-aware gateway, which is how you run unmask when your application is not served by nginx at all.

1. Quick start (docker compose)

docker-compose.example.yml (attached to every release, so the copy you fetch matches the image) is one service plus a stand-in upstream. Nothing to prepare:

curl -fsSL -o docker-compose.example.yml https://unmask.sh/dl/docker/docker-compose.yml
docker compose -f docker-compose.example.yml up -d
docker compose -f docker-compose.example.yml logs unmask | grep "setup token"

Without compose, the same container by hand. Until an upstream is set, the gateway answers visitors with a short notice; set it in the admin UI (below), or seed it at the first start with -e UNMASK_UPSTREAM=http://<your server>:<port> — an address this container can reach, since nginx will not start on a host name it cannot resolve.

docker run -d --name unmask --restart unless-stopped -p 80:80 -p 443:443 \
  --add-host host.docker.internal:host-gateway \
  -v unmask-config:/etc/unmask -v unmask-data:/var/lib/unmask -v unmask-acme:/var/cache/nginx/unmask-acme \
  unmask.sh/unmask:latest
docker logs unmask | grep "setup token"

Open https://localhost/unmask/admin/. The first start serves a self-signed certificate the admin generated, so the browser warns once; accept it for now. The admin UI sits behind the same protection as the rest of the site, so the next thing you see is a short security check; pass it and the install wizard follows. Paste the setup token from the log, pick SQLite (the default) or point it at your database, create the admin user — done. Then Settings → Gateway: the upstream (where passing requests go), the hostnames the gateway answers for, and the real certificate (Let's Encrypt, pasted, or mounted files) all live there.

Keep the volumes. /etc/unmask holds the rendered nginx includes and the secrets that sign pass cookies. Run the container without it and every restart mints new secrets: every visitor is logged out and challenged again. /var/lib/unmask is the database, and /var/cache/nginx/unmask-acme the Let's Encrypt account and certificates when you use them.

Settings changed in the admin UI are rendered into /etc/unmask, and nginx (in the same container) reloads itself within a few seconds of the files changing — after nginx -t, so a render that does not parse is logged and skipped while the running configuration stays up. Nothing to run by hand. UNMASK_AUTORELOAD=0 turns the watcher off if you would rather reload on your own schedule.

Two processes, one container: the entrypoint starts the daemon, waits for it, then starts nginx, and keeps both up. If the daemon exits it is restarted after a short pause while nginx keeps serving — requests are proxied without a challenge meanwhile (fail-open); only a banned client is still turned away. If nginx exits, the container exits and the restart policy brings the whole thing back. docker stop quits nginx gracefully.

2. Gateway mode: in front of any HTTP server

The container terminates TLS on :443 (so it sees the real client ClientHello — that is where JA4 comes from), runs the challenge decision, and proxies what passes to the upstream. Nothing is installed on the application side, and it does not matter what serves it: Node, Rails, Go, Apache, another nginx, a static file server.

The gateway is configured in the admin UI, Settings → Gateway: the upstream (http://host.docker.internal:8080 for a server on the Docker host, http://10.0.0.5:8080 for another host, http://app:3000 for a container in the same compose), the hostnames and the certificates; the trusted proxies of a load balancer in front are Settings → Network → trusted LB / CDN (presets for GCP, Cloudflare and others), the same set the forwarded JA4 header is trusted from. Until an upstream is set, the gateway answers with a notice that says so. To point it at a server on the same host, give :80/:443 to the gateway and move that server to 8080 (Apache: Listen 8080, nginx: listen 8080;) — bound to an address the container can reach, so not 127.0.0.1, and with 8080 closed at the firewall. The compose file already maps host.docker.internal to the host. The environment variables below only seed the tab on the first boot (UNMASK_UPSTREAM and UNMASK_TRUSTED_PROXIES also stand in while the tab leaves those empty).

VariableMeaningDefault
UNMASK_GATEWAYGateway mode: on by default in this image. 0, with UNMASK_UPSTREAM left unset (setting it turns gateway mode back on), makes the container a plain nginx with the module (plus the daemon), and your own conf.d applies — section 3.1
UNMASK_UPSTREAMWhere passing requests go, e.g. http://app:3000 — seeds the tab on the first boot, and stands in while the tab leaves the upstream empty.unset = set it in the tab
UNMASK_SERVER_NAMEThe hostnames the gateway answers for (seeds the Gateway tab on the first boot): _ = any, otherwise a space-separated list, which is also what the seeded certificate is for._ (any)
UNMASK_ACME_EMAILSeeds automatic HTTPS: the certificate for UNMASK_SERVER_NAME is requested from Let's Encrypt and renewed by nginx itself.unset = self-signed (or your own files when mounted)
UNMASK_ACME_DIRECTORYACME directory URL; point it at the staging endpoint while testing.Let's Encrypt production
UNMASK_TLS_CERTCertificate chain (PEM) in the container, when not using ACME./etc/unmask/tls/fullchain.pem
UNMASK_TLS_KEYPrivate key (PEM) in the container, when not using ACME./etc/unmask/tls/privkey.pem
UNMASK_TLS_MODEnone when a load balancer in front terminates TLS and forwards on :80 (seeds Listen on: http only -- no :443, no certificate); also files / acme / upload / selfsigned.derived: acme with an e-mail, files when a certificate is mounted, self-signed otherwise
UNMASK_TRUSTED_PROXIESAddress ranges of a load balancer in front of the gateway (space separated). The visitor's address is then read from its X-Forwarded-For. Seeds Settings → Network → trusted LB / CDN on the first boot, and stands in while that list is empty.unset = the connecting address is the visitor

Hostnames and certificates

Both live in the admin UI, Settings → Gateway, as two independent questions. Which hostnames the gateway answers for: any (the Host passes through to the upstream, for an upstream that serves several sites itself), or a list — any other Host is refused at the TLS handshake. Which certificates it has: one entry per certificate, each with its source and the domains it is for. nginx picks the certificate by SNI; the first entry is the default, served to any hostname no certificate names (the tab warns when a listed hostname has none). So one gateway serves several sites with several certificates, and changing the hostnames never touches the certificates. Each certificate also shows what :443 actually serves for its first domain — issuer, expiry, and whether the name matches. Where the certificate comes from:

:80 answers with a redirect to https. A balancer's health checks are exempt from the redirect by user agent (GoogleHC, ELB-HealthChecker, kube-probe and the like; more under Settings → Network → HTTP → HTTPS redirect). List the balancer's ranges under Settings → Network → trusted LB / CDN (or seed them with UNMASK_TRUSTED_PROXIES at the first start) so bans, rate limits and geo rules see the visitor rather than the balancer. The upstream receives Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto: https, and WebSocket upgrades are passed through.

The gateway imposes no body-size limit and allows hour-long responses, so uploads and streams are governed by the upstream's own settings, not by a default it never chose.

In front of an nginx you already run

It works, with the usual two-proxy rules. Your nginx now sees plain HTTP from the gateway's address, so (1) tell it where the real client is — set_real_ip_from the gateway's address and real_ip_header X-Forwarded-For — or its logs, rate limits and geo rules will all see the gateway; and (2) if it redirects http to https itself, key that on $http_x_forwarded_proto instead of the scheme it received, or the two will bounce every request between them. Alternatively keep TLS on your nginx and point the upstream at its https listener; the gateway sends SNI, so its vhost selection works. Client certificates (mTLS) cannot survive the hop, since TLS ends at the gateway.

If that nginx is one you can change, the module-only path is the better fit: add the module to it and there is no second proxy at all. The gateway is for the cases where you cannot.

Gateway mode puts unmask on the request path, which the module-only install never does. nginx itself is the same stock binary the official image ships, and the daemon-down fail-open in server.inc still applies: while the daemon is down (the entrypoint restarts it within seconds), requests are proxied without a challenge rather than refused.

3. Module-only: your own nginx image

Set UNMASK_GATEWAY=0 and leave UNMASK_UPSTREAM unset, and the container is a plain nginx with the module loaded (the daemon runs beside it as before) — mount your conf.d at /etc/nginx/conf.d and write the includes exactly as on a host:

# http scope (any conf.d file is http scope)
include /etc/unmask/http.inc;
include /etc/unmask/upstream.conf;
include /etc/unmask/forward-auth-lbtrust.conf;

server {
    listen 443 ssl;
    ...
    include /etc/unmask/server.inc;
    location / {
        include /etc/unmask/protect.inc;
        proxy_pass http://app:3000;
    }
}

Your own nginx image with the module in it is the host install's shape, not this image's: the module stage of Dockerfile shows how the .so is built (it must match the nginx version and be built with --with-compat; the official nginx:<version> images qualify), and the daemon then comes from the packages on the host, with the includes it renders mounted into your image.

4. Kubernetes and other reverse proxies

JA4 is read from the TLS handshake, so it exists only where TLS terminates. Behind an ingress controller, another reverse proxy or a cloud load balancer, the container never sees the client's ClientHello, and unmask runs without JA4 unless the front component forwards it (see LB / CDN setup for the ones that can). The behavioral CAPTCHA, PoW, IP and UA axes keep working; the fingerprint axis does not.

Where you control the edge, run the gateway image as the edge: a small cluster can point a LoadBalancer Service at it and keep the JA4 axis. There is no ingress-nginx integration — that controller does not load third-party modules.

5. Image tags

The image is built and pushed by the release workflow from the tagged commit, and the CI builds it on every push so a module change that stops loading under --with-compat fails before it ships.