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:
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.
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).
Variable
Meaning
Default
UNMASK_GATEWAY
Gateway 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_UPSTREAM
Where 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_NAME
The 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_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) in the container, when not using ACME.
/etc/unmask/tls/fullchain.pem
UNMASK_TLS_KEY
Private key (PEM) in the 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. 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:
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 /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 in the container, for a certificate that something else keeps
current (mount it at /etc/unmask/tls, as the example does); the domains are read from the
certificate.
Self-signed — what the first start serves when nothing else is configured: a pair the
daemon generates (for the hostnames, or localhost), so the admin UI 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 → 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
unmask.sh/unmask:<unmask version>, :latest — amd64 / arm64. nginx inside is
1.28.3 from the official image; the module is built against it in the same Dockerfile, so a new nginx
version is a one-line change. Mirrored at ghcr.io/unmask-sh/unmask.
Before 0.1.48 the daemon and nginx were two images (unmask.sh/unmask, before 0.1.47 named
unmask.sh/admin, and unmask.sh/nginx) and two compose services sharing volumes. To
move such an install: take the new compose file; in the config volume, rename admin.yml to
config.yml if it still has the old name (0.1.46 and older — without it the container starts
with a fresh config and new secrets), and set nginx.upstream_addr there to
127.0.0.1:9477 (the daemon is in the same container now; the old file said
unmask:9477); then docker compose up -d --remove-orphans, so the old nginx container
gives up ports 80 and 443. The config and data volumes carry over; the unmask-run volume is no
longer used.
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.