Fixing cURL DNS Failures on AWS EC2: Elementor Webhooks, WPML and bind9
If you’re running WordPress on an AWS EC2 instance and Elementor form webhooks are silently failing, or WPML is throwing errors about not being able to connect to its servers, the problem probably isn’t WordPress or the plugin.
It’s DNS.
The Symptoms
The issue can show up in several ways depending on what outbound connections your site makes:
- Elementor form webhooks not sending to Zapier, Make, or similar services
- WPML unable to reach its activation or translation servers
- API calls to third-party services randomly failing
- Any outbound cURL request that works fine sometimes, but fails without warning at other times
The intermittent nature is what makes it frustrating to debug. The site loads fine. The server seems healthy. But something is broken with outbound connections.
Adding Webhook Logging to Elementor
Elementor doesn’t log webhook failures anywhere by default, so the first step is to add some debugging. Drop the following into a mu-plugin. It hooks into both the outgoing request and the response, logging to wp-content/elementor-webhook-debug.log regardless of your WP_DEBUG setting - remove it once you’re done debugging.
<?php
/**
* Plugin Name: Elementor Webhook Debug Logger
* Description: Logs detailed Elementor form webhook request/response data to a dedicated log file.
* Version: 1.0.0
*
* Logs to: wp-content/elementor-webhook-debug.log
* Remove this file once debugging is complete.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'elementor_pro/forms/webhooks/response', function ( $response, $record ) {
$settings = $record->get( 'form_settings' );
$log_file = WP_CONTENT_DIR . '/elementor-webhook-debug.log';
$timestamp = gmdate( 'Y-m-d H:i:s' );
$entry = [
'timestamp' => $timestamp,
'form_id' => $settings['id'] ?? 'unknown',
'form_name' => $settings['form_name'] ?? 'unknown',
'url' => $settings['webhooks'] ?? 'not set',
];
if ( is_wp_error( $response ) ) {
$entry['error_type'] = 'WP_Error';
$entry['error_code'] = $response->get_error_code();
$entry['error_message'] = $response->get_error_message();
$entry['error_data'] = $response->get_error_data();
} else {
$entry['http_code'] = wp_remote_retrieve_response_code( $response );
$entry['http_message'] = wp_remote_retrieve_response_message( $response );
$entry['response_body'] = wp_remote_retrieve_body( $response );
$headers = wp_remote_retrieve_headers( $response );
if ( ! empty( $headers['content-type'] ) ) {
$entry['content_type'] = $headers['content-type'];
}
}
$line = '[' . $timestamp . '] ' . wp_json_encode( $entry, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) . "\n\n";
file_put_contents( $log_file, $line, FILE_APPEND | LOCK_EX );
}, 10, 2 );
add_filter( 'elementor_pro/forms/webhooks/request_args', function ( $args, $record ) {
$settings = $record->get( 'form_settings' );
$log_file = WP_CONTENT_DIR . '/elementor-webhook-debug.log';
$timestamp = gmdate( 'Y-m-d H:i:s' );
$entry = [
'timestamp' => $timestamp,
'direction' => 'OUTGOING REQUEST',
'form_id' => $settings['id'] ?? 'unknown',
'form_name' => $settings['form_name'] ?? 'unknown',
'url' => $settings['webhooks'] ?? 'not set',
'timeout' => $args['timeout'] ?? 'default (5s)',
'body_keys' => is_array( $args['body'] ) ? array_keys( $args['body'] ) : 'not array',
];
$line = '[' . $timestamp . '] ' . wp_json_encode( $entry, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) . "\n";
file_put_contents( $log_file, $line, FILE_APPEND | LOCK_EX );
return $args;
}, 10, 2 );
Once you have a failure, the response log entry will look something like this:
[2026-04-05 08:54:02] {
"timestamp": "2026-04-05 08:54:02",
"form_id": "xxxxxxx",
"form_name": "Xxxx: Xxxxxxx",
"url": "https://hooks.zapier.com/hooks/catch/xxxxxxx/xxxxxxx/",
"error_type": "WP_Error",
"error_code": "http_request_failed",
"error_message": "cURL error 6: Could not resolve host: hooks.zapier.com",
"error_data": null
}
cURL error 6 means the DNS lookup failed completely. The request never even left the server.
Diagnosing the DNS Problem
SSH into the server and run a DNS lookup:
dig hooks.zapier.com
If you get status: SERVFAIL with no answer section, you’ve confirmed DNS is failing at the server level.
The interesting thing is that nslookup may give you a completely different result:
nslookup hooks.zapier.com
# Returns IP addresses fine
If you suspect DNSSEC is the cause, you can confirm it by running the same query with DNSSEC checking disabled:
dig hooks.zapier.com # returns SERVFAIL
dig +cd hooks.zapier.com # returns NOERROR with results
If the +cd (checking disabled) query succeeds while the plain query fails, that conclusively proves the SERVFAIL is caused by DNSSEC validation. PHP’s cURL uses the system resolver, so it hits the same SERVFAIL and the request fails.
You can confirm it by checking which resolver is in use:
cat /etc/resolv.conf
On an AWS EC2 instance you’ll likely see something like:
nameserver 127.0.0.1
nameserver 127.0.0.53
search eu-west-3.compute.internal
And checking what’s listening on port 53:
ss -tlunp | grep ':53'
If you see entries with a backlog of 10 (rather than the 4096 that systemd-resolved uses), that’s bind9.
What’s Actually Happening
Some EC2 instances end up with bind9 as the local DNS resolver - typically installed by server control panels like ISPConfig rather than being part of the default AMI. The default bind9 configuration has dnssec-validation auto enabled and does its own recursive DNS lookups rather than forwarding to a trusted upstream resolver.
When bind9 encounters a domain with broken or mismatched DNSSEC records, it returns SERVFAIL. This doesn’t happen on every domain or every request, which is why the failures are intermittent - some domains trigger it, some don’t, and it can even vary between requests.
The AWS VPC resolver at 169.254.169.253 handles all of this correctly, but bind9 isn’t using it.
The Fix
Back up the current bind9 config first:
sudo cp /etc/bind/named.conf.options /etc/bind/named.conf.options.bak
Then edit it:
sudo nano /etc/bind/named.conf.options
Replace the contents with this:
options {
directory "/var/cache/bind";
forwarders {
169.254.169.253; // AWS VPC resolver (primary)
1.1.1.1; // Cloudflare fallback
8.8.8.8; // Google fallback
};
forward only;
dnssec-validation no;
listen-on-v6 { any; };
};
The key changes from the default config are:
forwarders- tells bind9 to pass queries upstream rather than resolving them itselfforward only- prevents bind9 from falling back to recursive resolution if the forwarders fail to provide an answerdnssec-validation no- removes the DNSSEC checking that was causing the failures; the upstream resolvers handle this
Save the file, validate the config, and restart:
sudo named-checkconf # silence = success
sudo systemctl restart bind9
dig hooks.zapier.com # should return status: NOERROR
Why 169.254.169.253 Is Safe to Hardcode
169.254.169.253 looks like a specific IP you’d want to verify, but it isn’t a regular address that AWS could change or reassign. It’s a link-local address in the reserved 169.254.x.x range (RFC 3927), hardcoded into every AWS VPC by design.
It’s the same address on every EC2 instance regardless of region, account, or instance type - the same way 169.254.169.254 is always the EC2 instance metadata endpoint. These addresses just exist at that location on every AWS instance, permanently.
This also means the config above can be copied to any other AWS server without modification.
Why Keep the AWS Resolver as Primary?
You might wonder why not just point bind9 at Cloudflare (1.1.1.1) or Google (8.8.8.8) and skip the AWS resolver entirely.
The AWS VPC resolver also handles internal DNS — things like RDS endpoint hostnames, EC2 instance names, and *.compute.internal addresses. Cloudflare and Google can’t resolve those. By listing 169.254.169.253 first with public resolvers as fallbacks, you get both internal DNS resolution and resilience if the VPC resolver has any issues.
This fix applies to any outbound cURL failure on EC2 with the same root cause — not just Elementor webhooks. If you’re seeing WPML licence check failures, Gravity Forms webhook errors, or WooCommerce payment gateway connection issues, check DNS first before digging into plugin settings.