.

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:

Tested environment and assumptions

This tutorial assumes that:

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.

References

· macOS, launchd, Let's Encrypt, NGINX, acme.sh, Homebrew