Internal names can have a normal, publicly trusted certificate. Let's Encrypt will issue one when you prove you control the DNS label directly under the zone, such as lab in lab.example.com. That is the top-level name this script groups on. You do not prove anything about the host itself. For a machine that is not on the internet, the proof is a DNS record instead of a web request.
DNS-01 asks for a TXT record at _acme-challenge under that label. Nothing connects to the server. It can be on a private address, behind a firewall, or powered off. Certbot's Cloudflare plugin creates the record, waits, and deletes it. You keep a list of the names you want. A weekly timer renews them.
What you need
- A Cloudflare zone. In the script below, that zone is exactly two DNS labels, such as
example.com. - Certbot and the
certbot-dns-cloudflareplugin. - An API token for that one zone, with only Zone:Read and DNS:Edit. Not an account-wide token.
Directory
Make one directory and do every step below inside it. This example uses certs:
mkdir certs
cd certsYou will add three files: cloudflare.ini, issue.conf, and issue.sh. The first run of ./issue.sh creates everything else.
Files
Create cloudflare.ini:
# Cloudflare API token used by Certbot
# Token needs Zone:Read and DNS:Edit on example.com only.
dns_cloudflare_api_token = CLOUDFLARE_DNS_EDIT_TOKENCreate issue.conf beside it. Settings go above : DOMAINS. One hostname per line goes below it. A setting whose name is a label directly under the zone chooses that group's output folder. If you skip it, the folder name is the label.
ZONE=example.com
EMAIL=you@example.com
CREDENTIALS=cloudflare.ini
CERTBOT_DIR=.letsencrypt
PROPAGATION_SECONDS=30
P12_PASSWORD=change-me
lab=services/lab
: DOMAINS
app.lab.example.com
db.lab.example.comlab is the label under example.com, so both names share one certificate and land in services/lab. A name such as printer.example.com gets its own certificate in a folder called printer.
Issue
Save this as issue.sh, run chmod 700 issue.sh, then run ./issue.sh.
#!/bin/bash
set -eu
cd "$(dirname "$0")"
CONF="${1:-issue.conf}"
# Settings only. The hostname list below the marker must not be executed.
eval "$(awk '/^: DOMAINS$/ { exit } { print }' "$CONF")"
mkdir -p logs
LOG="logs/issue-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$LOG") 2>&1
echo "logging to $LOG"
: "${ZONE:?ZONE is not set in $CONF}"
: "${EMAIL:?EMAIL is not set in $CONF}"
: "${CREDENTIALS:?CREDENTIALS is not set in $CONF}"
: "${CERTBOT_DIR:?CERTBOT_DIR is not set in $CONF}"
: "${PROPAGATION_SECONDS:?PROPAGATION_SECONDS is not set in $CONF}"
if [ ! -s "$CREDENTIALS" ]; then
echo "$CREDENTIALS is missing" >&2
exit 1
fi
NAMES=$(awk 'f && $0 !~ /^[[:space:]]*#/ && NF { gsub(/[[:space:]]/, ""); print } /^: DOMAINS$/ { f = 1 }' "$CONF")
if [ -z "$NAMES" ]; then
echo "$CONF has no hostnames under DOMAINS" >&2
exit 1
fi
# Group by the label directly under ZONE. ZONE is two labels, so that
# label is field (n - 2). A folder for a label comes from $CONF; otherwise
# the folder is the label itself.
echo "$NAMES" | awk -F. -v zone="$ZONE" '
BEGIN { zn = split(zone, zp, ".") }
{
n = split($0, p, ".")
if (n <= zn) next
g = p[n - zn]
group[g] = group[g] (group[g] ? " " : "") $0
}
END { for (g in group) print g "\t" group[g] }
' | while IFS=$(printf '\t') read -r GROUP DOMAINS; do
DEST=$(awk -F= -v g="$GROUP" '
/^: DOMAINS$/ { exit }
$0 ~ /^[[:space:]]*#/ { next }
$1 == g { sub(/^[^=]*=/, ""); print; found = 1; exit }
END { if (!found) print g }
' "$CONF")
mkdir -p "$DEST"
# Saved into the renewal config, so `certbot renew` re-files on its own.
# $RENEWED_LINEAGE is set only when the hook fires after a renewal.
HOOK="$PWD/$CERTBOT_DIR/deploy-$GROUP.sh"
cat > "$HOOK" <<EOF
#!/bin/sh
set -eu
LIVE="\${RENEWED_LINEAGE:-$PWD/$CERTBOT_DIR/live/$GROUP}"
cp "\$LIVE/fullchain.pem" "$PWD/$DEST/fullchain.pem"
cp "\$LIVE/privkey.pem" "$PWD/$DEST/privkey.pem"
chmod 600 "$PWD/$DEST/privkey.pem"
openssl pkcs12 -export \\
-in "\$LIVE/fullchain.pem" \\
-inkey "\$LIVE/privkey.pem" \\
-out "$PWD/$DEST/$GROUP.p12" \\
-name "$GROUP" \\
-passout "pass:${P12_PASSWORD}"
chmod 600 "$PWD/$DEST/$GROUP.p12"
EOF
chmod 700 "$HOOK"
set -- $DOMAINS
ARGS=""
for d in "$@"; do
ARGS="$ARGS -d $d"
done
certbot certonly \
--non-interactive --agree-tos -m "$EMAIL" \
--dns-cloudflare \
--dns-cloudflare-credentials "$PWD/$CREDENTIALS" \
--dns-cloudflare-propagation-seconds "$PROPAGATION_SECONDS" \
--cert-name "$GROUP" \
--deploy-hook "$HOOK" \
--config-dir "$PWD/$CERTBOT_DIR" \
--work-dir "$PWD/$CERTBOT_DIR/work" \
--logs-dir "$PWD/$CERTBOT_DIR/logs" \
$ARGS
"$HOOK"
echo "wrote $DEST ($DOMAINS)"
done
# `certbot renew` trusts the name list in the renewal config, not this file.
# Rewrite it so names added or removed since issuance take effect, and drop a
# lineage outright once none of its names remain. Files already written stay.
find "$CERTBOT_DIR/renewal" -name '*.conf' -type f 2>/dev/null | while read -r RC; do
LINEAGE=$(basename "$RC" .conf)
WANTED=$(echo "$NAMES" | awk -F. -v zone="$ZONE" -v lineage="$LINEAGE" '
BEGIN { zn = split(zone, zp, ".") }
{
n = split($0, p, ".")
if (n > zn && p[n - zn] == lineage) print
}')
if [ -z "$WANTED" ]; then
certbot delete --non-interactive --cert-name "$LINEAGE" \
--config-dir "$PWD/$CERTBOT_DIR" \
--work-dir "$PWD/$CERTBOT_DIR/work" \
--logs-dir "$PWD/$CERTBOT_DIR/logs"
echo "removed $LINEAGE from renewal; its files were left in place"
continue
fi
WANTED="$WANTED" awk '
/^\[\[/ { exit }
{ print }
END {
print ""
n = split(ENVIRON["WANTED"], w, "\n")
for (i = 1; i <= n; i++) if (w[i] != "") print "[[webroot_map]]\n" w[i] " = None\n"
}
' "$RC" > "$RC.tmp" && mv "$RC.tmp" "$RC"
doneLayout
After ./issue.sh finishes, certs looks like this:
certs/
issue.sh
issue.conf
cloudflare.ini
logs/
issue-YYYYMMDD-HHMMSS.log
.letsencrypt/
deploy-lab.sh
live/lab/
renewal/lab.conf
work/
logs/
services/lab/
fullchain.pem
privkey.pem
lab.p12The three files at the top are the ones you wrote. logs/ is the script transcript. .letsencrypt/ is Certbot's certificate, renewal config, and deploy hook. services/lab/ is the copy a service should read. A name such as printer.example.com adds a printer/ directory next to services/ instead of inside it.
Renew
Issuing once is not the point. The certificate lasts ninety days. Renewal reuses the config Certbot already saved:
certbot renew \
--config-dir .letsencrypt \
--work-dir .letsencrypt/work \
--logs-dir .letsencrypt/logsRun that from the same directory, weekly. Certbot only renews certificates inside the thirty-day window and skips the rest. When a certificate actually changes, the deploy hook copies the new files into place. It does not reload your service. Add that to the hook if a service needs it.
On Linux, use a systemd timer or cron. On macOS, use a LaunchAgent. Weekly is enough. Setup notes are at <https://certbot.org/renewal-setup>.
Changing the list
Edit the names under : DOMAINS and run ./issue.sh again. Names you add join a certificate and get renewed. Names you remove are dropped from renewal. The script does not delete fullchain.pem, privkey.pem, or the PKCS#12 file. Leave deletion to a person. A typo in the list should not wipe a live key.
Do not point the script at every record in the zone. Old tests and leftovers would get publicly trusted certificates. Add a name when you mean to, and remove it when you mean to.
A wildcard is supported even though this example does not use one. Add *.toplevel.example.com under : DOMAINS, where toplevel is the label directly under the zone, such as *.lab.example.com. DNS-01 can validate that name. One certificate then covers every host under that label.
