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).
| Variable | Meaning | Default |
UNMASK_GATEWAY | 1 turns gateway mode on (on both services: the admin seeds the tab, the nginx container renders the gateway servers). | unset = module-only |
UNMASK_UPSTREAM | Where 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_NAME | The 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_EMAIL | Seeds 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_DIRECTORY | ACME directory URL; point it at the staging endpoint while testing. | Let's Encrypt production |
UNMASK_TLS_CERT | Certificate chain (PEM) on the nginx container, when not using ACME. | /etc/unmask/tls/fullchain.pem |
UNMASK_TLS_KEY | Private key (PEM) on the nginx container, when not using ACME. | /etc/unmask/tls/privkey.pem |
UNMASK_TLS_MODE | none 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_PROXIES | Address 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:
- Automatic (Let's Encrypt) — enter the domains and a contact address and the gateway
takes care of TLS on its own: the official nginx image ships nginx's ACME module, so one certificate covering
all the entry's domains is obtained over the http-01 challenge on :80 and renewed in place, with no client,
cron or sidecar. The account is created on the first issuance — nothing to register beforehand —
and shared by every such entry (one contact, one directory). Two
conditions: the domains must resolve to this host and :80 must be reachable from the internet, and the
/var/cache/nginx/unmask-acme volume must persist (it holds the account key and the certificates;
losing it means re-issuing, which Let's Encrypt rate-limits). Use the staging directory until the setup is right.
- Paste a certificate — a purchased certificate: paste the certificate, the intermediate
chain if your CA gave you one, and the private key. The pair is checked (key matches, not expired) and
stored in the shared
/etc/unmask volume with the key readable by nobody else; the key is never shown
again, and the domains are read off the certificate's SANs. Renewal is pasting the new pair.
- Files — paths on the nginx container, for a certificate that something else keeps
current (mount it at
/etc/unmask/tls). Mount the same directory on the admin service too, as the
example does, and the domains are read from the certificate; otherwise, with several certificates, list the
domains this one is for so nginx can pick it by SNI (a single certificate needs none).
- Self-signed — what the first start serves when nothing else is configured: a pair the
admin generates (for the hostnames, or
localhost), so the admin is reachable over https right away.
Browsers warn; switch to one of the sources above.
- No https at all — untick https under the tab's Listen on when a load
balancer in front terminates TLS and forwards on :80. The container opens no :443 and needs no certificate;
forwarded https traffic (
X-Forwarded-Proto: https) is served on :80 like :443 would be. Unticking
http instead gives an https-only gateway (no redirect, and no Let's Encrypt, which needs :80).
: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.