unmask

docs

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

Running unmask in containers

Two images, published on GHCR with every release: unmask.sh/admin (the daemon) and unmask.sh/nginx (the official nginx image plus the unmask module, built against that exact nginx version). Together they are the same shape as a host install — nginx terminates TLS and classifies, the daemon verifies and keeps state. The nginx image can also stand 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 images) wires the two containers 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 admin | 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 admin container without it and every restart mints new secrets: every visitor is logged out and challenged again. /var/lib/unmask is the database; /run/unmask carries the access-log socket from nginx to the daemon (that is what feeds the dashboard's cookie-reuse views).

Settings changed in the admin UI are rendered into the shared volume, and the nginx 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.

2. Gateway mode: in front of any HTTP server

With UNMASK_GATEWAY=1, the nginx 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 (and UNMASK_UPSTREAM / UNMASK_TRUSTED_PROXIES on the nginx service still work as fallbacks while the tab leaves those empty).

VariableMeaningDefault
UNMASK_GATEWAY1 turns gateway mode on (on both services: the admin seeds the tab, the nginx container renders the gateway servers).unset = module-only
UNMASK_UPSTREAMWhere passing requests go, e.g. http://app:3000 — on the admin service it seeds the tab; on the nginx service it is the fallback while the tab leaves the upstream empty (the 0.1.37 way, which also turns gateway mode on).unset = set it in the tab
UNMASK_SERVER_NAMEThe hostnames the gateway answers for (on the admin service; 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) on the nginx container, when not using ACME./etc/unmask/tls/fullchain.pem
UNMASK_TLS_KEYPrivate key (PEM) on the nginx 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. On the admin service it seeds the tab; on the nginx service it is the fallback.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. Three sources:

: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 → nginx → https redirect). Set UNMASK_TRUSTED_PROXIES to the balancer's ranges 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: if the admin container is unreachable, requests are proxied without a challenge rather than refused.

3. Module-only: your own nginx image

Leave UNMASK_GATEWAY unset and unmask.sh/nginx is a plain nginx with the module loaded — mount your 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;
    }
}

Or keep your existing image and add the module to it. The .so must match the nginx version and be built with --with-compat; the official nginx:<version> images qualify, and docker/nginx/Dockerfile is a two-stage build you can copy from — or simply COPY --from=unmask.sh/nginx:1.28 the module out of ours. The admin container must share /etc/unmask and /run/unmask with it, and its UNMASK_DAEMON_ADDR must name the admin service as nginx reaches it (compose: admin:9477).

4. Kubernetes and other reverse proxies

JA4 is read from the TLS handshake, so it exists only where TLS terminates. Behind an ingress controller, Traefik, Caddy or a cloud load balancer, the nginx container never sees the client's ClientHello, and unmask runs in forward-auth mode 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

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