LetsEncrypt DNS Validation Template Using Cloudflare

AI-generated summary

Mark_Wass initially shared a custom YAML template for integrating Let’s Encrypt DNS validation with Cloudflare using acme.sh. However, Mark_Wass later advised against this custom template, noting it causes issues with modern discourse_docker versions, such as failing cert_exists checks and leading to rate limit errors due to redundant certificate generation. He also referenced a user reporting empty ECC certificates using the old method.

Instead, the recommended solution is to keep the stock Let’s Encrypt template and modify app.yml to support DNS validation. This involves:

  1. Adding CF_Token and CF_Zone_ID to the env section.
  2. Using a run block to download the specific Cloudflare DNS plugin (dns_cf.sh) and replace the stock web validation command with --dns dns_cf.

LotusJeff encountered a Cloudflare 521 error (server unreachable) after applying the new configuration. Mark_Wass explained that a 521 error indicates Nginx failed to start, likely because the certificate issuance script blocked startup while waiting for DNS propagation or failed entirely, leaving invalid SSL files. He clarified that the stock script does not request wildcard certificates by default and warned against forcing repeated renewals due to Let’s Encrypt rate limits.

To resolve 521 errors, users should check shared/standalone/letsencrypt*/acme.sh.log for validation failures. Mark_Wass provided a debugging workflow: test the DNS token and zone configuration inside the container against Let’s Encrypt’s staging server to verify credentials without affecting production. If the test passes, the issue likely lies in duplicate run sections in app.yml or YAML indentation errors. If it fails, the Cloudflare API token permissions or Zone ID may be incorrect. Users must ensure the API token has “Zone > DNS > Edit” permissions for the specific domain.

Hi Guys

I needed to use the DNS method for authenticating my SSL certificate ownership with Let’s Encrypt.

I took a copy of the existing web.letsencrypt.ssl.template.yml file found in /var/discourse/templates/ and modified it to use the Automatic DNS API Integration method. Below is my sample template, I’d be happy if there were any suggestion on how to improve it.

I called this file web.letsencrypt.ssl.dns.template.yml

env:
  LETSENCRYPT_DIR: "/shared/letsencrypt"
  DISCOURSE_FORCE_HTTPS: true

hooks:
  after_ssl:
    - exec:
       cmd:
         - if [ -z "$LETSENCRYPT_ACCOUNT_EMAIL" ]; then echo "LETSENCRYPT_ACCOUNT_EMAIL ENV variable is required and has not been set."; exit 1; fi
         - /bin/bash -c "if [[ ! \"$LETSENCRYPT_ACCOUNT_EMAIL\" =~ ([^@]+)@([^\.]+) ]]; then echo \"LETSENCRYPT_ACCOUNT_EMAIL is not a valid email address\"; exit 1; fi"

    - exec:
       cmd:
         - cd /root && git clone --branch 3.0.7 --depth 1 https://github.com/acmesh-official/acme.sh.git && cd /root/acme.sh
         - touch /var/spool/cron/crontabs/root
         - install -d -m 0755 -g root -o root $LETSENCRYPT_DIR
         - cd /root/acme.sh && LE_WORKING_DIR="${LETSENCRYPT_DIR}" ./acme.sh --install --log "${LETSENCRYPT_DIR}/acme.sh.log"
         - cd /root/acme.sh && LE_WORKING_DIR="${LETSENCRYPT_DIR}" ./acme.sh --upgrade --auto-upgrade
         - cd /root/acme.sh && LE_WORKING_DIR="${LETSENCRYPT_DIR}" ./acme.sh --set-default-ca  --server  letsencrypt 

    - file:
       path: /etc/runit/1.d/letsencrypt
       chmod: "+x"
       contents: |
        #!/bin/bash
        
        issue_cert() {
          export CF_Token="$$ENV_LETSENCRYPT_CF_TOKEN"
          export CF_Account_ID="$$ENV_LETSENCRYPT_CF_ACCOUNT_ID"
          export CF_Zone_ID="$$ENV_LETSENCRYPT_CF_ZONE_ID"
          LE_WORKING_DIR="${LETSENCRYPT_DIR}" $$ENV_LETSENCRYPT_DIR/acme.sh --issue --dns $$ENV_LETSENCRYPT_DNS_PROVIDER $2 -d $$ENV_DISCOURSE_HOSTNAME --keylength $1 -w /var/www/discourse/public
        }

        cert_exists() {
          [[ "$(cd $$ENV_LETSENCRYPT_DIR/$$ENV_DISCOURSE_HOSTNAME$1 && openssl verify -CAfile <(openssl x509 -in ca.cer) fullchain.cer | grep "OK")" ]]
        }

        ########################################################
        # RSA cert
        ########################################################
        issue_cert "4096"

        if ! cert_exists ""; then
          # Try to issue the cert again if something goes wrong
          issue_cert "4096" "--force"
        fi

        LE_WORKING_DIR="${LETSENCRYPT_DIR}" $$ENV_LETSENCRYPT_DIR/acme.sh \
          --installcert \
          -d $$ENV_DISCOURSE_HOSTNAME \
          --fullchainpath /shared/ssl/$$ENV_DISCOURSE_HOSTNAME.cer \
          --keypath /shared/ssl/$$ENV_DISCOURSE_HOSTNAME.key \
          --reloadcmd "sv reload nginx"

        ########################################################
        # ECDSA cert
        ########################################################
        issue_cert "ec-256"

        if ! cert_exists "_ecc"; then
          # Try to issue the cert again if something goes wrong
          issue_cert "ec-256" "--force"
        fi

        LE_WORKING_DIR="${LETSENCRYPT_DIR}" $$ENV_LETSENCRYPT_DIR/acme.sh \
          --installcert --ecc \
          -d $$ENV_DISCOURSE_HOSTNAME \
          --fullchainpath /shared/ssl/$$ENV_DISCOURSE_HOSTNAME_ecc.cer \
          --keypath /shared/ssl/$$ENV_DISCOURSE_HOSTNAME_ecc.key \
          --reloadcmd "sv reload nginx"

        if cert_exists "" || cert_exists "_ecc"; then
          grep -q 'force_https' "/var/www/discourse/config/discourse.conf" || echo "force_https = 'true'" >> "/var/www/discourse/config/discourse.conf"
        fi

    - replace:
       filename: "/etc/nginx/conf.d/discourse.conf"
       from: /ssl_certificate.+/
       to: |
         ssl_certificate /shared/ssl/$$ENV_DISCOURSE_HOSTNAME.cer;
         ssl_certificate /shared/ssl/$$ENV_DISCOURSE_HOSTNAME_ecc.cer;

    - replace:
       filename: /shared/letsencrypt/account.conf
       from: /#?ACCOUNT_EMAIL=.+/
       to: |
         ACCOUNT_EMAIL=$$ENV_LETSENCRYPT_ACCOUNT_EMAIL

    - replace:
       filename: "/etc/nginx/conf.d/discourse.conf"
       from: /ssl_certificate_key.+/
       to: |
         ssl_certificate_key /shared/ssl/$$ENV_DISCOURSE_HOSTNAME.key;
         ssl_certificate_key /shared/ssl/$$ENV_DISCOURSE_HOSTNAME_ecc.key;

    - replace:
       filename: "/etc/nginx/conf.d/discourse.conf"
       from: /add_header.+/
       to: |
         add_header Strict-Transport-Security 'max-age=63072000';

There are some extra environment variables you need to add to app.yml and possibly modify if you’re not using Cloudflare as your DNS provider. All the different providers API settings are here

This is what I added to my app.yml under the templates section

templates:
  - "templates/postgres.template.yml"
  - "templates/redis.template.yml"
  - "templates/web.template.yml"
  ## Uncomment the next line to enable the IPv6 listener
  #- "templates/web.ipv6.template.yml"
  - "templates/web.ratelimited.template.yml"
  ## Uncomment these two lines if you wish to add Lets Encrypt (https)
  - "templates/web.ssl.template.yml"
  - "templates/web.letsencrypt.ssl.dns.template.yml"
  #- "templates/web.letsencrypt.ssl.template.yml"

And further down under the env section.

## If you added the Lets Encrypt template, uncomment below to get a free SSL certificate
  LETSENCRYPT_ACCOUNT_EMAIL: me@mydomain.com
  LETSENCRYPT_CF_TOKEN: "YOUR_TOKEN"
  LETSENCRYPT_CF_ACCOUNT_ID: "YOUR_ACCOUNT_ID"
  LETSENCRYPT_CF_ZONE_ID: "YOUR_ZONE_ID"
  LETSENCRYPT_DNS_PROVIDER: "YOUR_DNS_PROVIDER" ## i.e. dns_cf

After updating those files I then simply ran the command to rebuild the docker app

cd /var/discourse
./launcher rebuild app 

Once rebuilt you should then have your app running at https:// and there should be a cron job that checks daily if your certificate needs updating. If it does need updating it will get you a new certificate and install it automatically.

Hope this helps someone.

EDIT: I just hit the letsencrypt 5 certificate (per exact domain) per week limit.

Did you have an issue with generating _ecc certificate ?

Using your post the main website.com.cer file is being successfully generated, however the website.com_ecc.cer shows 0 bytes (the _ecc.key is fine).

But both certificates are issued by the same issue_cert method, so for some reason the main one works and the _ecc fails.

Is this still valid to switch Let’s Encrypt to a DNS validation versus using a web server model?

Hi @LotusJeff,

Yes, DNS validation still works, but please don’t use the template in my first post any more. It is out of date with the current discourse_docker:

  • Its cert_exists check fails with the current Let’s Encrypt chain, so it forces new certificates on every container start and can hit the duplicate certificate rate limit.
  • Its nginx replace steps target lines that have moved, and the add_header one now overwrites an unrelated ETag header.

You don’t need a custom template now. Keep the stock Let’s Encrypt template and switch it to DNS validation in app.yml:

templates:
  # ...
  - "templates/web.ssl.template.yml"
  - "templates/web.letsencrypt.ssl.template.yml"

env:
  # ...
  CF_Token: "YOUR_TOKEN"        # Cloudflare API token with Zone > DNS > Edit on this zone
  CF_Zone_ID: "YOUR_ZONE_ID"

run:
  - exec:
      cmd:
        - mkdir -p /opt/dnsapi
        - curl -fsSL https://raw.githubusercontent.com/acmesh-official/acme.sh/3.0.6/dnsapi/dns_cf.sh -o /opt/dnsapi/dns_cf.sh
  - replace:
      filename: /usr/local/bin/letsencrypt
      from: "-w /var/www/discourse/public"
      to: "--dns dns_cf"

What this does:

  • The stock template downloads only the main acme.sh script, without the DNS plugins. The exec step adds the Cloudflare plugin, and the stock start-up script copies it into /shared/letsencrypt/dnsapi/. Keep the version in the URL the same as the acme.sh version in templates/web.letsencrypt.ssl.template.yml (3.0.6 today).
  • The run: section runs after the templates, so the replace switches the stock start-up script from HTTP to DNS validation.
  • acme.sh reads CF_Token and CF_Zone_ID from the environment when it validates, and saves them with the certificate. The daily cron job does not see the container environment, so renewals use that saved copy.

If you used my old template, remove its line from templates: and rename LETSENCRYPT_CF_TOKEN and LETSENCRYPT_CF_ZONE_ID to CF_Token and CF_Zone_ID.

If you already have certificates (from HTTP validation or from my old template), move the old acme.sh folder aside before you rebuild:

cd /var/discourse
mv shared/standalone/letsencrypt shared/standalone/letsencrypt.old
./launcher rebuild app

Without this, acme.sh can skip the DNS step (it keeps the old method until renewal is due, and Let’s Encrypt can reuse a recent validation). Then the token is never saved and the first cron renewal fails. With a fresh folder, acme.sh registers a new account and does a real DNS validation, so the first start takes about a minute longer.

Keep the .old folder until the new certificates work. If the DNS step fails, the start-up script still overwrites the files in shared/ssl, and nginx will not start.

acme.sh stores the token in plain text under shared/standalone/letsencrypt/, so give the token only the permission it needs.

Tested with discourse_docker and Discourse latest as of November 2026.

I followed this and ended up with a CloudFlare 521 error. This error was on my discourse url (forum.website.tld) and on my main domain (website.tld). I reverted the changes and rebuilt and everything came back up.

Is there something to force a cert renewal after rebuilding?
Is this trying to create a wildcard versus a defined url?

Hi @LotusJeff,

A Cloudflare 521 means Cloudflare could not connect to your server at all. It is not a certificate error (a bad certificate gives a 525 or 526). So nginx in the Discourse container was not running. I assume your main domain shows the same error because it points at the same server. The DNS plugin only adds and removes one TXT record. It does not change any A or CNAME records.

Is it trying to create a wildcard? No. The stock script asks for a certificate for DISCOURSE_HOSTNAME only, plus any names in DISCOURSE_HOSTNAME_ALIASES. acme.sh adds a TXT record _acme-challenge.forum.website.tld, waits for Let’s Encrypt to check it, and then deletes it. If you use DISCOURSE_HOSTNAME_ALIASES, all of those names must be in the zone that CF_Zone_ID points to.

How do I force a new certificate? Moving the old letsencrypt folder aside before the rebuild is the force. With an empty folder, acme.sh has no certificate to keep, so it does a new DNS validation at the next start. Please don’t repeat --force runs: each run that works issues two certificates (RSA and ECDSA), and Let’s Encrypt allows only 5 certificates for the same hostname in 7 days.

Why nginx did not start. The Let’s Encrypt script runs at container start, before nginx. If you moved the folder aside, there are two likely causes:

  1. It was still waiting. With DNS validation, acme.sh waits until the TXT record shows in public DNS. With Cloudflare this normally takes less than a minute. But if the record does not show, acme.sh waits up to 20 minutes for each certificate, and Cloudflare shows 521 for all of that time.
  2. The DNS step failed. Then the stock script still writes empty files into shared/ssl, and nginx cannot start.

If you did not move the folder aside, acme.sh skips the DNS step until renewal is due, so the 521 more likely came from the rebuild itself. Check that you added my lines to the existing templates:, env: and run: sections of app.yml, and did not add new ones. The stock app.yml already has a run: section at the bottom. If a key is in the file twice, only the last one counts.

Find the cause. The acme.sh log stays in the shared folder after a rebuild, so it still has the errors from the failed start:

cd /var/discourse
grep -a -i -E 'error|invalid|can not find|timed out|cert success' shared/standalone/letsencrypt*/acme.sh.log | tail -n 40

Feel free to post the output here. acme.sh does not write the token to this log at its default log level, but check the lines before you post.

Test before you try again. This does a full DNS validation against the Let’s Encrypt staging server, from a temporary folder inside the running container. It does not touch your live certificates or the production rate limit, so your forum stays up:

cd /var/discourse
./launcher enter app

mkdir -p /tmp/acme-test/dnsapi
curl -fsSL https://raw.githubusercontent.com/acmesh-official/acme.sh/3.0.6/acme.sh -o /tmp/acme-test/acme.sh
curl -fsSL https://raw.githubusercontent.com/acmesh-official/acme.sh/3.0.6/dnsapi/dns_cf.sh -o /tmp/acme-test/dnsapi/dns_cf.sh
chmod +x /tmp/acme-test/acme.sh
CF_Token="YOUR_TOKEN" CF_Zone_ID="YOUR_ZONE_ID" /tmp/acme-test/acme.sh --home /tmp/acme-test \
  --server letsencrypt_test --issue --dns dns_cf -d forum.website.tld

rm -rf /tmp/acme-test
exit

If it ends with Cert success., your token, zone ID and DNS are fine, and the problem is in the app.yml changes. If it fails, the error tells you why. For example, invalid domain usually means a wrong zone ID, or a token that has no access to that zone.

When you rebuild again, watch the start with docker logs -f app and give it a few minutes before you revert. You should see Cert success. twice, and then nginx starts.