Artica Wiki

Install EdgeGuard on a standalone nginx

Running the agent beside an nginx you build and configure yourself: what EdgeGuard touches, the directives you add, the socket, and how to tell it is working.

For an nginx you manage yourself — no Artica appliance needed.
EdgeGuard does not need Artica. The agent runs beside any nginx, on any distribution, and pulls its policy from the same console. This page is what to put in that nginx and how to tell it is working.

What EdgeGuard does and does not touch

It reads requests and answers allow or refuse, through a Unix socket. That is all.

It does not write, rewrite or reload your nginx configuration. On an Artica appliance the agent regenerates vhosts from Artica's own site definitions; on your nginx it does not, because that configuration is yours. From engine 3.0.62 the agent says so once at startup rather than leaving you to wonder:

Running without Artica's reverse-proxy, so nginx vhosts are not regenerated

That line is correct and expected. It is not a warning.

Installing the agent

Start in the console, on the Agents page.
Add Agent, give it a name, choose the sites it protects, and say where it
runs.

The Add Agent dialog, with "On an nginx we manage ourselves" chosen

Sites is required — an agent that protects nothing would run and examine
nothing. Choosing On an nginx we manage ourselves is what makes the console
hand you the one-line install command, with the address of your central and a
one-time password already in it:

curl -fsSL https://artica-ad-agent.b-cdn.net/install-agent.sh \
  | CENTRAL_URL='https://central.example.com:8082' \
    OTP='…' sh

Run it on the machine as root. It checks it can reach central before changing
anything, installs the engine, enrols, starts the service, and then builds the
nginx module for whatever nginx that machine runs.

The password is the whole credential and it is spent on first use. If a
machine was enrolled before, create another agent in the console and use its
command rather than reusing this one.

Installer options

All through the environment, on the same line as the command.

Variable What it does
CENTRAL_URL Where central answers, including the port. Required.
OTP The one-time password from the console. Required, and spent on first use.
CA_CERT=/path/ca.crt Trust a private certificate authority. Prefer this to INSECURE.
INSECURE=1 Skip TLS verification altogether.
ENROLL_FILE=/path.edgeguard Use an enrolment file you already hold instead of fetching it. Needed only if central could not keep the bundle.
SKIP_MODULE=1 Install the engine and stop. Build the nginx module later.
RUN_USER=name Use an existing service account instead of creating artica-edgeguard.
INSTALL_DEPS=0 Never install a package. The module build then fails if a header is missing, rather than touching the machine.

INSECURE=1 means anything on the path can read the one-time password,
and that password is the whole credential: it decrypts the enrolment bundle
and opens the downloads. Use it to get going on a lab, and CA_CERT on
anything you care about. The installer says so on screen when you use it,
which is deliberate.

Building the module separately — after SKIP_MODULE=1, or to rebuild it after
an nginx upgrade — takes a few of its own:

/opt/artica-edgeguard/install-module.sh
Variable What it does
EDGEGUARD_MODSEC=0 Never link ModSecurity. 1 requires it and fails at build time rather than at load time; unset looks for it and says what it found.
MODSEC_PREFIX=/path Where ModSecurity lives. Default /usr/local/modsecurity.
INSTALL_DEPS=0 Refuse to install build dependencies.
KEEP=1 Keep the build directory. Useful when a build fails and you want the logs and the objs/ output.
MODULE_SRC=/path Build from a module source tree other than the bundled one.

Install nginx first if it is not there yet. The agent needs nginx's group
to hand it the socket, and that is granted in the service unit, which is
written at install time — so a machine that gains nginx afterwards needs the
command run again.

Everything it downloads is checked against the published sha256 before being
installed, and nothing on the machine is changed until central has answered.

Valkey or Redis has to be there first. The agent keeps rate-limit
counters and sessions in it and exits at startup if it cannot connect, so the
installer checks before installing anything and names the package for your
distribution. The in-memory alternative works on a single node and is wrong for
a cluster, because each node would then count on its own.

The two things you add

1. In http { } — once per nginx

load_module modules/ngx_http_security_module.so;   # at the TOP of nginx.conf, outside http{}

http {
    security_engine_socket  /run/artica-edgeguard/engine.sock;
    security_engine_timeout 50;    # milliseconds; default 50
    security_pool_size      16;    # pooled connections to the engine; default 16
}

These three are main-context only. Putting them inside a server { } block is a configuration error, and it is the mistake most often made.

security_engine_socket has no default. Without it the module logs security: socket path not configured and lets every request through.

2. In each server { } you want protected

server {
    server_name  www.example.com;

    security_check      on;        # OFF unless you write this
    security_tenant_id  7;         # the tenant's id in the console

    # Optional, with their defaults:
    # security_fail_mode         open;   # open = serve if the engine is down
    # security_cache             on;
    # security_body_preview_size 8k;
}

security_check is off until you switch it on. An agent can be installed, running, green in the console and examining nothing, because no server block ever asked it to. This is the single most common reason a new install sees no events.

security_tenant_id must match the tenant's id on the Tenants page. Traffic reported under the wrong id lands in another site's statistics and is judged by another site's policy.

With ModSecurity, if you run it

security_modsec_score_var $modsec_anomaly_score;

EdgeGuard's handler is registered to run after ModSecurity, so it can read the CRS anomaly score and combine it with its own. Without this directive the two engines simply decide separately.

3. Stop announcing what you run

Not EdgeGuard's doing, and not optional once you have thought about it. A default nginx in front of PHP tells every caller what it is:

server: nginx/1.10.3
x-powered-by: PHP/7.3.10-1+0~20191008.45+debian9~1.gbp365209

That is the exact build, the packaging revision, the distribution and the month it was compiled. On 3 November 2026 a public exploit toolkit was pointed at a site answering like that: it reported the branch as end-of-life and unpatched in half a second, without probing for any of it. After the three lines below, the same run reports cannot determine the PHP version.

http {
    server_tokens off;
    fastcgi_hide_header X-Powered-By;
    proxy_hide_header   X-Powered-By;
}

Three, not one, and the reason matters:

This hides the target, it does not fix it. An end-of-life PHP is still end-of-life, and EdgeGuard refusing the known shapes is a compensating control rather than a patch. Check Vulnerabilities for what is actually exposed.

Reload and confirm, because an nginx reload is also where a dynamic module quietly stops being loaded:

nginx -t && systemctl reload nginx
curl -sI https://your-site.example | grep -iE 'server|powered|edgeguard'

You want server: nginx, no x-powered-by, and x-edgeguard-id still present — the last one is what says the module survived the reload.

The socket, and how nginx is let in

The engine creates the socket at mode 0660. nginx has to be able to open it, and from engine 3.1.6 that is arranged by group, which needs no privilege and changes nothing about nginx.

The reason is a Unix asymmetry worth knowing. Changing a file's owner needs root. Changing its group does not, as long as you own the file and belong to the group you are asking for. The agent owns the socket, so it puts the socket in nginx's own group, and nginx opens a 0660 socket it does not own — the same arrangement as /var/run/docker.sock.

The agent is given that group in its own service unit, which the installer writes:

[Service]
User=artica-edgeguard
SupplementaryGroups=www-data

Nothing is written to /etc/group, and nginx does not have to be restarted. The other direction — putting nginx's user in the agent's group — also works, but it changes nginx, and a worker reads its supplementary groups only when it starts, so it would cost an nginx restart on a live host for no gain.

If you install nginx after the agent, run the install command again. The group is granted in the unit, and the unit is written at install time, so it cannot appear by itself.

The agent runs unprivileged, so the chown to nginx's user fails with operation not permitted. That alone is not a fault, and from 3.1.6 the agent does not report it as one: it measures whether nginx can open the socket — owner, group, mode, and the full group list of nginx's user — and says something only when the answer is actually no. When it is, the log carries the command for your machine:

ERROR Nginx will not be able to open the engine socket
  why="/run/artica-edgeguard/engine.sock is artica-edgeguard:artica-edgeguard
       mode 0660, and www-data is neither the owner nor in the group"
  fix="add 'SupplementaryGroups=www-data' to the [Service] section ..."

It is an error and it reaches the console on the next heartbeat, because nginx being unable to open the socket means nothing is examined at all. If your nginx runs as a user the agent cannot guess, name it in config.json:

{ "socket_owner": "myuser", "socket_group": "myuser" }

Telling whether it actually works

On the Agents page, the NGINX MODULE column is the only one that answers this. STATUS says the agent is talking to the console; it says nothing about whether nginx is talking to the agent.

The fleet table: STATUS and NGINX MODULE answer different questions

What you see What it means
a version, e.g. 1.0.0 nginx is consulting the agent — it is working
not consulting no request has ever reached the agent from nginx
a version plus no recent request it was being consulted, and nothing for over six hours

A green agent with not consulting means your traffic is being served without being examined.

nginx -V will never show the module

This is worth saying plainly because it is the first thing everyone checks. nginx -V prints NGX_CONFIGURE, a string fixed into the binary when nginx itself was compiled — on a distribution package, the line its build machine ran years ago. A module loaded afterwards with load_module cannot appear there, ever. Nor can the module change it. Your nginx -V output will look identical before and after a successful install.

What does answer the question, weakest to strongest:

# 1. the file is there
ls -l /usr/lib/nginx/modules/ngx_http_security_module.so

# 2. the configuration asks for it, and nginx accepts the configuration
nginx -T 2>/dev/null | grep -n security
nginx -t

# 3. the RUNNING process has it mapped — only true after a reload
grep -c ngx_http_security /proc/$(cat /run/nginx.pid)/maps

# 4. a request was actually examined
curl -sI https://your-site/ | grep -i x-edgeguard-id

Number 4 is the real test. Every reply EdgeGuard examined carries an x-edgeguard-id header, and that id is what ties the request to its event in the console. A reply without one was never examined at all — which is the worst of the possible states and the one that looks most like nothing is wrong.

The three causes, in the order worth checking

  1. nginx was not reloaded. nginx -T reads the configuration files; it does not prove the running process loaded them. Check 3 above does.
  2. security_check is not on for that server block. It is off unless you write it.
  3. nginx cannot open the socket. The agent's log says so explicitly, with the fix for your machine — see the socket section above.

Building the module for your nginx

There is a script for this, and it is the recommended path. It reads this machine's nginx -V, fetches that exact source, replays that exact configure line, builds, checks what the result depends on, installs it, and asks nginx to load it before declaring success. It does not touch nginx.conf — at the end it prints what to add.

sudo ./install-module.sh

It stops before doing anything if the compiler or the development headers are missing, and names the packages. If nginx refuses the module it removes it again and restores whatever was there before.

The rest of this section is what the script does, for anyone who would rather do it by hand or needs to understand a failure.

Why it cannot be one prebuilt file

A dynamic module loads only into an nginx whose module signature matches its own. That signature is built from the nginx version and from the build-time features, so the rule has no exceptions: compile against the same nginx version, with the same configure arguments. nginx -V prints both.

Does your nginx have --with-compat?

nginx -V 2>&1 | grep -o -- --with-compat

This decides which of two recipes applies, and getting it wrong produces a .so that nginx refuses with "module is not binary compatible".

--with-compat arrived in nginx 1.11.5. It normalises the signature so a module built elsewhere can load — but only when the nginx you are loading into was itself built with it. It is not something you simply add to your own build.

Distributions differ, and not in the way you would guess:

nginx --with-compat
Debian 9 (stretch) 1.10.3 no — predates the option
Debian 10 (buster) 1.14.2 no — the option exists, the package does not use it
Debian 12 (bookworm) 1.22.1 yes
anything else — check with the command above

With --with-compat

The easy case. The signature is normalised on both sides, so you need the right version and nothing else from its configure line — none of its paths, modules or compiler flags matter.

cd /path/to/nginx-source    # the SAME version as nginx -V reports
./configure --with-compat --add-dynamic-module=/path/to/ngx_http_security_module
make modules

Without it

Reuse its own configure line verbatim and add ours. Nothing may be dropped: --with-debug and the --with-cc-opt string are part of the signature too, and --with-cc-opt='...' is a single argument that must stay in one piece.

One thing must be removed: the distribution's own --add-dynamic-module arguments. They point inside the build machine's tree — on Debian, paths like /build/nginx-m1Thpq/nginx-1.14.2/debian/modules/http-auth-pam — which exist nowhere else, so configure stops at the first one.

Removing them is not free, and the installer handles the consequence for you. Those add-on modules can set signature macros of their own: on Debian 9 one of them sets NGX_HTTP_HEADERS, which changes the layout of nginx's internal request structure and is part of the signature. Drop the arguments and you drop the macro, and nginx then refuses the module — correctly, because the two no longer agree about memory.

From central 2.7.3 the installer reads the signature out of both files, works out which bit differs using the position-to-macro map in that nginx's own src/core/ngx_module.h, appends the matching -D flags to the existing --with-cc-opt and rebuilds once:

- The module's signature differs from nginx's; matching it:
    NGX_HTTP_HEADERS = 1
- rebuilding with -DNGX_HTTP_HEADERS=1
+ Signature now matches nginx's

Doing it by hand is the same idea: strings both files, compare the digits after 8,4,8,, and define what is missing.

cd /path/to/nginx-source    # the SAME version as nginx -V reports
./configure <every argument from nginx -V, minus --add-dynamic-module=...> \
            --add-dynamic-module=/path/to/ngx_http_security_module
make modules

Then, either way

cp objs/ngx_http_security_module.so /etc/nginx/modules/
nginx -t && systemctl reload nginx

A reload is enough. Nothing about nginx's own user or groups changes when the agent hands over the socket, so there is nothing for a restart to pick up.

"module is not binary compatible" means the signature did not match. It is never a problem with the module's code. The signature is a literal string in both files — the pointer, atomic and time_t sizes, then one digit per build-time feature — so you do not have to guess which one:

strings /usr/sbin/nginx | grep -E '^[0-9]+,[0-9]+,[0-9]+,[01]+$' | head -1
strings objs/ngx_http_security_module.so | grep -E '^[0-9]+,[0-9]+,[0-9]+,[01]+$' | head -1

The position where they differ names the feature, and the map is in that nginx's src/core/ngx_module.h. Replaying the line from nginx -V is usually enough, but not always: a macro set by an add-on module you had to drop has to be defined with -D.

ModSecurity is optional

The build looks for ModSecurity and tells you which variant it made:

  EdgeGuard: building WITHOUT ModSecurity (none found at /usr/local/modsecurity)
    The CRS anomaly score still reaches the engine through
    security_modsec_score_var if you run ModSecurity separately.

Until now it always linked libmodsecurity and referenced the ModSecurity-nginx connector, and either one stops the module loading on a machine that does not run ModSecurity — ld.so refuses the file before nginx can look at it. That is why the module would not install beside an nginx from apt or yum.

Setting Effect
EDGEGUARD_MODSEC=0 never link it
EDGEGUARD_MODSEC=1 require it, and fail at build time rather than at load time
unset look for it, and say what was found
MODSEC_PREFIX=/path where ModSecurity lives; default /usr/local/modsecurity

A build without ModSecurity loses one shortcut — reading the anomaly score straight out of the transaction — and keeps the feature, because security_modsec_score_var reads the same number from an nginx variable.

Log lines that look like faults and are not

On a machine without Artica, three parts of the agent have nothing to talk to. All three say so once, at INFO, and none of them is a problem:

The line says What it means
Running without Artica's reverse-proxy, so nginx vhosts are not regenerated Correct here. EdgeGuard applies policy through the module and the socket and leaves your nginx.conf alone.
Not shipping WAF events to Artica's SIEM relay, because Artica is not installed Events reach the console through the agent's own shipper, which is what the console reads. Nothing is lost.
Socket left owned by this service account; nginx reaches it through the group The chown needed privilege the agent does not have and does not need.

Before engine 3.1.7 the second one was different: it retried the relay every few seconds for ever, warning each time about /run/reverse-proxy.sock, a socket that can never exist on your machine. Backing off and retrying is right for a relay that is down and will come back; it was wrong for one that was never there.

Keeping the engine up to date

Press Update engine on the agent in the console and it downloads, verifies the SHA-256, runs the new binary once with -version to prove it starts on your distribution, and replaces itself.

The fleet-wide equivalents are under Actions, which is also where you force every agent to re-read the rule feed rather than waiting for its own six-hour timer.

The Actions menu: the fleet-wide maintenance commands

The last step is the interesting one on a machine you own. The agent runs unprivileged, and asking systemd to restart a service needs root or polkit:

Failed to restart artica-edgeguard-agent.service: The name
org.freedesktop.PolicyKit1 was not provided by any .service files

Stopping, though, is something any process may do, and starting it again is the service manager's job. So from engine 3.1.8 the agent exits instead and lets systemd bring it back — five seconds after the result has reached the console, so the record of a successful update is not lost with the process. The unit is written Restart=always.

The acknowledgement says "restart requested", never "restarted". A process cannot watch itself come back, and the version on the next heartbeat is what confirms an update. Do not read the byte count as confirmation either: two different versions can be the same size, and they have been.

If an agent is too old to update itself, replace the binary by hand:

cd /tmp && curl -fsSLO https://artica-ad-agent.b-cdn.net/artica-edgeguard-engine.tar.gz
sha256sum artica-edgeguard-engine.tar.gz    # compare with the console
tar xzf artica-edgeguard-engine.tar.gz
install -m755 artica-edgeguard-engine /opt/artica-edgeguard/bin/artica-edgeguard-agent
systemctl restart artica-edgeguard-agent

The file inside the archive is called artica-edgeguard-engine and the installed one artica-edgeguard-agent. Same binary, different name.

Checking it for yourself

The repository carries two batteries you can run against a host you operate:

node scripts/live-waf-test/php-rce.js          your-host
node scripts/live-waf-test/cve-2026-109618.js  your-host

The first sends 22 payloads — PHP wrappers, traversal, command execution, webshell paths, CVE-2017-9841, Shellshock — and prints each status with its x-edgeguard-id. The first row is a plain request and must stay 200: without it, "everything blocked" and "the site is down" are the same measurement.

The second checks the trigger for PHP2Shell in every shape the field can arrive in, and includes two cases that must not be blocked. It deliberately does not run the published exploit, which drives an out-of-memory condition and kills server processes — that belongs in a disposable lab, not in front of anything you care about.