Automate Let’s Encrypt certificate renewal with launchd on macOS
The Let’s Encrypt certificate already exists and NGINX already serves the website over HTTPS. The remaining task is to automate certificate renewal and ask NGINX to reload it whenever it changes.
This guide explains that automation step by step on macOS. acme.sh is installed with Homebrew, and the daily check is managed by launchd, the service manager built into macOS. Initial certificate issuance and the NGINX HTTPS configuration are deliberately outside its scope.
The example uses the fictitious domain mondomaine.com and a macOS account named webmaster. Replace both values with your own domain and username.
The design deliberately separates privileges:
acme.shruns as the user who owns its files;- only a small, protected helper runs as
root; - NGINX is reloaded only when the certificate actually changes;
- no passwordless
sudoersrule is required.
Tested environment and assumptions
This tutorial assumes that:
- Homebrew is installed in
/opt/homebrew, the usual location on Apple Silicon Macs; - NGINX was installed with Homebrew;
- the ECC certificate for
mondomaine.comhas already been issued withacme.sh; - NGINX already uses that certificate;
- the account managing the certificates is named
webmaster.
Homebrew usually uses /usr/local on an Intel Mac. Adjust every /opt/homebrew path accordingly.
1. Install acme.sh with Homebrew
Install acme.sh with Brew:
brew install acme.sh
Check the installed path and version:
/opt/homebrew/bin/acme.sh --version
The scripts below deliberately call /opt/homebrew/bin/acme.sh by its absolute path. A launchd job does not necessarily inherit the same PATH as an interactive Terminal session.
The certificate must already be known to acme.sh. This command should either renew it or report that renewal is not due yet:
/opt/homebrew/bin/acme.sh --renew -d mondomaine.com --ecc
Do not add --force to a daily automated job. Let’s Encrypt applies issuance limits, and acme.sh already knows when a renewal is required.
2. Check the paths used by the setup
This example uses the following layout:
/Users/webmaster/.acme.sh/mondomaine.com_ecc/fullchain.cer
/Users/webmaster/scripts/renew-certificate-mondomaine.com.sh
/Users/webmaster/scripts/renew-certificates-launchd.sh
/Library/PrivilegedHelperTools/org.local.acme-renew
/Library/LaunchDaemons/org.local.acme-renew.plist
/opt/homebrew/var/log/acme-renew/
Create the directory that will contain the user-owned scripts:
mkdir -p /Users/webmaster/scripts
chmod 755 /Users/webmaster/scripts
Also check the NGINX master PID file:
cat /opt/homebrew/var/run/nginx.pid
It contains the process number to which the helper will send the reload signal.
3. Create the domain renewal script
Create /Users/webmaster/scripts/renew-certificate-mondomaine.com.sh with the following content:
#!/usr/bin/env bash
set -uo pipefail
umask 077
DOMAIN="mondomaine.com"
ACME_SH="/opt/homebrew/bin/acme.sh"
ACME_HOME="/Users/webmaster/.acme.sh"
CERTIFICATE="$ACME_HOME/${DOMAIN}_ecc/fullchain.cer"
LOG_FILE="/Users/webmaster/scripts/certificate-renew-${DOMAIN}.log"
mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
log() {
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
}
certificate_state() {
if [[ -f "$CERTIFICATE" ]]; then
/usr/bin/shasum -a 256 "$CERTIFICATE"
else
printf 'MISSING %s\n' "$CERTIFICATE"
fi
}
if [[ ! -x "$ACME_SH" ]]; then
log "ERROR acme.sh was not found or is not executable: $ACME_SH"
exit 1
fi
before_state="$(certificate_state)"
log "START renewal check for $DOMAIN"
"$ACME_SH" --renew -d "$DOMAIN" --ecc >> "$LOG_FILE" 2>&1
acme_status=$?
after_state="$(certificate_state)"
if [[ "$before_state" != "$after_state" ]]; then
log "CHANGE certificate changed; reload delegated to the launchd service"
else
log "NOCHANGE certificate unchanged"
fi
if (( acme_status != 0 && acme_status != 2 )); then
log "ERROR acme.sh exited with status $acme_status"
exit "$acme_status"
fi
log "END renewal check completed"
This first script runs acme.sh without administrator privileges. It compares the SHA-256 fingerprint of the certificate before and after the command. Its log file is protected with mode 600, because certificate-related technical information does not need to be readable by every local account.
Make it executable:
chmod 755 /Users/webmaster/scripts/renew-certificate-mondomaine.com.sh
4. Create the coordinator
Create /Users/webmaster/scripts/renew-certificates-launchd.sh:
#!/usr/bin/env bash
set -uo pipefail
SCRIPTS=(
"/Users/webmaster/scripts/renew-certificate-mondomaine.com.sh"
)
status=0
for script in "${SCRIPTS[@]}"; do
if [[ ! -x "$script" ]]; then
printf 'Script was not found or is not executable: %s\n' "$script" >&2
status=1
continue
fi
if ! "$script"; then
status=1
fi
done
exit "$status"
The coordinator may look unnecessary for one domain. It makes adding another domain later much easier, without modifying the system service: create another domain script and add it to the SCRIPTS array.
Make it executable:
chmod 755 /Users/webmaster/scripts/renew-certificates-launchd.sh
5. Test renewal without launchd
Before installing the service, run the script as the webmaster user:
/Users/webmaster/scripts/renew-certificate-mondomaine.com.sh
Read its log:
tail -50 /Users/webmaster/scripts/certificate-renew-mondomaine.com.log
If the certificate is not close to expiry, the normal result is a skipped renewal followed by a NOCHANGE entry. Check that the log is private:
stat -f '%Sp %Su:%Sg %N' /Users/webmaster/scripts/certificate-renew-mondomaine.com.log
6. Create the privileged helper
Renewal must continue to run as webmaster, but an ordinary user cannot reload an NGINX master process running as root. A tightly scoped helper connects the two operations.
First create /Users/webmaster/scripts/renew-certificates-root-helper.sh:
#!/usr/bin/env bash
set -euo pipefail
umask 027
ACME_USER="webmaster"
ACME_HOME="/Users/webmaster/.acme.sh"
COORDINATOR="/Users/webmaster/scripts/renew-certificates-launchd.sh"
NGINX_PID_FILE="/opt/homebrew/var/run/nginx.pid"
CERTIFICATES=(
"$ACME_HOME/mondomaine.com_ecc/fullchain.cer"
)
certificate_state() {
local certificate
for certificate in "${CERTIFICATES[@]}"; do
if [[ -f "$certificate" ]]; then
/usr/bin/shasum -a 256 "$certificate"
else
printf 'MISSING %s\n' "$certificate"
fi
done
}
run_as_acme_user() {
/usr/bin/sudo -u "$ACME_USER" -H /usr/bin/env \
HOME="/Users/webmaster" \
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
"$@"
}
if [[ "$(/usr/bin/id -u)" -ne 0 ]]; then
printf 'This helper must run as root.\n' >&2
exit 1
fi
if [[ ! -x "$COORDINATOR" ]]; then
printf 'Coordinator was not found or is not executable: %s\n' "$COORDINATOR" >&2
exit 1
fi
before_state="$(certificate_state)"
set +e
run_as_acme_user "$COORDINATOR"
renew_status=$?
set -e
after_state="$(certificate_state)"
if [[ "$before_state" != "$after_state" ]]; then
printf 'A certificate changed; requesting an NGINX reload.\n'
if [[ ! -r "$NGINX_PID_FILE" ]]; then
printf 'NGINX PID file was not found: %s\n' "$NGINX_PID_FILE" >&2
exit 1
fi
nginx_pid="$(<"$NGINX_PID_FILE")"
if [[ ! "$nginx_pid" =~ ^[0-9]+$ ]]; then
printf 'Invalid NGINX PID: %s\n' "$nginx_pid" >&2
exit 1
fi
nginx_process="$(/bin/ps -p "$nginx_pid" -o user=,command=)"
if [[ ! "$nginx_process" =~ ^root[[:space:]]+nginx:\ master\ process[[:space:]] ]]; then
printf 'PID %s does not belong to the root NGINX master process.\n' "$nginx_pid" >&2
exit 1
fi
/bin/kill -HUP "$nginx_pid"
printf 'NGINX reloaded after certificate renewal.\n'
else
printf 'No certificate changed; NGINX was left unchanged.\n'
fi
exit "$renew_status"
The helper confirms that it is running as root, starts the coordinator as webmaster, and compares the certificate state. Only when a certificate changes does it check that the PID belongs to the root-owned NGINX master before sending HUP.
The HUP signal asks NGINX to reread its configuration and certificates without abruptly terminating current connections. The NGINX master validates the new configuration and keeps the previous one if the new configuration cannot be applied.
7. Create the LaunchDaemon
Create /Users/webmaster/scripts/org.local.acme-renew.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.local.acme-renew</string>
<key>ProgramArguments</key>
<array>
<string>/Library/PrivilegedHelperTools/org.local.acme-renew</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>17</integer>
</dict>
<key>WorkingDirectory</key>
<string>/Users/webmaster/scripts</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/webmaster</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>ProcessType</key>
<string>Background</string>
<key>LowPriorityIO</key>
<true/>
<key>Umask</key>
<integer>23</integer>
<key>StandardOutPath</key>
<string>/opt/homebrew/var/log/acme-renew/launchd.out.log</string>
<key>StandardErrorPath</key>
<string>/opt/homebrew/var/log/acme-renew/launchd.error.log</string>
<key>ThrottleInterval</key>
<integer>60</integer>
</dict>
</plist>
StartCalendarInterval schedules a daily run at 03:17. The integer 23 used by Umask represents the octal value 027, preventing service-created files from becoming readable by every local account.
8. Why launchd instead of cron?
cron can still work on macOS, but launchd is Apple’s native mechanism for services and scheduled jobs. A LaunchDaemon can start before any user logs in and explicitly defines its environment, logs and permissions.
There is another useful difference for a small Mac server. A StartCalendarInterval event missed while the Mac is asleep runs after wake. A cron job missed during sleep normally waits for its next scheduled time. A daily certificate check is well suited to this wake-up behaviour.
Do not use KeepAlive for this job. acme.sh should perform a short check and exit, rather than remain continuously in memory.
9. Validate all four files before installation
Check the syntax of the three scripts:
/bin/bash -n /Users/webmaster/scripts/renew-certificate-mondomaine.com.sh
/bin/bash -n /Users/webmaster/scripts/renew-certificates-launchd.sh
/bin/bash -n /Users/webmaster/scripts/renew-certificates-root-helper.sh
Validate the property list:
plutil -lint /Users/webmaster/scripts/org.local.acme-renew.plist
The expected response is OK.
10. Install the protected components
Create the log directory with restrictive permissions:
sudo install -d -o root -g wheel -m 750 /opt/homebrew/var/log/acme-renew
sudo install -o root -g wheel -m 640 /dev/null /opt/homebrew/var/log/acme-renew/launchd.out.log
sudo install -o root -g wheel -m 640 /dev/null /opt/homebrew/var/log/acme-renew/launchd.error.log
Install a protected copy of the helper. This prevents launchd from running a user-modifiable file as root:
sudo install -o root -g wheel -m 755 \
/Users/webmaster/scripts/renew-certificates-root-helper.sh \
/Library/PrivilegedHelperTools/org.local.acme-renew
Install the LaunchDaemon:
sudo install -o root -g wheel -m 644 \
/Users/webmaster/scripts/org.local.acme-renew.plist \
/Library/LaunchDaemons/org.local.acme-renew.plist
11. Load and test the service
Load the service in the system domain:
sudo launchctl bootstrap system /Library/LaunchDaemons/org.local.acme-renew.plist
If the service was already loaded before an update, replace it cleanly:
sudo launchctl bootout system/org.local.acme-renew
sudo launchctl bootstrap system /Library/LaunchDaemons/org.local.acme-renew.plist
Start an immediate test instead of waiting until 03:17:
sudo launchctl kickstart -k system/org.local.acme-renew
Display its current state:
sudo launchctl print system/org.local.acme-renew
Read all logs:
sudo tail -50 /opt/homebrew/var/log/acme-renew/launchd.out.log
sudo tail -50 /opt/homebrew/var/log/acme-renew/launchd.error.log
tail -50 /Users/webmaster/scripts/certificate-renew-mondomaine.com.log
When the certificate is still valid, the normal behaviour is: acme.sh skips renewal, the certificate remains unchanged, and the helper reports that NGINX was not reloaded.
12. Check file permissions
Display ownership and permissions:
stat -f '%Sp %Su:%Sg %N' \
/Library/PrivilegedHelperTools/org.local.acme-renew \
/Library/LaunchDaemons/org.local.acme-renew.plist \
/opt/homebrew/var/log/acme-renew \
/opt/homebrew/var/log/acme-renew/launchd.out.log \
/opt/homebrew/var/log/acme-renew/launchd.error.log \
/Users/webmaster/scripts/certificate-renew-mondomaine.com.log
The expected values include:
-rwxr-xr-x root:wheel /Library/PrivilegedHelperTools/org.local.acme-renew
-rw-r--r-- root:wheel /Library/LaunchDaemons/org.local.acme-renew.plist
drwxr-x--- root:wheel /opt/homebrew/var/log/acme-renew
-rw-r----- root:wheel /opt/homebrew/var/log/acme-renew/launchd.out.log
-rw-r----- root:wheel /opt/homebrew/var/log/acme-renew/launchd.error.log
-rw------- webmaster:staff /Users/webmaster/scripts/certificate-renew-mondomaine.com.log
13. What happens during an actual renewal
Every day, launchd starts the protected helper. It records the certificate fingerprint, runs the coordinator as webmaster, and then calculates the fingerprint again.
If acme.sh renews nothing, NGINX is not touched. If the certificate changes, the helper validates the PID and identity of the NGINX master process and sends it HUP. NGINX then loads the new certificate without a complete server shutdown.
After a real renewal, check the certificate served publicly:
echo | openssl s_client -connect mondomaine.com:443 \
-servername mondomaine.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -fingerprint -sha256
14. Add another domain
Copy the domain-specific script, change DOMAIN, make the new file executable and add its path to the coordinator array. Also add the path to its fullchain.cer file to the helper’s CERTIFICATES array.
After changing the helper, reinstall its protected copy:
sudo install -o root -g wheel -m 755 \
/Users/webmaster/scripts/renew-certificates-root-helper.sh \
/Library/PrivilegedHelperTools/org.local.acme-renew
The property list does not need to change.
15. Remove the automation
Unload the service before removing its files:
sudo launchctl bootout system/org.local.acme-renew
sudo rm /Library/LaunchDaemons/org.local.acme-renew.plist
sudo rm /Library/PrivilegedHelperTools/org.local.acme-renew
The scripts and logs may be kept for troubleshooting. They do not need to be deleted to stop the automation.