unmask

install

Pick how unmask sits in front of your site — the nginx module or the gateway container — and your OS, to get the exact yum / apt / apk / docker compose commands and config snippet.

0 Choose where unmask sits

Click a card; the steps below follow it.

nginx native module

The dynamic module goes into the nginx you already run; decisions happen inside the nginx process, which makes it the fastest shape.

  • ✓ Once a client has passed the challenge, it runs at the same speed as bare nginx (the server just verifies a short-lived pass cookie)
  • ✓ ~0.05 ms per request (cookie verification within the same process. No subrequest / no IPC)
  • ✓ rate_limit / honeypot paths are handled entirely within nginx
  • △ nginx only (anything else: the gateway container)

gateway container

A container bundle — nginx with the module built in, plus the unmask daemon — placed in front of your existing HTTP server; nothing behind it changes.

  • ✓ In front of any HTTP server (Apache, Caddy, Node, Rails, IIS, another nginx)
  • ✓ Sees every visitor's TLS fingerprint (JA4) (the container terminates TLS, with the native module inside)
  • ✓ HTTPS handled for you (Let's Encrypt automatically, or paste the certificate you bought into the admin)
  • △ Needs Docker (one container: nginx with the module, plus the daemon — docker run or one compose service)

Apache or another server, and no Docker? Put nginx in front of it and follow the nginx native module steps.

1 Choose your environment

Every option above is CI-verified. Derivative distros (openSUSE / Mint / Pop!_OS / etc.) and the full matrix live on the supported distro list.

2 Install the unmask core ?repo method: install unmask-release once first to set up the repo config + GPG public key → from then on you can install / upgrade the core with dnf install unmask / apt install unmask. Signature verification is handled automatically by the package manager.

Right after install: the unmask daemon starts on its own (127.0.0.1:9477), and the package prints the setup token and keeps it in /var/lib/unmask/.setup-token. The setup wizard launches in section 4.

Upgrade: sudo dnf upgrade 'unmask*' (on deb, sudo apt upgrade; on Alpine, sudo apk upgrade). The package restarts the daemon itself.

sudo dnf install -y https://unmask.sh/dl/rpm/unmask-release-latest.noarch.rpm
sudo dnf install -y unmask
sudo yum install -y https://unmask.sh/dl/rpm/unmask-release-latest.noarch.rpm
sudo yum install -y unmask
sudo yum install -y https://unmask.sh/dl/rpm/unmask-release-latest.noarch.rpm
sudo yum install -y unmask
curl -fsSLO https://unmask.sh/dl/deb/unmask-release-latest.deb
sudo apt install -y ./unmask-release-latest.deb
sudo apt update && sudo apt install -y unmask
wget https://unmask.sh/dl/apk/unmask-release-latest.apk
sudo apk add --allow-untrusted ./unmask-release-latest.apk
sudo apk update && sudo apk add unmask

3 Install unmask-plugin-nginx (native plugin) ?Prerequisite: nginx must be installed (auto-pulled in step 4 via unmask-web-nginx dependencies).

postinstall: places load_module into the distro's conventional dir (/usr/share/nginx/modules/ or /etc/nginx/modules-enabled/) automatically. 15 nginx versions × OpenSSL 1.0 / 1.1 / 3 ABIs are bundled (1.10–1.30 series / glibc) so the matching module is picked at install time. No manual editing.

After an nginx upgrade: ABI mismatch surfaces as nginx -t errors → sudo dnf upgrade unmask-plugin-nginx (or sudo apt install --only-upgrade unmask-plugin-nginx on deb) realigns versions.

sudo dnf install -y unmask-plugin-nginx
sudo yum install -y unmask-plugin-nginx
sudo yum install -y unmask-plugin-nginx
sudo apt install -y unmask-plugin-nginx
sudo apk add unmask-plugin-nginx
RHEL / AlmaLinux / Rocky 8: the default nginx there (the 1.14 module stream) is built without --with-compat, so it cannot load the module. Before this step, switch to a newer stream from the same AppStream repo (this also upgrades an nginx 1.14 that is already installed). 9 and 10 need nothing.
sudo dnf module reset -y nginx
sudo dnf module install -y nginx:1.24
CentOS 7 / RHEL 7: nginx is not in the OS stock (base) repo, so install it from EPEL first.
sudo yum install -y epel-release
sudo yum install -y nginx
CentOS 6 / RHEL 6: nginx is not in base, but EPEL has 1.10.3 (matches the oldest ABI bundled in the fat plugin). If you want something newer, the nginx.org HTTP repo (1.18.0) works too. Run either block below.
# A. via EPEL (nginx 1.10.3 / within the OS ecosystem. recommended)
sudo rpm -Uvh http://archive.kernel.org/centos-vault/6.10/extras/x86_64/Packages/epel-release-6-8.noarch.rpm
sudo yum install -y nginx
# B. nginx.org HTTP repo (nginx 1.18.0 / newer)
sudo tee /etc/yum.repos.d/nginx.repo <<'EOF'
[nginx]
name=nginx repo
baseurl=http://nginx.org/packages/centos/6/x86_64/
gpgcheck=0
enabled=1
EOF
sudo yum install -y nginx

4 Install unmask-web-nginx (web config snippet) ?postinstall places the upstream symlink and the include described below, validates with nginx -t, and prints the setup wizard URL. It does not touch the running nginx — the wizard URL becomes reachable after the restart you run below.

The JA4 maps include is fully automatic: postinstall places a symlink at /etc/nginx/conf.d/00-unmask.conf → /var/lib/unmask/nginx/http.inc. Since every distro's default include /etc/nginx/conf.d/*.conf; auto-loads it (/etc/nginx/http.d/ on Alpine), you do not need to edit the http {} block.

sudo dnf install -y unmask-web-nginx
sudo yum install -y unmask-web-nginx
sudo yum install -y unmask-web-nginx
sudo apt install -y unmask-web-nginx
sudo apk add unmask-web-nginx
3 ways to open the setup wizard — pick the one that fits your environment and open /unmask/admin/setup/.
  1. [Recommended] Add one line to an existing vhost — open the setup wizard on the domain you already use. Inside the target server { } block, add the blue (highlighted) line:
    server {
        listen 443 ssl http2;
        server_name example.com;
        ssl_certificate ...;
    
        include /var/lib/unmask/nginx/server.inc;   # ← unmask itself (challenge serving, BAN, admin UI)
    
        root /var/www/example;
    }
    → sudo nginx -t && sudo systemctl restart nginx (RHEL/CentOS 6: sudo service nginx restart; Alpine: sudo rc-service nginx restart; a restart — not reload — is what loads the freshly installed module) → open https://<your-domain>/unmask/admin/setup/.
  2. [Direct port] — nothing to change if you can shell into the host itself: http://127.0.0.1:9477/unmask/admin/setup/ is reachable out of the box. From a different host, use the SSH tunnel below.
  3. [SSH tunnel] — a universal fallback that needs no firewall changes.
    ssh -L 9477:127.0.0.1:9477 your-server -N &
    → open http://localhost:9477/unmask/admin/setup/.

A challenge on the setup URL only means the admin UI (/unmask/admin/) is protected. Your site itself stays unprotected until you add protect.inc in the next step.

The wizard: setup token (printed when the unmask package was installed, or sudo cat /var/lib/unmask/.setup-token) → database (the SQLite default is enough) → admin account → review. It then drops you on the admin home; remaining settings live under /unmask/admin/settings/.

5 Fire the challenge in the server {} you want to protect (ON/OFF switch) ?These highlighted lines are unmask's ON/OFF switch. Add them and bot protection fires; remove them and blocking / challenges stop immediately, with no effect on user traffic. The access-log statistics feed keeps flowing either way, so a safe rollout is step-by-step: monitoring (stats only) → trial (fire on one location) → production (fire site-wide).

What you actually write is 2 lines: include /var/lib/unmask/nginx/server.inc; directly inside server { }, and include /var/lib/unmask/nginx/protect.inc; wherever you want protection. Everything else (rate-limit zones, challenge dispatch, JA4 maps) is rendered by the admin into /var/lib/unmask/nginx/ and auto-loaded via the conf.d symlink placed in section 4.

Rate / burst / target paths live on the Settings → rate-limit tab. To restrict which domain can reach the admin UI, use Settings → Network → admin_allowed_hosts.

scope:

In the target server {} block (the .conf of your existing nginx vhost), add the blue (highlighted) lines. Everything else is your existing config — a reverse proxy is shown, but a static site (root + try_files) adds the same lines.

No need to edit existing locations — just add this one block.

server {
    listen 443 ssl http2;
    server_name example.com;
    ssl_certificate ...;

    include /var/lib/unmask/nginx/server.inc;   # ← unmask itself (challenge serving, BAN, admin UI)
    include /var/lib/unmask/nginx/protect.inc;  # ← outside any location (protects every location)

    location / {                              # ← your existing location stays unedited
        proxy_pass http://your-upstream;
    }
}

Once you're done editing, apply the config. If you have not restarted nginx since installing the plugin, restart (a reload does not load a new module); afterwards plain reloads are enough:

sudo nginx -t && sudo systemctl restart nginx
sudo nginx -t && sudo service nginx restart
sudo nginx -t && sudo rc-service nginx restart

From 0.1.53 the reload after a settings save can run by itself: sudo systemctl enable --now unmask-nginx-reload.timer. The timer ships disabled with unmask-web-nginx; every five minutes it reloads nginx when a render changed the configuration and nginx -t passes, at most once per ten minutes, and never restarts it; while the nginx master still maps a library an OS update has replaced, it logs that a restart is needed instead of reloading. Without systemd, run /usr/share/unmask/nginx-reload-if-stale.sh from cron.

1 Get the compose file ?One container, one file: unmask is the gateway — the official nginx image with the unmask module, plus the daemon (admin UI, challenge verification, ban list, stats), kept up by one entrypoint. The image comes from unmask.sh/ (served like the packages; mirrored at ghcr.io/unmask-sh/), and a stand-in app is included so the example answers on its own.

The volumes matter: /etc/unmask holds the rendered nginx config and the secrets that sign pass cookies, /var/lib/unmask is the database, /var/cache/nginx/unmask-acme the Let's Encrypt state. Keep them across upgrades.

Upgrade: docker compose pull && docker compose up -d. An install from 0.1.47 or earlier ran two containers; moving it takes a few extra steps, listed in Containers (docs) under image tags.

Runs on any Linux with Docker Engine and Compose v2 (Docker Desktop on macOS / Windows too) — there is no OS / distro to choose.

mkdir unmask && cd unmask
curl -fsSLO https://unmask.sh/dl/docker/docker-compose.yml

2 Start it and pick up the token ?The first start brings :443 up with a self-signed certificate (your browser warns once). The upstream, the hostnames and the real certificate are all set in the admin UI in the next step (Settings → Gateway), so the compose file needs no editing.

The gateway forwards passing requests with Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto, passes WebSocket upgrades, and sets no body-size limit of its own.

If a server is already listening on :80 / :443 on this host, move it off those ports first — the gateway takes :80/:443, and the start below fails on a port clash otherwise. Apache: Listen 8080, nginx: listen 8080;, then reload (bound to an address the container can reach, so not 127.0.0.1; keep 8080 closed at the firewall). From here until your server is set as the upstream in step 3, visitors who pass reach the example's stand-in app (it echoes the request back) instead of your site, for a few minutes. Two-proxy details are in Containers (docs); already running Nginx Proxy Manager, Traefik, Caddy or a Cloudflare Tunnel? The FAQ shows the two places unmask can go. On a fresh host, skip this paragraph.

docker-compose.yml works as it is. Start it and pick up the setup token from the log:

docker compose up -d
docker compose logs unmask | grep "setup token"

3 Finish the wizard, then set up the Gateway ?The admin UI sits behind the same protection as the rest of the site, so the first thing you see on the setup URL is a short security check; pass it and the wizard follows. Only /unmask/admin/ is challenged at this point. The example compose file starts with its stand-in app as the upstream; once you enter your server, your site is served as before. (Started without any upstream, as a plain docker run is, the gateway answers with a notice saying so.)

Certificates: Settings → Gateway shows what :443 is serving right now (issuer, expiry, whether the name matches). Pick Let's Encrypt and enter a contact address (the hostname must resolve to this host, with :80 reachable; the account is created on the first issuance), or paste a purchased certificate there (certificate, chain, key — the key is stored readable by nobody else and never shown again); a renewal is pasting the new pair.

Open https://<this host>/unmask/admin/setup/ (accept the self-signed certificate warning once) and finish the wizard — setup token (from docker compose logs unmask above; the log shows it on the first start only, and docker compose exec unmask cat /var/lib/unmask/.setup-token prints it any time until setup is done) → database (the SQLite default is enough) → admin account → review. Then:

Settings → Gateway — enter your server as the upstream; three examples (a server on this host / another host / a container in the same compose) sit right under the field. A server on this host is the one you moved to 8080 in step 2 (http://host.docker.internal:8080). The hostnames can stay at "any" or be narrowed to the ones visitors use; switch the certificate from self-signed to Let's Encrypt or a pasted one.

Every save is applied by the container on its own within a few seconds, and protection is on for everything the gateway proxies. That is all the Gateway tab holds (upstream, hostnames, certificate). The protection itself — the ban list, rate limits, geo / ASN rules, how the challenge behaves — starts out on its defaults; tune it in the other tabs of the same settings screen.

6 Verify + troubleshoot

6.1 Force-fire the challenge
# plain curl — no browser TLS, no JS. It should be served the challenge
curl -sk https://example.com/ | grep -oE 'unmask|challenge' | head -1
# → if "unmask" appears, the challenge HTML is being served
6.2 Confirm events are flowing in on the dashboard

The Stats tab → the "Challenge funnel" card. Success if serve / load / pow / captcha are increasing.

resilience: if the unmask daemon stops, the site fails open — already-passed visitors keep flowing, and not-yet-passed visitors skip PoW / CAPTCHA and still get the page they asked for (the nginx module and the gateway container alike). For a deliberate observe-only rollout, the admin's Operating mode tab has a full pass-through (monitoring) mode.
6.3 Deployments with TLS terminated at an upstream LB / CDN

When TLS is terminated at an LB / CDN, extract the real client's JA4 on the LB and forward it via an X-Client-JA4 header. Per-LB / CDN setup lives in Capturing JA4 from an LB (docs) ↗. Behavior when JA4 is empty: What JA4 does (docs) ↗.

6.4 Common snags
  • git, a sync client or a mobile app stopped working → clients that cannot run JavaScript cannot pass the challenge. Let the paths they use (an API, a WebDAV or git endpoint) through under Settings → Bypass paths; a Git forge has a Git over HTTP preset there, and the FAQ lists the paths for common apps.
  • 403 forbidden appears → add your IP / CIDR to admin_allowed_ips in settings → Network tab
  • The JA4 column in the log is empty → check the load_module config; with TLS terminated at an LB / CDN, see 6.3 (the LB's X-Client-JA4 header forwarding). A verdict of "(none)" is normal: it means no fingerprint rule matched, which is true of ordinary browsers
  • module ABI mismatch on nginx -t → nginx was upgraded but the module didn't follow. Restart nginx: on systemd hosts the plugin picks the module that matches the installed nginx at every start (on RHEL/CentOS 6 and Alpine, run sudo sh /usr/share/unmask/plugin/place-module.sh before the restart). If your nginx version is newer than any the plugin carries, upgrade unmask-plugin-nginx once a release includes it
  • The challenge HTML doesn't show → check that unmask is listening on 127.0.0.1:9477 (systemctl status unmask; RHEL/CentOS 6: service unmask status; Alpine: rc-service unmask status)
  • 502 from the gateway container → the upstream (Settings → Gateway) is not reachable from inside the container. A server on the Docker host is http://host.docker.internal:8080, bound to an address the container can reach (not 127.0.0.1). A "no upstream yet" notice means the upstream has not been entered at all
  • The gateway serves no / the wrong certificate → Settings → Gateway shows what :443 is serving. Automatic HTTPS needs the hostname to resolve to this host and :80 reachable from the internet; a purchased certificate is pasted on the same tab