Skip to content

How to Rate Limit Contact Form Spam in WordPress with Nginx (Trellis Setup)

By Jasper Frumau DevOps

Your WordPress contact form plugin has spam protection — reCAPTCHA, honeypots, maybe a quiz field. And yet the spam keeps coming. That is because most anti-spam measures only work when the bot actually executes JavaScript and renders the form. A large portion of form spam never touches your frontend at all. Bots send a raw POST request directly to your form handler endpoint, skipping the page entirely, and your carefully configured honeypot never sees it.

The fix is not another WordPress plugin. It is a server-level rate limit in Nginx that throttles form submissions before they reach PHP. If an IP submits more than two forms per minute, Nginx drops the connection with a 429 and WordPress never has to process the request. Here is how we set this up on a Trellis stack — and the gotcha that made our first attempt miss half the spam.

Quick Summary: WordPress contact forms accept submissions through two paths: the REST API (AJAX) and a direct HTML form POST. Most rate limiting guides only cover the API endpoint. You need both. This post shows the complete Nginx configuration for Trellis, including a map-based zone that rate limits POST requests without affecting normal page visits.

Why Plugin-Level Spam Protection Is Not Enough

Contact Form 7, WPForms, and Gravity Forms all offer built-in spam protection — typically reCAPTCHA, Cloudflare Turnstile, or honeypot fields. These work well against bots that actually load the page and execute JavaScript. The problem is the bots that do not.

When we reviewed access logs on one of our managed WordPress hosting clients, we found two spam submissions in a single 24-hour window. Both followed the same pattern:

  • Load the contact page via GET
  • Submit the form via POST within 1-4 seconds
  • No additional asset requests (no CSS, no JS, no images)

No human fills out a contact form in one second. These bots fetched the page HTML, parsed the form fields, and submitted a POST request directly — all without executing a single line of JavaScript. The honeypot field was never rendered, so it was never filled. The CAPTCHA challenge was never loaded, so it was never solved. The form submission went straight through.

One bot was running Chrome 67 on Windows 7 — a browser version from 2018. The other used a CCleaner-branded user agent. Both were clearly automated. Both succeeded because plugin-level spam protection only works at the application layer, and these requests were hitting the form handler directly at the HTTP layer.

The Two Submission Paths You Need to Cover

This is the part most rate limiting guides miss. WordPress contact forms accept submissions through two completely different URL paths:

Path 1: The REST API (AJAX). When a real user fills out a Contact Form 7 form and clicks submit, the browser sends an AJAX request to /wp-json/contact-form-7/v1/contact-forms/{id}/feedback. This is the modern, JavaScript-dependent path. It is what CF7 uses when everything works normally.

Path 2: Direct HTML POST. When JavaScript is disabled — or when a bot skips the frontend entirely — the form falls back to a standard HTML form submission: POST /contact/. This hits the page URL directly, and WordPress processes it through the regular request lifecycle. No AJAX, no REST API, no JavaScript required.

Most guides tell you to rate limit the REST API endpoint. That covers Path 1. But the spam bots we observed were using Path 2 exclusively — direct POST to the page URL. If you only rate limit the API, you are guarding the door the bots are not using.

The Complete Nginx Rate Limit Configuration

You need two rate limit zones in Nginx — one for each submission path. Here is the full setup as we deploy it on our Trellis stacks.

Step 1: Define the rate limit zones in nginx.conf

The first zone uses the client IP directly — every request to the CF7 REST API is a form submission by definition, so there is no need to filter by method. The second zone uses a map to create a key that is only populated for POST requests. GET requests get an empty key, which means Nginx skips rate limiting entirely for normal page visits.

# In the http {} block of nginx.conf

# Zone 1: CF7 REST API submissions
limit_req_zone $binary_remote_addr zone=cf7_form:10m rate=2r/m;

# Zone 2: Direct POST to contact page
map $request_method $contact_post_key {
    POST    $binary_remote_addr;
    default "";
}
limit_req_zone $contact_post_key zone=contact_post:10m rate=2r/m;

The rate=2r/m means two requests per minute per IP — one submission every 30 seconds. The 10m allocates 10 megabytes of shared memory for the zone, which is enough to track roughly 160,000 unique IP addresses. For a small business site, this is far more than you will ever need.

The map directive is the key piece. Without it, rate limiting the /contact/ location would throttle GET requests too — meaning a visitor browsing your contact page and then refreshing it could get blocked. The map ensures only POST requests count against the limit.

Step 2: Apply the zones to location blocks

Add both location blocks to your Nginx server configuration. On Trellis, this goes in an includes file:

# Rate limit Contact Form 7 REST API submissions
location ~* ^/wp-json/contact-form-7/ {
    limit_req zone=cf7_form burst=3 nodelay;
    limit_req_status 429;

    try_files $uri /index.php?$args;
    include fastcgi_params;
    fastcgi_param SERVER_NAME $host;
    fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
    fastcgi_pass unix:/var/run/php-fpm-wordpress.sock;
}

# Rate limit direct POST to /contact/
location = /contact/ {
    limit_req zone=contact_post burst=3 nodelay;
    limit_req_status 429;

    try_files $uri /index.php?$args;
    include fastcgi_params;
    fastcgi_param SERVER_NAME $host;
    fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
    fastcgi_pass unix:/var/run/php-fpm-wordpress.sock;
}

The burst=3 parameter allows a small burst of up to 3 requests before throttling kicks in — a legitimate user who double-clicks submit will not get blocked. The nodelay flag serves burst requests immediately instead of queuing them. The limit_req_status 429 returns HTTP 429 (Too Many Requests) instead of the default 503, which is semantically correct and tells the client exactly why the request was rejected.

Where the Files Go on Trellis

On a Trellis setup, Nginx configuration is managed through Ansible templates. You do not edit files on the server directly — you edit Jinja2 templates in your local repo and provision.

The rate limit zones go in the main Nginx config template. On a standard Trellis installation, this is trellis/roles/nginx/templates/nginx.conf.j2. Add the map and limit_req_zone directives inside the http {} block.

The location blocks go in an includes file. Trellis supports per-site and global includes via the nginx-includes/ directory. For a rule that should apply to all sites on the server, use the all/ subdirectory:

trellis/
├── nginx-includes/
│   └── all/
│       └── rate-limit-forms.conf.j2    ← location blocks go here
└── roles/
    └── nginx/
        └── templates/
            └── nginx.conf.j2           ← zone definitions go here

Note: If you modify nginx.conf.j2, you need to exclude it from automatic Trellis updates. Add --exclude="roles/nginx/templates/nginx.conf.j2" to your Trellis updater script’s rsync command, or your customization will be overwritten the next time you update Trellis.

Deploying the Configuration

Nginx configuration changes deploy through trellis provision, not trellis deploy. The provision command runs the Ansible playbook that renders your Jinja2 templates and reloads Nginx:

cd ~/your-project/trellis
trellis provision --tags nginx,wordpress-setup production

The --tags nginx,wordpress-setup flag limits the provision to Nginx-related tasks. The nginx tag deploys nginx.conf.j2 (where the zones are defined). The wordpress-setup tag deploys the includes files (where the location blocks live). You need both tags because the zone must exist before the location block can reference it.

Trellis runs nginx -t automatically before reloading. If there is a syntax error or a referenced zone does not exist, the provision will fail safely without taking down Nginx. We learned this the hard way — our first deploy used only --tags wordpress-setup and got a zero size shared memory zone "contact_post" error because the zone definition in nginx.conf.j2 had not been deployed yet.

Verifying It Works

After provisioning, confirm the configuration is live on the server:

# Check that the zone is defined in nginx.conf
ssh user@yourserver "grep 'contact_post' /etc/nginx/nginx.conf"

# Check that the location block is in the includes
ssh user@yourserver "cat /etc/nginx/includes.d/all/rate-limit-forms.conf"

To monitor whether the rate limit is actually catching spam, search for 429 responses in your access log:

# Find all rate-limited requests
ssh user@yourserver "grep '\" 429 ' /srv/www/yoursite.com/logs/access.log"

# Count rate-limited requests in the last 24 hours
ssh user@yourserver "grep '\" 429 ' /srv/www/yoursite.com/logs/access.log | \
  awk -v d=$(date -d '24 hours ago' '+%d/%b/%Y') '\$0 ~ d' | wc -l"

If you see 429 responses to POST /contact/ or POST /wp-json/contact-form-7/, the rate limit is working. If you see none, that either means no spam bots have hit the endpoint yet, or they are staying under the 2-per-minute threshold. Both are fine.

Layering with Other Defenses

Rate limiting is one layer. It stops rapid-fire bot submissions but will not catch a bot that sends one spam message per hour. For comprehensive protection, layer it with:

  • Honeypot fields — a hidden form field that real users never see. Bots that auto-fill every field get caught. Contact Form 7 supports this natively with the [hidden] tag or through plugins like CF7 Honeypot.
  • Cloudflare Turnstile — a free, invisible CAPTCHA replacement. Less friction than reCAPTCHA v2 and more effective than reCAPTCHA v3 for most sites. CF7 has integration plugins available.
  • IP deny lists — for repeat offenders, add their IP to your Nginx deny list. On Trellis, this is a deny-ips.conf.j2 file in nginx-includes/all/ that drops connections with a 444 before the request reaches WordPress.
  • Antispam Bee — a WordPress plugin that checks comment and form submissions against known spam patterns. No API key required, GDPR-compliant, and effective as a last-resort filter at the application layer.

The goal is defense in depth. Nginx rate limiting handles the volume — bots that spray dozens of requests per minute. Honeypots and Turnstile handle the quality — single requests from bots that mimic real users. IP blocks handle the persistence — known bad actors who keep coming back. Together, they cover the full spectrum.

Adapting for Other Form Plugins

The REST API path is different for each form plugin:

PluginREST API Endpoint
Contact Form 7/wp-json/contact-form-7/v1/contact-forms/
WPForms/wp-json/wpforms/
Gravity Forms/wp-json/gf/v2/
Formidable Forms/wp-json/frm/v2/

Swap the regex in the first location block to match your plugin. The direct POST location block stays the same regardless of which plugin you use — bots POST to whatever page URL the form is on.

If your contact form lives on a different page — say /get-in-touch/ instead of /contact/ — change the location = /contact/ directive accordingly. If you have forms on multiple pages, you can either add multiple location blocks or use a regex match for all of them.

Frequently Asked Questions

  • Will Nginx rate limiting block real users who submit the contact form? No. The limit is 2 requests per minute with a burst of 3. A real user submitting a form once — or even double-clicking the submit button — will never hit this threshold. Only automated submissions that fire multiple requests in rapid succession get blocked.
  • Do I need both location blocks for the REST API and direct POST? You need both. The REST API block catches AJAX submissions from browsers with JavaScript enabled. The direct POST block catches bots that skip JavaScript entirely and submit the HTML form directly. In our logs, all the spam came through the direct POST path — the REST API rate limit alone would have caught none of it.
  • What happens when a WordPress contact form request gets rate limited by Nginx? Nginx returns HTTP 429 (Too Many Requests) and closes the connection. The request never reaches PHP or WordPress. No email is sent, no form data is processed, and no server resources are consumed beyond the Nginx connection handling.
  • Can I use Nginx rate limiting on a non-Trellis WordPress server? Yes. The Nginx configuration is the same regardless of how you manage your server. The only difference is how you deploy it — on Trellis you edit Jinja2 templates and provision; on a manually managed server you edit the Nginx config files directly and reload with sudo nginx -s reload.
  • Does Nginx rate limiting work with Cloudflare or other CDN proxies? If your site is behind Cloudflare, $binary_remote_addr will contain Cloudflare’s IP, not the visitor’s. You need to use $http_x_forwarded_for or configure Nginx’s real_ip module to restore the original client IP before rate limiting will work correctly per visitor.

Done Managing Your Own Server?

We offer managed WordPress hosting built on Trellis — Nginx, PHP 8.3, Redis, automated deployments via Ansible, and Bedrock structure on Hetzner EU. No shared hosting, no page builders, no surprises.

  • Trellis + Bedrock on Hetzner EU (Frankfurt / Helsinki)
  • Nginx + FastCGI caching + Redis object cache
  • Automated deployments via Ansible, SSL via Let’s Encrypt
  • From €49/month — or €65/hour for one-off server work

Leave a Reply

Your email address will not be published.