Certbot connection refused on http-01: diagnosing proxies, IPv6 and port 80
The renewal timer failed quietly three weeks ago. You found out because a customer said the site was showing a scary red warning, and now you have eleven days of expiry left, a terminal open, and certbot telling you it cannot fetch a file it just wrote. The domain resolves. Nginx is running. You can curl the challenge path from the server itself and get a 200. Let's Encrypt still says connection refused.
Almost every guide answers this with "open port 80" or, worse, "disable the firewall". Both are guesses. Connection refused is a precise signal, and it is different from a timeout, different from a 404, and different from a 403. Reading which one you actually got narrows twenty possible causes down to about three, before you change a single line of config.
"Connection refused" means the TCP packet reached a host and something actively rejected it, so nothing is listening on port 80 at the address Let's Encrypt resolved. Do these three checks before touching config:
dig +short A example.com; dig +short AAAA example.com- a stale AAAA record is the single most common hidden cause, because the validator prefers IPv6.sudo ss -ltnp 'sport = :80'- confirm exactly one process holds port 80, and that it is the one your authenticator assumes.- Place a real file at
/.well-known/acme-challenge/testand fetch it from another machine, never from localhost.
Then match the authenticator to reality: --standalone only if nothing else holds port 80, --webroot -w pointed at the exact directory the vhost serves, or --dns-01 if port 80 is not yours to control.
The exact error text
Modern certbot (roughly 1.10 onward) prints this:
Certbot failed to authenticate some domains (authenticator: webroot). The Certificate Authority reported these problems:
Domain: example.com
Type: connection
Detail: 203.0.113.10: Fetching http://example.com/.well-known/acme-challenge/laE4TFx48-IXIQLaCd0jS: Connection refused
Older certbot, and most of the search results you will land on, phrase it like this:
Failed authorization procedure. example.com (http-01): urn:ietf:params:acme:error:connection ::
The server could not connect to the client to verify the domain :: Connection refused
And if you reached for --standalone while nginx was still running, you get a different error entirely, which people often confuse with the above:
Could not bind TCP port 80 because it is already in use by another process on this system
(such as a web server). Please stop the program in question and then try again.
That third one is not an authorization failure. It is certbot refusing to start. Different problem, different fix, and it is the clearest possible sign that --standalone is the wrong authenticator for this machine.
Why this happens: read the failure mode, not the fix
The http-01 challenge is deliberately dumb. Certbot writes a token file, then asks the CA to fetch it over plain HTTP. Let's Encrypt then makes that request from several vantage points around the world, from IPs you do not control, on port 80 and only port 80. Their documentation is unambiguous: "The HTTP-01 challenge can only be done on port 80." You cannot move it. Custom ports are rejected by the ACME standard, not by certbot.
Redirects are allowed and this trips people up in the opposite direction. Validation follows up to 10 redirects, accepts only http: and https: targets, and only ports 80 or 443. Critically, "when redirected to an HTTPS URL, it does not validate certificates". So your port 80 block redirecting everything to HTTPS is not the problem, even if the current certificate is expired or self-signed. Stop deleting that redirect.
What the CA reports back is a raw network result, and each one means something specific:
| What the CA reports | What actually happened | Where to look |
|---|---|---|
Connection refused | TCP RST. A host answered and said no. Nothing is listening on 80, or a firewall is set to REJECT. | Is the web server up? Is it bound to the right interface? Did you resolve to the right host? |
Timeout during connect | Packets dropped silently. Cloud security group, ufw DROP rule, or the A record points at nothing. | Provider firewall/security group first, host firewall second. |
404 | Something is listening and serving, but not this path. Wrong webroot, wrong vhost matched, or a proxy swallowing it. | Your vhost's location blocks and server_name matching. |
403 | Serving the path but refusing. Directory permissions, or a WAF/bot rule blocking an unfamiliar user agent. | Filesystem permissions on the webroot, then WAF rules. |
Invalid response ... "<!DOCTYPE" | A catch-all route returned your app's 404 page instead of the file. | Framework routing catching /.well-known. |
If you only remember one thing: refused and timeout are opposite diagnoses. Refused means a machine is there and unwilling. Timeout means nothing is there at all. Treating them the same is why people end up disabling firewalls that were never involved.
The fix, in order
Step 1: Check both DNS record types, not just A
dig +short A example.com
dig +short AAAA example.com
Expected output for a typical single-stack VPS:
203.0.113.10
An empty second line is fine. A populated second line that does not match a working IPv6 address on your server is a bug, and it is the cause I see most often when everything else looks correct. The validator prefers the AAAA record. If your provider gave you an IPv6 address, you added the AAAA record two years ago, and nginx only has listen 80; without listen [::]:80;, then the validator connects over IPv6, nothing is bound there, and you get connection refused while every test you run over IPv4 passes.
Two ways out: bind IPv6 properly, or delete the AAAA record. Deleting is faster at 2am; binding is correct.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
}
Step 2: Find out what actually holds port 80
sudo ss -ltnp 'sport = :80'
Expected output on a normal nginx box:
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=812,fd=6))
LISTEN 0 511 [::]:80 [::]:* users:(("nginx",pid=812,fd=6))
Three things to read here. Is anything listening at all? Is it bound to 0.0.0.0 or to 127.0.0.1 only, which produces connection refused from outside and success from localhost? And is IPv6 present, which answers step 1. If you see a Docker proxy process instead of nginx, the container's config is what matters, not the host's.
Step 3: Pick the authenticator that matches what you found
| Situation | Use | Requirement |
|---|---|---|
| Nothing listening on port 80, no web server at all | --standalone | certbot binds port 80 itself; nothing else may hold it |
| nginx or apache already serving the domain | --webroot -w /path | the vhost must serve that exact directory at /.well-known/acme-challenge/ |
| nginx serving the domain, config you are happy to have edited | --nginx | plugin rewrites and reloads your config automatically |
| Port 80 is behind a CDN or not reachable at all | --preferred-challenges dns | a DNS provider plugin and API credentials; no port 80 involved |
The most common mismatch by a distance: someone copies a --standalone command from a tutorial written for a bare server onto a machine already running nginx. It works once, because they stopped nginx. Then the renewal timer runs unattended three months later with nginx up, and fails.
Step 4: Wire the challenge path into the vhost explicitly
For the webroot authenticator, do not assume your document root works. Add an explicit block, above any proxy or catch-all:
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
Then sudo mkdir -p /var/www/certbot and use -w /var/www/certbot. The root directive appends the URI, so certbot's file at /var/www/certbot/.well-known/acme-challenge/TOKEN is served at the matching URL. That is why the webroot you pass and the root in the vhost are the same path, not the path with .well-known appended.
The ^~ modifier matters more than it looks. It tells nginx to stop searching once this prefix matches, which means a regex location further down (a PHP handler, a catch-all proxy_pass to your app) cannot steal the request. Without it, a reverse proxy in front of a Node or Frappe app will happily forward the challenge to the app, which returns its own 404 page. If you are running a proxy in front of an app, the same principle applies as in any nginx reverse proxy setup: the most specific location has to win.
sudo nginx -t && sudo systemctl reload nginx
Step 5: Prove the path from outside before running certbot
echo "hello" | sudo tee /var/www/certbot/.well-known/acme-challenge/test
Now fetch it from any machine that is not the server. Your laptop, another VPS, a phone on mobile data:
curl -sS -D - http://example.com/.well-known/acme-challenge/test -o /dev/null
You want a 200, or a 301 to the https URL followed by a 200. Testing from the server itself proves nothing, because localhost bypasses DNS, the cloud firewall, the CDN and any IPv6 problem in one go. That single mistake accounts for most "but it works when I curl it" forum threads.
Step 6: Run certbot in dry-run mode first
sudo certbot certonly --webroot -w /var/www/certbot \
-d example.com -d www.example.com --dry-run
Dry runs use the staging environment, which has far looser rate limits. Production issuance limits are real and unforgiving, and burning them at midnight while iterating on a config is how a three-hour outage becomes a three-day one. Only drop --dry-run once the dry run says The dry run was successful.
What about Cloudflare
The advice you will read is "pause the orange cloud". That is usually unnecessary and sometimes counterproductive, because pausing changes DNS propagation timing and adds a variable. A proxied record forwards /.well-known/acme-challenge/ to your origin like any other path, so http-01 through Cloudflare normally works. What actually breaks it, in order of how often I have seen it:
- The origin is not reachable from Cloudflare on port 80 or 443 at all, because you locked the firewall down to Cloudflare's ranges and the list is stale, or because you only allow 443 and your origin has no valid cert yet.
- A WAF rule, bot-fight mode, or a "under attack" setting challenges the validator's request. The validator does not solve JavaScript challenges.
- A Page Rule or redirect rule rewriting
/.well-known/*to somewhere else.
If you are behind a CDN you do not fully control, stop fighting http-01 and use dns-01. It never touches port 80, it works for wildcards, and it works identically whether the origin is up or not. The tradeoff is that you now store DNS API credentials on the server, so scope that API token to DNS edit on one zone and nothing else.
Verification
Three commands, in this order.
- Prove renewal will work unattended, which is the actual thing that failed:
Expected output ends withsudo certbot renew --dry-runCongratulations, all simulated renewals succeeded. If this passes while nginx is running normally, your renewal is genuinely fixed rather than fixed-once-by-hand. - Confirm the served certificate, not the one on disk:
Expected output showsecho | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \ | openssl x509 -noout -dates -subjectnotAfterroughly 90 days out and the right subject. - Confirm the timer is actually scheduled:
An empty result means nothing will renew this in 60 days and you will be back here. On Debian and Ubuntu packages the unit is usuallysystemctl list-timers 'certbot*' --allcertbot.timer; on some setups renewal runs from cron in/etc/cron.d/certbotinstead. One of them must exist.
What people get wrong
"Disable the firewall and try again." If the report said refused, the firewall is almost certainly innocent, because a default ufw deny policy DROPs packets and produces a timeout, not a refusal. Turning off the firewall to fix a certificate is an enormous, permanent risk taken to solve a temporary problem, and in my experience nobody turns it back on. If you must test, allow port 80 specifically and leave everything else alone.
"chmod 777 the webroot." This appears in an alarming number of accepted answers. A 403 during validation means the web server user cannot traverse or read the path. The fix is that the directory is world-executable and the file world-readable, which is what 755 and 644 already give you. World-writable means any process on the box, including a compromised app, can drop files into a path served over HTTP.
"Run certbot --force-renewal until it works." Forced renewals count against production rate limits. Repeated failures at 1am can lock you out of issuance for that domain, which turns an expiring certificate into an expired one. Iterate with --dry-run.
"Delete /etc/letsencrypt and start clean." That directory holds your account key, your renewal configuration and your existing certificates. Deleting it does not fix a network reachability problem; it just removes the certificate you could still have fallen back to.
When it is still broken
- Watch the request arrive. Run
sudo tail -f /var/log/nginx/access.login one terminal and the certbot dry run in another. If no request for/.well-known/acme-challenge/ever appears, the failure is upstream of nginx: DNS, firewall or CDN. If it appears with a 404, it is a vhost matching problem and you now know exactly which server block answered. - Check which vhost answered. A request for a domain with no matching
server_namefalls through to the default server, which may have a completely different root.sudo nginx -T | grep -n "server_name"shows every one in the effective config. - Check for a second web server. A leftover container publishing port 80, an old apache2 unit still enabled, or a systemd socket unit will all quietly take the port back after a reboot.
- Switch to dns-01 and move on. If the domain sits behind infrastructure you do not control, http-01 may simply never work from where you are. That is not defeat; it is picking the challenge that matches your topology.
Once issuance succeeds, do not assume you are finished. A valid certificate served with an incomplete chain fails in a completely different and equally confusing way, which I cover in the unable to get local issuer certificate article. Getting the certificate is half the job; serving it correctly is the other half.
Frequently asked questions
- What does connection refused mean in a certbot http-01 failure?
- It means the TCP packet reached a host and that host actively rejected it with a RST, so nothing was listening on port 80 at the address Let's Encrypt resolved. It is the opposite diagnosis from a timeout, which means packets were silently dropped by a firewall or the address is unreachable. Refused points you at the web server, the interface it is bound to, or the DNS record you resolved, not at the firewall.
- Why does the acme-challenge file work with curl on the server but fail validation?
- Curling from the server itself bypasses DNS resolution, the cloud provider firewall, any CDN in front of you, and any IPv6 problem, so it proves almost nothing. Let's Encrypt fetches the file from outside, from multiple vantage points, and prefers your AAAA record over your A record if both exist. Always test the challenge URL from a different machine before blaming certbot.
- Can I run the Let's Encrypt http-01 challenge on a port other than 80?
- No. Let's Encrypt documents that the HTTP-01 challenge can only be done on port 80, and this is a restriction in the ACME standard rather than in certbot. Validation does follow up to 10 redirects, but only to http or https on ports 80 or 443. If port 80 is not available to you, use the dns-01 challenge instead.
- Do I need to pause Cloudflare's proxy to renew a Let's Encrypt certificate?
- Usually not. A proxied Cloudflare record forwards the /.well-known/acme-challenge/ path to your origin like any other request, so http-01 normally works through it. Renewals fail when your origin firewall no longer allows Cloudflare's ranges, when a WAF or bot-fight rule challenges the validator, or when a page rule rewrites the /.well-known path. If you cannot control those, switch to dns-01 rather than pausing the proxy.