# unmask image: the gateway in one container -- the official nginx image with # the unmask module, plus the unmask daemon, supervised by one entrypoint. # Published per release as ghcr.io/unmask-sh/unmask: and mirrored at # unmask.sh/unmask. # # docker run -d --name unmask -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" # -> https:///unmask/admin/ (the install wizard; paste the token) # # Inside: the daemon (admin UI, challenge verification, ban list, stats) # renders the nginx includes into /etc/unmask and listens on 127.0.0.1:9477; # nginx terminates TLS (that is where JA4 comes from), classifies, and either # serves the challenge or proxies to the upstream (Settings > Gateway). # docker/gateway-entrypoint.sh starts the daemon, waits for it, starts nginx # through the stock entrypoint, and keeps both up. # # The module is built from source against the exact nginx version of the # runtime image, with --with-compat, so the signature matches the official # binary. NGINX_VERSION must be a tag that exists at nginx.org AND on Docker # Hub. Multi-arch: docker buildx build --platform linux/amd64,linux/arm64 . ARG NGINX_VERSION=1.30.6 # ------------------------------------------------------------------------- # build stage: the Go static binary # ------------------------------------------------------------------------- FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine AS build ARG TARGETOS=linux ARG TARGETARCH=amd64 ARG UNMASK_VERSION=docker WORKDIR /src RUN apk add --no-cache git ca-certificates # COPY module files first to leverage the go mod cache. COPY admin/go.mod admin/go.sum ./admin/ # The module go.mod replaces the SQLite driver with (see admin/sqliteshim): # `go mod download` reads its go.mod, so it comes with the two above. COPY admin/sqliteshim/ ./admin/sqliteshim/ RUN cd admin && go mod download COPY admin/ ./admin/ RUN cd admin && \ CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ go build -trimpath -ldflags="-s -w -X main.Version=$UNMASK_VERSION" \ -o /out/unmask ./cmd/unmask # ------------------------------------------------------------------------- # module stage: compile only the module, against this nginx version # ------------------------------------------------------------------------- FROM debian:bookworm-slim AS modbuild ARG NGINX_VERSION RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential libssl-dev libpcre2-dev zlib1g-dev curl ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /src # The tarball is checked against the sha256 pinned in the repo (the same file # the Makefile's module build verifies) before it is unpacked: a version with # no pin fails the build, so does a tarball that does not match. COPY nginx-module/nginx-sha256sums.txt /src/nginx-sha256sums.txt RUN curl -fsSL -o "nginx-${NGINX_VERSION}.tar.gz" "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" \ && grep -E "^[0-9a-f]{64} nginx-${NGINX_VERSION}\.tar\.gz$" nginx-sha256sums.txt | sha256sum -c - \ && tar -xzf "nginx-${NGINX_VERSION}.tar.gz" \ && rm "nginx-${NGINX_VERSION}.tar.gz" COPY nginx-module/ /src/nginx-module/ COPY tools/check-so-hardening.sh /src/check-so-hardening.sh # The hardening options are the ones every build of the module asks # nginx-module/hardening.sh for, and the check reads them back out of the .so. RUN cd "nginx-${NGINX_VERSION}" \ && ./configure --with-compat \ --with-http_ssl_module \ --with-http_realip_module \ --with-http_auth_request_module \ --with-cc-opt="$(sh /src/nginx-module/hardening.sh cc)" \ --with-ld-opt="$(sh /src/nginx-module/hardening.sh ld)" \ --add-dynamic-module=/src/nginx-module \ && make -j"$(nproc)" modules \ && sh /src/check-so-hardening.sh objs/ngx_http_unmask_module.so # ------------------------------------------------------------------------- # runtime: the official nginx image + the module + the daemon # ------------------------------------------------------------------------- FROM nginx:${NGINX_VERSION} ARG NGINX_VERSION LABEL org.opencontainers.image.source="https://github.com/unmask-sh/unmask" \ org.opencontainers.image.description="unmask: the bot-challenge gateway (nginx ${NGINX_VERSION} with the JA4 module, plus the unmask daemon)" \ org.opencontainers.image.licenses="Apache-2.0" COPY --from=modbuild /src/nginx-${NGINX_VERSION}/objs/ngx_http_unmask_module.so \ /etc/nginx/modules/ngx_http_unmask_module.so # The challenge page is served by nginx itself (the module rewrites to it), # same path as the host packages use. COPY admin/assets/static/challenge.html admin/assets/static/challenge.js /usr/share/unmask/challenge/ # The license and the notices travel with the image as with the packages # (/usr/share/doc/unmask/ there too): the daemon carries MIT / BSD / ISC / # MPL-2.0 code and MIT data whose terms ask for them in a binary distribution. COPY LICENSE NOTICE THIRD-PARTY-NOTICES.md /usr/share/doc/unmask/ # load_module has to sit in main context; prepend it to the stock nginx.conf # rather than replacing the file, so upstream changes to that file keep # flowing through. The official image ships nginx's own ACME module; loading # it costs nothing when unused and lets the gateway do automatic HTTPS with # one variable (UNMASK_ACME_EMAIL). Its state directory must be writable by # the worker user and should persist (a volume). RUN sed -i '1i load_module modules/ngx_http_unmask_module.so;\nload_module modules/ngx_http_acme_module.so;' /etc/nginx/nginx.conf \ && mkdir -p /etc/unmask /run/unmask /etc/unmask/tls /var/cache/nginx/unmask-acme \ && chown nginx:nginx /var/cache/nginx/unmask-acme \ && nginx -t # .envsh: the stock entrypoint sources it, so the defaults it exports reach # the envsubst step that renders the gateway template. COPY --chmod=0755 docker/nginx/10-unmask-gateway.envsh /docker-entrypoint.d/10-unmask-gateway.envsh COPY docker/nginx/gateway.conf.template /usr/share/unmask/gateway.conf.template COPY docker/nginx/gateway-includes.sh /usr/share/unmask/gateway-includes.sh COPY docker/nginx/gateway-http.conf.template /usr/share/unmask/gateway-http.conf.template COPY docker/nginx/gateway-https.conf.template /usr/share/unmask/gateway-https.conf.template # Reloads nginx when the daemon re-renders the includes, so a settings change # applies without anyone running `nginx -s reload`. COPY --chmod=0755 docker/nginx/30-unmask-autoreload.sh /docker-entrypoint.d/30-unmask-autoreload.sh RUN test -x /docker-entrypoint.d/10-unmask-gateway.envsh && test -x /docker-entrypoint.d/30-unmask-autoreload.sh # The daemon: a pure-Go static binary, its own user (the binary drops to it # right after start, as under systemd), and the directories it owns. It is # a member of nginx's group so the access-log socket it binds can be shared # with nginx's worker by group (0660), as the service unit arranges on a # host install. # /run/unmask holds the sockets nginx's worker reaches: the daemon's, shared # with nginx's group at 0750 (the daemon sets it so at bind, as the service # unit's RuntimeDirectoryMode does on a host install). RUN useradd --system --user-group --groups nginx --home-dir /var/lib/unmask --shell /usr/sbin/nologin unmask \ && mkdir -p /var/lib/unmask /var/log/unmask \ && chown -R unmask:unmask /var/lib/unmask /var/log/unmask /etc/unmask /run/unmask \ && chown unmask:nginx /run/unmask && chmod 0750 /run/unmask COPY --from=build /out/unmask /usr/local/bin/unmask # First boot: a minimal config.yml (the wizard fills in the rest), migrate, # render; then the daemon. The same script the daemon-only image used. COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/unmask-daemon.sh # Both processes, one PID 1: see the script for what it does when one dies. COPY --chmod=0755 docker/gateway-entrypoint.sh /usr/local/bin/gateway-entrypoint.sh # This image IS the gateway: on by default (UNMASK_GATEWAY=0 turns the # container into nginx-with-module plus the daemon, your own conf.d applies). # The daemon and nginx share the container, so the addresses are loopback. ENV UNMASK_GATEWAY=1 \ UNMASK_DAEMON_ADDR=127.0.0.1:9477 \ UNMASK_GATEWAY_ADDR=127.0.0.1:443 EXPOSE 80 443 VOLUME ["/etc/unmask", "/var/lib/unmask", "/var/cache/nginx/unmask-acme"] HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=6 \ CMD curl -fsS -o /dev/null http://127.0.0.1:9477/unmask/healthz && test -f /run/nginx.pid STOPSIGNAL SIGTERM ENTRYPOINT ["/usr/local/bin/gateway-entrypoint.sh"]