Automate Tactical RMM SSL with acme.sh & acme-dns

Automate Tactical RMM SSL with acme.sh & acme-dns

I manage endpoints for multiple clients using Tactical RMM — an open source, self-hosted Remote Monitoring and Management platform. If you're not familiar with RMM tools, the short version is: it's how I keep an eye on every workstation and server across every client without driving to their office. I can push software, run scripts, respond to alerts, and remote in — all from one dashboard.

The self-hosted nature of Tactical RMM is one of its biggest strengths. Client data stays on my infrastructure, there are no per-endpoint licensing fees, and the MeshCentral integration means remote support sessions never pass through a third-party relay. But self-hosted means I own the operational side too — including keeping SSL certificates valid and renewed. That's what this post is about.


The Problem with Certbot

Tactical RMM ships with Certbot for SSL. It works, but it has some rough edges when you want things to be truly hands-off:

  • It needs either port 80 open (HTTP validation) or your DNS provider's API key stored directly on the server
  • Customizing what happens after a renewal — restarting services, fixing file permissions — requires extra configuration that's easy to break on updates
  • There's no built-in way to get notified when a renewal happens or fails

I wanted something cleaner. The goal: certs renew on their own, services restart automatically, and I get a Slack message confirming it worked. No manual steps, ever.


The Solution at a Glance

Here's what we're building:

acme.sh — a lightweight shell-based ACME client that replaces Certbot. It handles the certificate requests, renewal scheduling, and running your custom scripts after a renewal.

acme-dns — a small, self-hosted DNS server with one job: answer Let's Encrypt's DNS challenges. Instead of giving your RMM server the keys to your entire DNS zone, you point a CNAME record at acme-dns and it handles the challenge in isolation. Your Cloudflare or Route53 credentials never touch the RMM box.

A deploy hook — a small bash script that acme.sh runs automatically after each renewal to copy the fresh certificates into place, set the right file permissions, and restart the RMM services.

The flow looks like this:

Every ~60 days:
  acme.sh wakes up via cron
    → talks to acme-dns via REST API
      → acme-dns answers Let's Encrypt's DNS challenge
        → Let's Encrypt issues a fresh certificate
          → deploy hook copies files + restarts services
            → Slack notification fires ✓

Before You Start

You'll need:

  • A working Tactical RMM install (with Certbot currently handling certs)
  • A running acme-dns server you can reach from the RMM host
  • Access to your domain's DNS registrar to add CNAME records (you'll do this once, then never again)
  • Root access to the RMM server

Substituting your own domains: Everywhere you see api.yourdomain.com, rmm.yourdomain.com, and mesh.yourdomain.com in this guide, replace them with your own Tactical RMM subdomains. The structure is the same regardless of your domain name.


Step 1: Install acme.sh

SSH into your RMM server, elevate to root and run:

curl https://get.acme.sh | sh -s email=your-email@example.com
source ~/.bashrc

Then immediately switch the default Certificate Authority to Let's Encrypt. acme.sh switched its default to ZeroSSL in 2021, and ZeroSSL has a bug with multi-domain certificates that causes it to give up and ask you to wait 24 hours. Let's Encrypt doesn't have this problem.

acme.sh --set-default-ca --server letsencrypt

Step 2: Point acme.sh at Your acme-dns Server

acme.sh needs to know where your acme-dns instance lives. Set this in two places so it survives reboots and scheduled cron jobs:

echo 'export ACMEDNS_BASE_URL="https://your-acmedns-server"' >> /root/.bashrc
echo 'export ACMEDNS_BASE_URL="https://your-acmedns-server"' >> /root/.acme.sh/acme.sh.env
source /root/.bashrc

Replace your-acmedns-server with your actual acme-dns hostname or IP.

A note on HTTP vs HTTPS here. If your acme-dns server is on a separate machine and reachable over a network, the registration credentials — the username, password, and subdomain token — will travel in plaintext over HTTP. An attacker who can sniff that traffic could hijack those credentials and fraudulently update your challenge records.

There are a few acceptable ways to handle this depending on your setup:

  • HTTPS (recommended for exposed servers): Put your acme-dns instance behind a reverse proxy (Nginx or Caddy) with a valid SSL cert and use https:// here.
  • Network-level controls (what I use): If your acme-dns server is on an internal or segmented network with firewall rules restricting which hosts can reach port 8181, HTTP is acceptable — the risk is addressed at the network layer rather than the transport layer.
  • Localhost only: If acme-dns runs on the same machine as Tactical RMM and binds to 127.0.0.1, HTTP is fine since the traffic never leaves the host.
  • Tunnel: WireGuard or Tailscale between the two hosts is another solid option if you want encryption without setting up a full reverse proxy.

Step 3: Register Each Subdomain (Do This One at a Time)

Why one at a time? If you pass all three domains in a single command on the first run, acme.sh creates one acme-dns account and tries to use it for all three subdomains. That causes failures because each domain needs its own separate slot on the acme-dns server. Running them individually forces acme.sh to register unique credentials for each one.

Run these three commands, one at a time, pausing between each one to complete the DNS step below:

acme.sh --issue --dns dns_acmedns -d api.yourdomain.com --server letsencrypt --keylength ec-256

After running each command, acme.sh automatically makes an API call to your acme-dns server, provisions a new account, and generates a unique CNAME target for that subdomain. It then pauses and prints the CNAME record you need to add:

Please add the following CNAME record:
  Host:   _acme-challenge.api.yourdomain.com
  Type:   CNAME
  Target: a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx.your-acmedns-server

Go add that CNAME record to your DNS registrar now. Wait about 60 seconds for it to propagate, then press Enter to let acme.sh continue.

Repeat for the other two subdomains:

acme.sh --issue --dns dns_acmedns -d rmm.yourdomain.com --server letsencrypt --keylength ec-256
acme.sh --issue --dns dns_acmedns -d mesh.yourdomain.com --server letsencrypt --keylength ec-256

Each subdomain gets its own unique CNAME target string. Don't copy the same target for all three — they must each point to their own entry on the acme-dns server.

Quick sanity check

Once all three are done, verify that acme.sh saved separate credentials for each domain:

grep -i "acmedns" /root/.acme.sh/api.yourdomain.com_ecc/api.yourdomain.com.conf

You should see ACMEDNS_USERNAME, ACMEDNS_PASSWORD, and ACMEDNS_SUBDOMAIN values. If the file looks empty or generic, something went wrong in the registration — go back and rerun the individual issue commands.


Step 4: Issue the Final Combined Certificate

Now that all three subdomains are registered, issue a single certificate that covers all of them. This is called a SAN (Subject Alternative Name) certificate — one file that's valid for all three domains at once. When it renews, all three update together automatically.

acme.sh --issue --dns dns_acmedns \
  -d api.yourdomain.com \
  -d rmm.yourdomain.com \
  -d mesh.yourdomain.com \
  --server letsencrypt \
  --dnssleep 0

--dnssleep 0 skips a redundant local DNS check. Since your acme-dns server handles the challenge directly, the extra wait serves no purpose.

When it finishes, you'll see something like:

Your cert is in: /root/.acme.sh/api.yourdomain.com_ecc/api.yourdomain.com.cer
Your cert key is in: /root/.acme.sh/api.yourdomain.com_ecc/api.yourdomain.com.key
The full-chain cert is in: /root/.acme.sh/api.yourdomain.com_ecc/fullchain.cer

Those are your raw certificate files. Next we'll set up the automation that installs them properly.


Step 5: Create the Deploy Hook

The deploy hook is what makes this truly hands-off. Every time acme.sh renews the certificate, it runs this script automatically to put the files in the right place and restart your services.

Important — acme.sh deploy hooks work differently than regular bash scripts. The file gets sourced (not executed), and the certificate file paths are passed in via environment variables, not command-line arguments. The function name must exactly match the filename with _deploy appended. Get either of these wrong and nothing will be copied.

Create the file:

nano /root/.acme.sh/deploy/tacticalrmm.sh

Paste this in exactly:

#!/usr/bin/env bash

tacticalrmm_deploy() {
  # acme.sh sets these automatically when running this hook:
  # $Le_Domain           - the primary domain name
  # $CERT_KEY_PATH       - path to the private key
  # $CERT_FULLCHAIN_PATH - path to the full chain certificate

  local PROD_CERT_PATH="/etc/ssl/tacticalrmm/fullchain.pem"
  local PROD_KEY_PATH="/etc/ssl/tacticalrmm/privkey.pem"

  echo "Deploying certs for $Le_Domain to Tactical RMM paths..."

  # Create the destination directory if it doesn't exist
  mkdir -p /etc/ssl/tacticalrmm

  # Copy the fresh certificates into place
  cp "$CERT_FULLCHAIN_PATH" "$PROD_CERT_PATH"
  cp "$CERT_KEY_PATH" "$PROD_KEY_PATH"

  # Lock down permissions so only the tactical user can read them
  chown tactical:tactical "$PROD_CERT_PATH" "$PROD_KEY_PATH"
  chmod 440 "$PROD_CERT_PATH" "$PROD_KEY_PATH"

  # Reload Nginx gracefully — a full restart would drop active connections.
  # Nginx can pick up new SSL certificates on a reload without disruption.
  systemctl reload nginx

  # The application services need a full restart to pick up the new cert.
  systemctl restart meshcentral rmm daphne

  return 0
}

Save and close (Ctrl+O, then Ctrl+X).

Copy the certificates manually for the first time

The hook hasn't run yet, so the destination folder is still empty. Copy the files over now so Nginx doesn't fail when you update the config in the next step:

mkdir -p /etc/ssl/tacticalrmm

cp /root/.acme.sh/api.yourdomain.com_ecc/fullchain.cer /etc/ssl/tacticalrmm/fullchain.pem
cp /root/.acme.sh/api.yourdomain.com_ecc/api.yourdomain.com.key /etc/ssl/tacticalrmm/privkey.pem

chown tactical:tactical /etc/ssl/tacticalrmm/*.pem
chmod 440 /etc/ssl/tacticalrmm/*.pem

This tells acme.sh to run your deploy script every time this certificate renews:

acme.sh --deploy --deploy-hook tacticalrmm -d api.yourdomain.com --ecc

Confirm it saved:

grep "Le_DeployHook" /root/.acme.sh/api.yourdomain.com_ecc/api.yourdomain.com.conf
# Should show: Le_DeployHook='tacticalrmm'

Step 6: Update Nginx to Point at the New Certificate Location

Your three Nginx config files are still pointing at the old Certbot paths. Open each one in /etc/nginx/sites-enabled/ and swap the SSL certificate lines:

Find lines that look like this (old Certbot path):

ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

Replace them with:

ssl_certificate /etc/ssl/tacticalrmm/fullchain.pem;
ssl_certificate_key /etc/ssl/tacticalrmm/privkey.pem;

Do this in all three config files — typically rmm.conf, frontend.conf, and meshcentral.conf (the exact names depend on your install).

Once all three are updated, test and reload Nginx:

nginx -t && systemctl reload nginx

If nginx -t reports any errors, check that the certificate files actually exist at /etc/ssl/tacticalrmm/ (you copied them in Step 5).


Step 7: Tell Tactical RMM Where the New Certificates Live

This step is easy to overlook, but important. Tactical RMM's update script rebuilds your Nginx configs from values stored in its database. If those values still point to the old Certbot paths, the next time you run update.sh your Nginx configs will be overwritten and your setup will break.

First, check whether your install has the set_config management command:

source /rmm/api/env/bin/activate
cd /rmm/api/tacticalrmm
python manage.py help | grep config
deactivate

If set_config shows up, run this:

cd /rmm/api/tacticalrmm
source ../env/bin/activate
python manage.py set_config certfile /etc/ssl/tacticalrmm/fullchain.pem
python manage.py set_config keyfile /etc/ssl/tacticalrmm/privkey.pem
deactivate

If it doesn't exist, add these two lines to the bottom of /rmm/api/tacticalrmm/tacticalrmm/local_settings.py instead:

CERT_FILE = "/etc/ssl/tacticalrmm/fullchain.pem"
KEY_FILE = "/etc/ssl/tacticalrmm/privkey.pem"

⚠️ If you use the local_settings.py fallback, treat it as fragile. Tactical RMM updates can overwrite or regenerate configuration files during major version upgrades. After every update.sh run, verify these lines are still present. Consider keeping a backup copy and checking it into your own private git repo so you can spot if it changes. The set_config database approach is more durable — if it becomes available in a future update, migrate to it.

Make sure there's no indentation before these lines. Python treats leading whitespace as meaningful, and an indented line here will throw an error.


Step 8: Set Up Slack Notifications

acme.sh has a built-in Slack notifier. Set your webhook URL and register it — acme.sh saves it permanently to its config file, so you don't need to keep the environment variable around after this:

export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
acme.sh --set-notify --notify-hook slack

Verify it was saved:

grep "SAVED_SLACK" /root/.acme.sh/account.conf

You should see your webhook URL stored there. From this point on, every successful renewal (and any failures) will post to your Slack channel.


Step 9: Test the Whole Thing

Force a renewal right now to confirm every piece works end-to-end:

acme.sh --renew -d api.yourdomain.com --force --ecc

Watch the output. A successful run looks like this:

Renewing: 'api.yourdomain.com'
Multi domain='DNS:api.yourdomain.com,DNS:rmm.yourdomain.com,DNS:mesh.yourdomain.com'
api.yourdomain.com is already verified, skipping dns-01.
rmm.yourdomain.com is already verified, skipping dns-01.
mesh.yourdomain.com is already verified, skipping dns-01.
Verification finished, beginning signing.
Cert success.
Deploying certs for api.yourdomain.com to Tactical RMM paths...
Restarting Tactical RMM services...
Success

Then check your Slack channel — you should see the renewal notification come through.


How Renewals Work Going Forward

During install, acme.sh added a cron job that runs once a day:

crontab -l | grep acme.sh
# 48 18 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null

Every day at 18:48, it silently checks whether any certificates are getting close to expiry. Once a certificate hits the 30-day-remaining mark (Let's Encrypt certs are valid for 90 days), acme.sh kicks off the full renewal flow automatically — no intervention needed.

By default, the output is thrown away (> /dev/null). If you'd rather have a log you can review, update the cron line to write to a file:

crontab -e

Change the line to:

48 18 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" >> /var/log/tactical-ssl-renew.log 2>&1

You can also check acme.sh's own internal log at any time:

tail -f /root/.acme.sh/acme.sh.log

Things That Tripped Me Up (Save Yourself the Trouble)

Registering all three domains at once on the first run. If you pass all three -d flags in a single command before the domains are registered with acme-dns, acme.sh creates one shared account for all three. That leads to NXDOMAIN errors when Let's Encrypt tries to validate the second and third domains. Always register them one at a time first.

ZeroSSL being the default CA. The acme.sh default CA was quietly changed to ZeroSSL a few years ago. ZeroSSL sometimes returns a 24-hour retry delay during multi-domain validation, which makes acme.sh give up immediately. Switch to Let's Encrypt explicitly using --set-default-ca.

The deploy hook file format. acme.sh deploy hooks are sourced as shell libraries, not run as standalone scripts. The function must be named <filename>_deploy, and the cert paths come from environment variables ($CERT_FULLCHAIN_PATH, $CERT_KEY_PATH), not from $1, $2, etc. Using the wrong variable names results in silent empty cp commands.

Always use the fullchain cert, not the leaf cert. The MeshCentral agent and NATS connections will drop if you only serve the leaf certificate. Use fullchain.cer (not api.yourdomain.com.cer) when copying to your production path.

ECC cert flag consistency. Once you issue a certificate with --ecc, every subsequent acme.sh command for that domain needs the --ecc flag too. acme.sh stores ECC and RSA certificates in separate directories (the ECC one has an _ecc suffix), and it won't find the right one without the flag.


Summary

Once this is set up, you never need to think about SSL again for your Tactical RMM instance. The daily cron job checks expiry, acme-dns handles the Let's Encrypt challenge without exposing your DNS provider credentials, the deploy hook puts everything in the right place, and Slack tells you it worked.

What Does What
acme.sh Requests and renews the certificate
acme-dns Answers Let's Encrypt's DNS challenge safely
CNAME records Delegates challenge validation to acme-dns (set once, permanent)
Deploy hook Copies certs, fixes permissions, restarts services
/etc/ssl/tacticalrmm/ Where the live certs live on disk
Daily cron Checks for renewals every day at 18:48
Slack webhook Notifies you when a renewal completes or fails

References

jevellangelo/tacticalrmm-ssl-automation
Contribute to jevellangelo/tacticalrmm-ssl-automation development by creating an account on GitHub.