DNS-01 and AOP in Traefik: how I made only Cloudflare talk to my origin
To close port 80 to Let's Encrypt I needed to renew certs with DNS-01, and to stop any TLS connection from reaching the origin without going through Cloudflare I added Authenticated Origin Pulls. I'm going over both changes, the gotchas I ran into, and the right order when you do them in the same session.

In the first post in the series I explained why I put the VPS behind Cloudflare. Now it's time to get into the first technical change, which touches Traefik in two places. First, moving Let's Encrypt certificates from HTTP-01 validation to DNS-01 with the Cloudflare API. Second, setting up Authenticated Origin Pulls so the origin rejects any TLS handshake that doesn't present Cloudflare's client certificate.
In theory these two changes are independent, but in practice you do them back to back, and the order matters. If you do them the other way around, you'll spend a while wondering why your site is returning 525 in the browser.
Why switch from HTTP-01 to DNS-01
The HTTP-01 challenge is the default Let's Encrypt flow, and it works by asking your server to serve a specific file at http://tu-dominio/.well-known/acme-challenge/<token>. That means keeping port 80 open to the outside world, because LE validates from an IP you don't control.
As soon as you decide to close port 80 to everything except Cloudflare (that's coming in the next post), HTTP-01 stops working. The clean way out is DNS-01, which validates by creating a TXT record in your DNS zone. Since Cloudflare manages the zone, all you need is an API token with the Zone:DNS:Edit scope, and Traefik can solve the challenge without needing an open port.
The Cloudflare token
I created it from the CF dashboard under My Profile, API Tokens, using a custom template and the Zone DNS Edit permission for the two zones I'm migrating. Nothing else. The less scope it has, the less damage it can do if it leaks. The token lives in two places.
As the
CF_DNS_API_TOKENenvironment variable inside the Traefik container. That's the copy the daemon uses day to day to solve DNS-01 challenges.As a local cache in a protected
0600 rootfile inside the VPS, which acts as a backup for the auto-reinject monitor I talk about in the fourth post.
The real source of truth is my secrets manager, Infisical. The local copy is a second line of defense so the monitoring script can act without asking the network for permission.
The change in Traefik
In Traefik's static configuration I define two resolvers, the old HTTP-01 one (which I'm phasing out router by router) and the new DNS-01 one with the Cloudflare provider.
certificatesResolvers:
letsencrypt:
acme:
email: [email protected]
storage: /etc/dokploy/traefik/dynamic/acme.json
httpChallenge:
entryPoint: web
letsencrypt-dns:
acme:
email: [email protected]
storage: /etc/dokploy/traefik/dynamic/acme-dns.json
dnsChallenge:
provider: cloudflare
resolvers:
- 1.1.1.1:53
- 1.0.0.1:53The two resolvers coexist without conflict. The new one stores certs in a different file (acme-dns.json), so they don't step on each other. Each router picks its own.
Migrating a project with no downtime
To move a router from the old resolver to the new one, you need to do two things at once, change the certResolver line and force Traefik to issue a new cert. That second part has a gotcha I found on the first attempt.
Gotcha, Traefik reuses the cert in memory
If you just change certResolver: letsencrypt to certResolver: letsencrypt-dns and restart Traefik, the cert doesn't get reissued. Why? Because the old cert from the HTTP-01 resolver is still valid for 90 days from its last renewal, and Traefik serves it as-is even if you've changed resolvers. Renewal with the new resolver doesn't kick in until the cert is around 60 days old.
The fix is to purge the cert from the acme.json file before restarting, so Traefik is forced to request a new one, and the next one it requests goes through DNS-01 because the router's resolver is already the new one.
# Backup primero, siempre
sudo cp /etc/dokploy/traefik/dynamic/acme.json acme.json.bak-$(date +%Y%m%d-%H%M%S)
# Borrar entradas del cert viejo
sudo python3 -c "
import json
with open('/etc/dokploy/traefik/dynamic/acme.json') as f:
d = json.load(f)
to_remove = {'mi-dominio.com', 'www.mi-dominio.com'}
for r, c in d.items():
certs = c.get('Certificates') or []
c['Certificates'] = [x for x in certs if x.get('domain', {}).get('main') not in to_remove]
with open('/etc/dokploy/traefik/dynamic/acme.json', 'w') as f:
json.dump(d, f, indent='\t')
"
# Reiniciar Traefik para que pida el cert nuevo via DNS-01
sudo docker restart dokploy-traefikIn 20 to 30 seconds Cloudflare propagates the TXT, Let's Encrypt validates it, and the new cert shows up in acme-dns.json. I verify that the cert date is today.
echo | openssl s_client -connect mi-dominio.com:443 -servername mi-dominio.com 2>/dev/null \
| openssl x509 -noout -issuer -datesAnother gotcha, .bak files inside dynamic/
Traefik watches the dynamic/ folder with a file watcher, and if you leave the backup as acme.json.bak-... inside that same folder, it tries to load it as valid configuration. Result, conflicts and errors in the logs. Move backups out of that folder to somewhere Traefik isn't watching.
Enabling Authenticated Origin Pulls
With DNS-01 working, certificates renew without opening port 80. But the origin still accepts TLS handshakes from any client, not just Cloudflare. AOP closes that gap.
The idea is that Cloudflare presents a client certificate signed by a Cloudflare CA during the TLS handshake to the origin, and the origin rejects any handshake that doesn't include that cert. Cloudflare publishes the public CA and the feature is free on any plan.
Setting up the CA in Traefik
I download the CA from the official CF docs and save it inside Traefik's dynamic/ directory. Then I define a tls.options that uses it.
tls:
options:
cloudflare-aop:
clientAuth:
caFiles:
- /etc/dokploy/traefik/dynamic/cloudflare-origin-pull-ca.pem
clientAuthType: RequireAndVerifyClientCertThe important part is clientAuthType: RequireAndVerifyClientCert. RequestClientCert asks for the cert but doesn't reject the connection if it doesn't show up. RequireAndVerifyClientCert does.
Applying the option to the router
The project's router references this option. Here's the variant for a file provider router.
routers:
mi-app-websecure:
rule: Host(`mi-dominio.com`)
entryPoints:
- websecure
service: mi-app-service
tls:
certResolver: letsencrypt-dns
options: cloudflare-aop@fileAnd here's the variant for a docker provider router. Same idea, expressed as a compose label.
traefik.http.routers.mi-app-websecure.tls.options=cloudflare-aop@fileEnabling AOP in Cloudflare
In the CF dashboard, inside the zone, I go to SSL/TLS, Origin Server, Authenticated Origin Pulls, and turn on the Global toggle. Each zone needs its own toggle. The Global option uses Cloudflare's shared CA, which is exactly the one I downloaded for Traefik. The Per-zone or Per hostname options are for custom certificates uploaded to the dashboard, which isn't what I want here.
The right order when you do both in the same session
This is the lesson that cost me a scare. If you're making both changes together, this is the safe sequence.
Edit the router yml with only
certResolver: letsencrypt-dns. No AOP yet.Turn on the orange cloud on the DNS record. From that point on requests start going through CF.
Purge the old cert from
acme.jsonand restart Traefik. The new cert gets issued through DNS-01.Verify that through Cloudflare the page returns 200 and that
curl -sIshowsserver: cloudflare.Only then add
options: cloudflare-aop@fileto the router. The watcher picks up the change live, with no restart.Verify the direct bypass, it should fail during the TLS handshake.
The mistake that almost cost me downtime was adding options: cloudflare-aop@file in step 1, before the orange cloud. While the cloud is still gray, visitors are still going straight to the VPS through public DNS. Since the router requires a client cert from the CF CA and visitors don't present one, the handshake fails and the app goes down for everyone. I caught it within seconds and rolled it back, but it makes the point clearly, logical order and safe order are not the same thing.
How I verify that AOP is doing its job
Two commands. The first checks that the site still works through Cloudflare.
curl -sI https://mi-dominio.com
# debe responder 200/301/307 con server: cloudflareThe second tries to bypass Cloudflare by connecting directly to the VPS IP while forcing the correct SNI.
curl -v --resolve mi-dominio.com:443:<IP-del-VPS> https://mi-dominio.com
# debe terminar en error TLS, algo como:
# OpenSSL/3.x: error:1404C45C:SSL routines:tls_process_server_certificate:tlsv13 alert certificate requiredIf you get a 421 Misdirected Request instead of a TLS error, you didn't test what you think you tested. A raw IP without SNI makes Traefik reply with a different default cert, without ever hitting the project's router. You need to use --resolve or something equivalent to force the correct SNI.
The free Universal SSL case and sub-subdomains
Cloudflare's Free plan includes Universal SSL, which covers the domain apex and one level of subdomains. It doesn't cover sub-subdomains. If you have x.y.tu-dominio.com with the orange cloud enabled, the client gets a cert error because CF doesn't have a cert issued for that hostname.
You have three options. Pay for Advanced Certificate Manager (10 euros per month per zone, it issues certs at any depth), rename the subdomain to a single level (my choice, transparent for internal admin services), or leave it on the gray cloud (no CF protection for that domain). I went with renaming it, and only lost a few minutes changing environment variables and one DNS record.
Per-project rollback
If something goes wrong while migrating a specific project, rollback is quick. Remove options: cloudflare-aop@file from the router. Change certResolver: letsencrypt back. Restore the backup acme.json. Switch the cloud back to gray in CF. Restart Traefik. In under two minutes the project is back to its previous state.
With DNS-01 and AOP working, I now have two of the four layers I mentioned in the motivation post. Cloudflare validates visitors at the edge, and my origin requires any TLS connection to be signed by the Cloudflare CA. What's left is closing ports 80 and 443 at the kernel level to everything except Cloudflare, and that's what comes next in the next post.

Jose, author of the blog
QA Engineer. I write out loud about automation, AI and software architecture. If something here helped you, write to me and tell me about it.
Comments
Comments are closed on this post.
If you liked this

ScamDetector, un detector de estafas con inteligencia artificial
ScamDetector combina inteligencia artificial, búsqueda de reputación de teléfonos y escaneo de URLs para ayudarte a identificar estafas digitales. Sin registro, sin datos almacenados.

Guía práctica de hardening para tu VPS Linux: de CrowdSec al kernel
Repaso completo de las medidas de seguridad que puedes aplicar a un VPS Linux: desde CrowdSec y el firewall hasta el hardening del kernel, pasando por SSH, Docker y las actualizaciones automáticas.

Cómo verificamos que nadie manipula los posts de este blog
Nuestros posts viven en una base de datos SQLite. Si alguien accede a ella, puede cambiar cualquier artículo sin dejar rastro. Construimos un verificador externo con hashes SHA-256 y firma Ed25519 que vigila la integridad desde un segundo servidor.