Issue a Let’s Encrypt DNS-01 Certificate on Ubuntu and Import It into Synology DSM 7.4

My Synology NAS is available only on the local network, so I do not want certificate renewal to depend on an inbound HTTP port. Its DNS zone is hosted by Cloudflare, which makes DNS-01 a better fit.

This guide uses Certbot on Ubuntu 26.04 to request the certificate and DSM 7.4.1 to import it. Steps 1–8 cover manual import; steps 9–15 add automatic renewal over SSH.


Lab context

  • Certificate host: Ubuntu 26.04 LTS
  • ACME client: Certbot 4.0.0
  • DNS provider: Cloudflare
  • Challenge type: DNS-01
  • Target: Synology DSM 7.4.1
  • Example certificate name: nas.example.com
  • Example documentation address: 192.0.2.30
  • Manual import: DSM Control Panel
  • Optional automation: Certbot deploy hook, restricted SSH transfer, and a DSM worker

Replace the example hostname, IP address, and account name with your own values. Do not commit the Cloudflare token or private keys to Git.

192.0.2.30 is reserved for documentation by RFC 5737. Replace it with the actual private address of your NAS.


What you’ll build

Cloudflare authoritative DNS
        ↑ temporary _acme-challenge TXT record
Ubuntu Certbot host

        ├─ supported path → secure copy → DSM Control Panel import

        └─ optional tested path → Certbot deploy hook
                                      │ restricted SSH transfer

                              DSM staging account
                                      │ five-minute root worker

                         validate → back up → import → verify

Why DNS-01 runs on a separate host

A DNS-01 challenge proves control of a domain by publishing a TXT record under _acme-challenge. Let’s Encrypt does not need to connect to the NAS, and the NAS hostname does not need a public A or AAAA record. This makes DNS-01 suitable for services that are available only through a LAN or VPN.

The Let’s Encrypt DNS-01 documentation recommends narrowly scoped DNS credentials or performing validation on a separate server. In this design, the Cloudflare token stays on the Ubuntu certificate host.

Internal clients still need an internal DNS record such as nas.example.com → 192.0.2.30. Public DNS only needs to allow the temporary ACME TXT record.


What Synology officially supports

DSM supports certificate import through Control Panel → Security → Certificate. The manual procedure below uses that interface. See Synology’s certificate documentation for the required files.

The automatic renewal section calls SYNO.Core.Certificate, the internal WebAPI used by DSM Control Panel. Synology does not document it as a public API, so it may change after a DSM update.

After a DSM update, test the automation before relying on it. If it fails, import the certificate manually; do not copy files directly into /usr/syno/etc/certificate.


Prerequisites

  • A domain whose authoritative DNS is managed by Cloudflare
  • A Cloudflare API token limited to DNS editing for the required zone
  • An Ubuntu host for Certbot
  • An internal DNS record for the final NAS hostname
  • A DSM administrator account for the initial Control Panel import and later manual recovery
  • A safe way to copy three PEM files to the computer used to manage DSM
  • Time to check DSM and its packages after the first import
  • For automatic renewal: SSH, Python 3, OpenSSL, and curl on DSM; Python 3 and OpenSSH on Ubuntu; a dedicated DSM staging account; and the scripts shown later

  1. Configure the DSM domain

Run on: DSM Control Panel

Open Control Panel → Login Portal → DSM. Under Domain, set the customized domain to the certificate hostname. See Synology’s Login Portal documentation for this setting.

nas.example.com

Leave the DSM management ports at 5000 and 5001. With a customized domain, DSM can also answer on standard HTTPS port 443 when that port is available. Direct access through port 5001 still works.

Enable HSTS only after the domain, certificate, and renewal have been tested.


  1. Create and store a restricted Cloudflare API token

Run on: Cloudflare dashboard, then Ubuntu

In Cloudflare, start with the Edit Zone DNS token template and limit it to the zone that contains nas.example.com. See Cloudflare’s Create API token guide.

Create a root-only credentials file:

sudo install -d -o root -g root -m 700 /root/.secrets/certbot
sudoedit /root/.secrets/certbot/cloudflare.ini

Add the token without quotes:

dns_cloudflare_api_token = REPLACE_WITH_THE_RESTRICTED_TOKEN

Protect the file:

sudo chown root:root /root/.secrets/certbot/cloudflare.ini
sudo chmod 600 /root/.secrets/certbot/cloudflare.ini

Use an API token, not the Global API Key, and keep the credentials file out of Git.


  1. Install Certbot and the Cloudflare DNS plugin

Run on: Ubuntu

sudo apt update
sudo apt install certbot python3-certbot-dns-cloudflare

certbot --version
certbot plugins | grep -A 3 dns-cloudflare

The DNS plugin is a separate package. Its documentation shows the credentials format and required Zone:DNS:Edit permission.


  1. Request the first certificate

Run on: Ubuntu

sudo certbot certonly \
  --cert-name nas.example.com \
  --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/certbot/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 30 \
  --non-interactive \
  --agree-tos \
  --email admin@example.com \
  -d nas.example.com

Replace admin@example.com with an address you monitor. Certbot creates and removes the temporary TXT record automatically. --cert-name nas.example.com keeps the files under /etc/letsencrypt/live/nas.example.com.

Inspect the issued certificate:

sudo openssl x509 \
  -in /etc/letsencrypt/live/nas.example.com/cert.pem \
  -noout -subject -issuer -dates -fingerprint -sha256

sudo openssl x509 \
  -in /etc/letsencrypt/live/nas.example.com/cert.pem \
  -noout -checkhost nas.example.com

Certbot files used by DSM

  • cert.pem: the leaf certificate for nas.example.com
  • chain.pem: the intermediate CA chain uploaded to DSM
  • privkey.pem: the private key; this is the most sensitive file in the bundle
  • fullchain.pem: leaf certificate plus chain, useful for verification but not uploaded as the DSM leaf-certificate field

  1. Transfer and import the certificate

Run on: Ubuntu, then DSM Control Panel

Copy privkey.pem, cert.pem, and chain.pem from /etc/letsencrypt/live/nas.example.com/ to a temporary folder on the computer used to open DSM. Use a secure transfer method; do not put the files in email, chat, Git, or a shared folder.

Sign in to DSM Control Panel. For the first import, open Control Panel → Security → Certificate → Add → Add a new certificate → Import certificate. For a renewal, choose Add → Replace an existing certificate and select the certificate to replace.

  • Private key: privkey.pem
  • Certificate: cert.pem
  • Intermediate certificate: chain.pem

Before replacing the default certificate, note which DSM services currently use it.

After the import works, delete the temporary copies from the administrator computer. Certbot keeps its originals under /etc/letsencrypt. Treat any backup containing privkey.pem as a private key.


  1. Assign the certificate to DSM services

Run on: DSM Control Panel

Open Control Panel → Security → Certificate → Settings and choose the new certificate for each service that needs it. Check DSM Desktop, Synology Drive, FTPS, and any installed packages separately.


  1. Verify TLS

Run on: Ubuntu or another trusted Linux client

Test with the final hostname, not the NAS IP address:

curl -Iv https://nas.example.com/

openssl s_client \
  -connect nas.example.com:443 \
  -servername nas.example.com \
  -verify_hostname nas.example.com \
  -verify_return_error </dev/null

-servername sends SNI but does not check the hostname. That is why the command also uses -verify_hostname. A successful test ends with Verify return code: 0 (ok).

Do not use curl -k for the final test. Opening https://192.0.2.30 may fail—and should fail—because the certificate is issued for nas.example.com.


  1. Test renewal without replacing the current certificate

Run on: Ubuntu

sudo certbot renew \
  --cert-name nas.example.com \
  --dry-run

systemctl status certbot.timer --no-pager
systemctl list-timers certbot.timer --no-pager

A normal dry run uses Let’s Encrypt’s staging CA and skips deploy hooks. With --run-deploy-hooks, Certbot runs the hook after a successful test but passes it the current production certificate, not the temporary staging certificate. DSM receives a new certificate only after a real renewal.


Optional automatic renewal

The examples below use nas.example.com, 192.0.2.30, and a DSM account named certdeploy. Change them before running the scripts. Leave the timer disabled until a manual test succeeds.

Limitations

  • The Certbot hook sends the certificate directly over SSH. If the NAS is offline, Ubuntu does not save the job for a later retry.
  • The receiver and worker share one pending.json file. The worker does not rename it first to mark the job as in progress.
  • Processed payloads still contain the Base64-encoded private key and remain on disk until they are deleted manually.
  • Rollback backups include private keys and are not deleted automatically.
  • During import, the worker temporarily disables DSM’s global OTP setting and creates an administrator account for the internal API call. If a run stops unexpectedly, check that OTP was restored and the temporary account was deleted.
  • A crash may leave /run/nas-certificate-deploy.lock behind. Delete it only after confirming that the worker is not running; otherwise later timer runs will stop with an “already running” error.
  • The worker replaces whichever certificate DSM currently marks as default. It does not check the service assignments first.

Use manual import if these limitations do not fit your environment. If renewal happens while the NAS is offline, run the publisher again after the NAS reconnects.


  1. Review the automation files

There is nothing to run in this step. The list shows where each file belongs before the installation steps begin.

  • audit-synology-certificate-deployment.sh — DSM: Shows the default certificate and the services that use it.
  • receive-nas-certificate.py — DSM: Accepts one certificate over the restricted SSH key and saves it for the worker.
  • deploy-nas-certificate.sh — DSM: Checks the received files, backs up the current certificate, imports the new one, and verifies the result.
  • install-nas-certificate-automation.sh — DSM: Installs the worker, systemd service, and five-minute timer.
  • publish-nas-certificate.sh — Ubuntu: Runs after renewal and sends the new certificate to DSM over SSH.

The installer below creates both the DSM service and its five-minute timer, so there are no separate systemd unit files to download.

  1. Check the existing DSM certificate

Run on: Ubuntu or the administrator workstation, then DSM over SSH

Copy the audit script to the DSM staging account, connect over SSH as certdeploy, and run it with sudo. It shows the default certificate and the services assigned to it.

Do not enable the worker unless the audit finds exactly one matching certificate, it is the current default, and the listed service assignments are correct. Run the audit again after changing assignments or upgrading DSM.

audit-synology-certificate-deployment.sh
#!/bin/sh

# Read-only preflight for automating a Certbot certificate deployment to DSM.
# It does not modify certificates, service bindings, or running services.

set -eu
umask 077

TARGET_HOST=${1:-nas.example.com}
CERT_ROOT=/usr/syno/etc/certificate
ARCHIVE_DIR=$CERT_ROOT/_archive
ARCHIVE_INFO=$ARCHIVE_DIR/INFO
DEFAULT_FILE=$ARCHIVE_DIR/DEFAULT

fail() {
    echo "ERROR: $*" >&2
    exit 1
}

if [ "$(id -u)" -ne 0 ]; then
    fail "Run this script with sudo; DSM certificate metadata is root-only."
fi

case "$TARGET_HOST" in
    ''|*[!A-Za-z0-9.-]*)
        fail "Invalid target hostname: $TARGET_HOST"
        ;;
esac

[ -d "$ARCHIVE_DIR" ] || fail "Certificate archive not found: $ARCHIVE_DIR"
[ -r "$ARCHIVE_INFO" ] || fail "Certificate metadata not readable: $ARCHIVE_INFO"
command -v openssl >/dev/null 2>&1 || fail "openssl is unavailable"
command -v python3 >/dev/null 2>&1 || fail "python3 is unavailable"

echo "DSM_CERTIFICATE_DEPLOYMENT_AUDIT"
echo "Target hostname: $TARGET_HOST"
if [ -r /etc.defaults/VERSION ]; then
    version=$(awk -F= '
        $1 == "productversion" { gsub(/"/, "", $2); version=$2 }
        $1 == "buildnumber" { gsub(/"/, "", $2); build=$2 }
        END { printf "%s-%s", version, build }
    ' /etc.defaults/VERSION)
    echo "DSM version: $version"
fi

default_id=''
if [ -r "$DEFAULT_FILE" ]; then
    default_id=$(tr -d '\r\n' <"$DEFAULT_FILE")
fi
echo "Default certificate ID: ${default_id:-<not set>}"

echo
echo "=== DSM certificate registry ==="
python3 - "$ARCHIVE_INFO" "$default_id" <<'PY'
import json
import sys

path, default_id = sys.argv[1:]
with open(path, "r", encoding="utf-8") as handle:
    data = json.load(handle)

entries = []
if isinstance(data, dict):
    nested = data.get("certs")
    if isinstance(nested, list):
        entries = [(str(item.get("id", "")), item) for item in nested
                   if isinstance(item, dict)]
    elif isinstance(nested, dict):
        entries = [(str(cert_id), item) for cert_id, item in nested.items()
                   if isinstance(item, dict)]
    else:
        # DSM 7.4 stores certificate IDs as the top-level object keys.
        entries = [(str(cert_id), item) for cert_id, item in data.items()
                   if isinstance(item, dict)]
elif isinstance(data, list):
    entries = [(str(item.get("id", "")), item) for item in data
               if isinstance(item, dict)]

if not entries:
    print("No certificate entries found in INFO.")

for cert_id, item in entries:
    cert_id = str(item.get("id", cert_id))
    desc = str(item.get("desc", ""))
    marker = " [DEFAULT]" if cert_id == default_id else ""
    print(f"ID: {cert_id}{marker}")
    print(f"  Description: {desc or '<empty>'}")
    services = item.get("services", []) or []
    if not services:
        print("  Services: <none>")
    else:
        print("  Services:")
        for service in services:
            subscriber = service.get("subscriber", "")
            name = service.get("service", "")
            display = service.get("display_name", "")
            package = service.get("isPkg", False)
            print(
                "    - subscriber={}; service={}; display={}; package={}".format(
                    subscriber or "<empty>",
                    name or "<empty>",
                    display or "<empty>",
                    "yes" if package else "no",
                )
            )
PY

echo
echo "=== Certificate files ==="
matched_ids=''
for cert_dir in "$ARCHIVE_DIR"/*; do
    [ -d "$cert_dir" ] || continue
    cert_id=${cert_dir##*/}
    cert_file=$cert_dir/cert.pem
    chain_file=$cert_dir/fullchain.pem
    key_file=$cert_dir/privkey.pem

    if [ ! -r "$cert_file" ]; then
        echo "ID: $cert_id (missing readable cert.pem)"
        continue
    fi

    echo "ID: $cert_id"
    openssl x509 -in "$cert_file" -noout \
        -subject -issuer -dates -fingerprint -sha256 \
        | sed 's/^/  /'
    san=$(openssl x509 -in "$cert_file" -noout -ext subjectAltName 2>/dev/null \
        | tail -n +2 | tr '\n' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
    echo "  SAN: ${san:-<none>}"

    host_check=$(openssl x509 -in "$cert_file" -noout \
        -checkhost "$TARGET_HOST" 2>&1 || true)
    if printf '%s\n' "$host_check" \
        | grep -Fqx "Hostname $TARGET_HOST does match certificate"; then
        echo "  Hostname match: yes"
        matched_ids="$matched_ids $cert_id"
    else
        echo "  Hostname match: no"
    fi

    if [ -r "$key_file" ]; then
        cert_pub=$(openssl x509 -in "$cert_file" -pubkey -noout 2>/dev/null \
            | openssl pkey -pubin -outform DER 2>/dev/null \
            | openssl dgst -sha256 | awk '{print $NF}')
        key_pub=$(openssl pkey -in "$key_file" -pubout -outform DER 2>/dev/null \
            | openssl dgst -sha256 | awk '{print $NF}')
        if [ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ]; then
            echo "  Private key match: yes"
        else
            echo "  Private key match: NO"
        fi
    else
        echo "  Private key match: unavailable"
    fi

    if [ -r "$chain_file" ]; then
        chain_count=$(grep -c 'BEGIN CERTIFICATE' "$chain_file" || true)
        echo "  Full chain certificates: $chain_count"
    else
        echo "  Full chain certificates: unavailable"
    fi
done

echo
echo "=== DSM certificate consumers ==="
find "$CERT_ROOT" -path "$ARCHIVE_DIR" -prune -o -type f -name cert.pem -print \
    2>/dev/null | sort | while IFS= read -r cert_file; do
        fingerprint=$(openssl x509 -in "$cert_file" -noout -fingerprint -sha256 \
            2>/dev/null | sed 's/^[^=]*=//' || true)
        subject=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null \
            | sed 's/^subject=//' || true)
        printf '%s\n  SHA256: %s\n  Subject: %s\n' \
            "$cert_file" "${fingerprint:-<unreadable>}" "${subject:-<unreadable>}"
    done

echo
echo "=== Root WebAPI command usage ==="
if [ -x /usr/syno/bin/synowebapi ]; then
    /usr/syno/bin/synowebapi --help 2>&1 | head -n 100 || true
else
    echo "synowebapi is unavailable"
fi

set -- $matched_ids
match_count=$#
echo
echo "=== Deployment decision ==="
echo "Matching certificate IDs: ${matched_ids# }"
case "$match_count" in
    0)
        echo "Result: NO_EXISTING_TARGET"
        echo "A certificate registry entry must be created before safe in-place renewal."
        ;;
    1)
        echo "Result: UNIQUE_EXISTING_TARGET"
        echo "Candidate certificate ID: $1"
        ;;
    *)
        echo "Result: AMBIGUOUS_TARGET"
        echo "More than one certificate matches; service bindings must select the target."
        ;;
esac

echo
echo "READ_ONLY_AUDIT_COMPLETE"
# Ubuntu or the administrator workstation
scp audit-synology-certificate-deployment.sh \
  certdeploy@192.0.2.30:/var/services/homes/certdeploy/

# DSM, signed in as certdeploy
chmod 700 ~/audit-synology-certificate-deployment.sh
sudo ~/audit-synology-certificate-deployment.sh nas.example.com
  1. Install the DSM-side scripts and timer

Run on: DSM Control Panel and DSM over SSH; copy the files from Ubuntu or the administrator workstation

In DSM Control Panel, create certdeploy, add it to administrators, and enable User Home under Control Panel → User & Group → Advanced. Then confirm over SSH that /var/services/homes/certdeploy belongs to that account. DSM requires administrator membership for SSH access. The forced command in step 12 limits only the deployment key, so use a unique password and do not add other keys to this account.

In DSM Package Center, install Synology’s Python 3 package if python3 is missing. Over SSH, confirm that python3, openssl, and curl are available before enabling the service.

Save the next three files on Ubuntu or the administrator computer, edit the values at the top, and copy them to /var/services/homes/certdeploy with the command after the listings. All three run on DSM.

receive-nas-certificate.py
#!/usr/bin/python3

"""Forced-command receiver for the nas.example.com certificate bundle.

The SSH key using this command cannot run arbitrary commands.  This receiver
only accepts a small JSON payload and atomically stages it for the root timer.
It intentionally does not install certificates or invoke privileged tools.
"""

import base64
import binascii
import json
import os
import sys


TARGET_HOST = "nas.example.com"
STAGE_DIR = "/var/services/homes/certdeploy/.certificate-deploy"
PENDING_FILE = os.path.join(STAGE_DIR, "pending.json")
MAX_PAYLOAD = 256 * 1024
MAX_PEM = 96 * 1024
REQUIRED_PEM_FIELDS = ("cert", "chain", "privkey")


def fail(message: str) -> None:
    print(f"ERROR: {message}", file=sys.stderr)
    raise SystemExit(1)


def read_payload() -> bytes:
    payload = sys.stdin.buffer.read(MAX_PAYLOAD + 1)
    if not payload:
        fail("empty certificate payload")
    if len(payload) > MAX_PAYLOAD:
        fail("certificate payload is too large")
    return payload


def validate_payload(raw: bytes) -> None:
    try:
        payload = json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        fail(f"invalid JSON payload: {exc}")

    if not isinstance(payload, dict):
        fail("payload must be a JSON object")
    if payload.get("hostname") != TARGET_HOST:
        fail("unexpected certificate hostname")

    allowed = {"hostname", "cert", "chain", "privkey", "created_at"}
    unexpected = set(payload) - allowed
    if unexpected:
        fail("unexpected payload fields: " + ", ".join(sorted(unexpected)))

    markers = {
        "cert": (b"-----BEGIN CERTIFICATE-----", b"-----END CERTIFICATE-----"),
        "chain": (b"-----BEGIN CERTIFICATE-----", b"-----END CERTIFICATE-----"),
        "privkey": (b"-----BEGIN ", b"PRIVATE KEY-----"),
    }
    for field in REQUIRED_PEM_FIELDS:
        value = payload.get(field)
        if not isinstance(value, str) or not value:
            fail(f"missing payload field: {field}")
        try:
            decoded = base64.b64decode(value, validate=True)
        except (ValueError, binascii.Error):
            fail(f"invalid base64 in field: {field}")
        if not decoded or len(decoded) > MAX_PEM:
            fail(f"invalid PEM size in field: {field}")
        first, last = markers[field]
        if first not in decoded or last not in decoded:
            fail(f"invalid PEM markers in field: {field}")


def atomic_stage(raw: bytes) -> None:
    os.makedirs(STAGE_DIR, mode=0o700, exist_ok=True)
    os.chmod(STAGE_DIR, 0o700)
    temporary = os.path.join(STAGE_DIR, f".pending.{os.getpid()}.tmp")
    flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
    if hasattr(os, "O_NOFOLLOW"):
        flags |= os.O_NOFOLLOW

    fd = os.open(temporary, flags, 0o600)
    try:
        with os.fdopen(fd, "wb", closefd=True) as handle:
            handle.write(raw)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(temporary, PENDING_FILE)
        os.chmod(PENDING_FILE, 0o600)
        directory_fd = os.open(STAGE_DIR, os.O_RDONLY)
        try:
            os.fsync(directory_fd)
        finally:
            os.close(directory_fd)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


def main() -> None:
    raw = read_payload()
    validate_payload(raw)
    atomic_stage(raw)
    print("CERTIFICATE_STAGED")


if __name__ == "__main__":
    main()
deploy-nas-certificate.sh
#!/bin/sh

# Root-only DSM certificate deployer. It validates a fixed staging payload,
# creates a short-lived local DSM administrator, imports the certificate using
# the same WebAPI as Control Panel, then logs out and deletes that account.

set -eu
umask 077

TARGET_HOST=nas.example.com
TEMP_USER=cert-import-tmp
STAGE_DIR=/var/services/homes/certdeploy/.certificate-deploy
PENDING_FILE=$STAGE_DIR/pending.json
PROCESSED_DIR=$STAGE_DIR/processed
CERT_ROOT=/usr/syno/etc/certificate
ARCHIVE_DIR=$CERT_ROOT/_archive
ARCHIVE_INFO=$ARCHIVE_DIR/INFO
DEFAULT_FILE=$ARCHIVE_DIR/DEFAULT
BACKUP_ROOT=/volume1/nas-certificate-backups
LOCK_DIR=/run/nas-certificate-deploy.lock
CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
BASE_URL=http://127.0.0.1:5000
SYNOUSER=/usr/syno/sbin/synouser
SYNOGROUP=/usr/syno/sbin/synogroup
SYNOGETKEYVALUE=/usr/syno/bin/synogetkeyvalue
SYNOSETKEYVALUE=/usr/syno/bin/synosetkeyvalue

work_dir=''
backup_dir=''
target_id=''
old_description=''
temp_created=0
otp_changed=0
otp_option=''
sid=''
token=''
auth_path=''
auth_version=''
completed=0

log() {
    message="$(date '+%Y-%m-%dT%H:%M:%S%z') $*"
    echo "$message"
    logger -t nas-certificate-deploy "$message" 2>/dev/null || true
}

fail() {
    log "ERROR: $*" >&2
    exit 1
}

restore_otp_policy() {
    if [ "$otp_changed" -eq 1 ]; then
        "$SYNOSETKEYVALUE" /etc/synoinfo.conf otp_enforce_option "$otp_option" \
            >/dev/null 2>&1 || true
        otp_changed=0
    fi
}

logout_api() {
    if [ -n "$sid" ] && [ -n "$auth_path" ] && [ -n "$auth_version" ]; then
        curl -fsS --get \
            --data-urlencode 'api=SYNO.API.Auth' \
            --data-urlencode "version=$auth_version" \
            --data-urlencode 'method=logout' \
            --data-urlencode "_sid=$sid" \
            "$BASE_URL/webapi/$auth_path" >/dev/null 2>&1 || true
        sid=''
        token=''
    fi
}

delete_temp_admin() {
    if [ "$temp_created" -eq 1 ]; then
        "$SYNOUSER" --del "$TEMP_USER" >/dev/null 2>&1 || true
        temp_created=0
    fi
}

on_exit() {
    status=$?
    trap - EXIT HUP INT TERM
    restore_otp_policy
    logout_api
    delete_temp_admin
    if [ -n "$work_dir" ] && [ -d "$work_dir" ]; then
        rm -f \
            "$work_dir/payload.json" \
            "$work_dir/cert.pem" \
            "$work_dir/chain.pem" \
            "$work_dir/privkey.pem" \
            "$work_dir/fullchain.pem"
        rmdir "$work_dir" 2>/dev/null || true
    fi
    [ -d "$LOCK_DIR" ] && rmdir "$LOCK_DIR" 2>/dev/null || true
    exit "$status"
}
trap on_exit EXIT HUP INT TERM

archive_pending() {
    result=$1
    fingerprint=$2
    timestamp=$(date +%Y%m%d-%H%M%S)
    mkdir -p "$PROCESSED_DIR"
    chmod 700 "$PROCESSED_DIR"
    safe_fingerprint=$(printf '%s' "$fingerprint" | tr -d ':' | tr 'A-F' 'a-f')
    mv "$PENDING_FILE" "$PROCESSED_DIR/$timestamp-$result-$safe_fingerprint.json"
}

certificate_description() {
    python3 - "$ARCHIVE_INFO" "$target_id" <<'PY'
import json
import sys

path, target_id = sys.argv[1:]
with open(path, "r", encoding="utf-8") as handle:
    data = json.load(handle)

item = None
if isinstance(data, dict):
    nested = data.get("certs")
    if isinstance(nested, dict):
        item = nested.get(target_id)
    elif isinstance(nested, list):
        item = next((entry for entry in nested
                     if isinstance(entry, dict)
                     and str(entry.get("id", "")) == target_id), None)
    elif isinstance(data.get(target_id), dict):
        item = data[target_id]

if isinstance(item, dict):
    print(str(item.get("desc", "")))
else:
    print("")
PY
}

discover_auth_api() {
    if ! response=$(curl -fsS --get \
        --data-urlencode 'api=SYNO.API.Info' \
        --data-urlencode 'version=1' \
        --data-urlencode 'method=query' \
        --data-urlencode 'query=SYNO.API.Auth' \
        "$BASE_URL/webapi/query.cgi"); then
        fail "unable to query DSM authentication API"
    fi
    auth_fields=$(printf '%s' "$response" | python3 -c '
import json,sys
data=json.load(sys.stdin).get("data",{}).get("SYNO.API.Auth",{})
path=data.get("path","")
version=data.get("maxVersion","")
if not path or not version:
    raise SystemExit(1)
print(path)
print(version)
') || fail "unable to discover DSM authentication API"
    auth_path=$(printf '%s\n' "$auth_fields" | sed -n '1p')
    auth_version=$(printf '%s\n' "$auth_fields" | sed -n '2p')
}

create_temp_admin() {
    random_password=$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')
    [ "${#random_password}" -eq 48 ] || fail "unable to generate temporary password"
    temp_password="Aa9!${random_password}Zz"

    "$SYNOUSER" --del "$TEMP_USER" >/dev/null 2>&1 || true
    if ! account_output=$("$SYNOUSER" --add \
        "$TEMP_USER" "$temp_password" '' 0 '' 0 2>&1); then
        fail "unable to create temporary DSM account: $account_output"
    fi
    temp_created=1
    "$SYNOGROUP" --memberadd administrators "$TEMP_USER" >/dev/null 2>&1 || true
    group_output=$("$SYNOGROUP" --get administrators 2>&1 || true)
    if ! printf '%s\n' "$group_output" \
        | grep -Fq "[$TEMP_USER]"; then
        fail "temporary DSM account was not added to administrators"
    fi
    log "Temporary DSM administrator created"

    if [ -x "$SYNOGETKEYVALUE" ] && [ -x "$SYNOSETKEYVALUE" ]; then
        otp_option=$("$SYNOGETKEYVALUE" /etc/synoinfo.conf otp_enforce_option \
            2>/dev/null || true)
        if [ -n "$otp_option" ] && [ "$otp_option" != none ]; then
            "$SYNOSETKEYVALUE" /etc/synoinfo.conf otp_enforce_option none
            otp_changed=1
        fi
    fi
}

login_api() {
    if ! response=$(curl -fsS --get \
        --data-urlencode 'api=SYNO.API.Auth' \
        --data-urlencode "version=$auth_version" \
        --data-urlencode 'method=login' \
        --data-urlencode 'format=sid' \
        --data-urlencode "account=$TEMP_USER" \
        --data-urlencode "passwd=$temp_password" \
        --data-urlencode 'enable_syno_token=yes' \
        "$BASE_URL/webapi/$auth_path"); then
        fail "DSM authentication request failed"
    fi
    restore_otp_policy

    auth_fields=$(printf '%s' "$response" | python3 -c '
import json,sys
reply=json.load(sys.stdin)
if not reply.get("success"):
    raise SystemExit("DSM authentication failed: {}".format(reply.get("error",{}).get("code","unknown")))
data=reply.get("data",{})
sid=data.get("sid","")
token=data.get("synotoken","")
if not sid or not token:
    raise SystemExit("DSM authentication response is incomplete")
print(sid)
print(token)
') || fail "temporary DSM administrator could not authenticate"
    sid=$(printf '%s\n' "$auth_fields" | sed -n '1p')
    token=$(printf '%s\n' "$auth_fields" | sed -n '2p')
    log "Temporary DSM administrator authenticated locally"
}

api_import() {
    import_key=$1
    import_cert=$2
    import_chain=$3
    import_description=$4

    if [ -s "$import_chain" ]; then
        if ! response=$(curl -fsS -X POST \
            -H "X-SYNO-TOKEN: $token" \
            -F "key=@$import_key;type=application/octet-stream" \
            -F "cert=@$import_cert;type=application/octet-stream" \
            -F "inter_cert=@$import_chain;type=application/octet-stream" \
            -F "id=$target_id" \
            -F "desc=$import_description" \
            -F 'as_default=true' \
            "$BASE_URL/webapi/entry.cgi?api=SYNO.Core.Certificate&method=import&version=1&SynoToken=$token&_sid=$sid"); then
            log "ERROR: DSM certificate upload request failed"
            return 1
        fi
    else
        if ! response=$(curl -fsS -X POST \
            -H "X-SYNO-TOKEN: $token" \
            -F "key=@$import_key;type=application/octet-stream" \
            -F "cert=@$import_cert;type=application/octet-stream" \
            -F "id=$target_id" \
            -F "desc=$import_description" \
            -F 'as_default=true' \
            "$BASE_URL/webapi/entry.cgi?api=SYNO.Core.Certificate&method=import&version=1&SynoToken=$token&_sid=$sid"); then
            log "ERROR: DSM certificate upload request failed"
            return 1
        fi
    fi

    printf '%s' "$response" | python3 -c '
import json,sys
reply=json.load(sys.stdin)
if not reply.get("success"):
    raise SystemExit("DSM certificate import failed: {}".format(reply.get("error",{}).get("code","unknown")))
' || return 1
}

wait_for_fingerprint() {
    expected=$1
    count=0
    while [ "$count" -lt 20 ]; do
        if [ -r "$CERT_ROOT/system/default/cert.pem" ]; then
            actual=$(openssl x509 -in "$CERT_ROOT/system/default/cert.pem" \
                -noout -fingerprint -sha256 2>/dev/null \
                | sed 's/^[^=]*=//' || true)
            [ "$actual" = "$expected" ] && return 0
        fi
        count=$((count + 1))
        sleep 2
    done
    return 1
}

if [ "$(id -u)" -ne 0 ]; then
    fail "This deployment service must run as root"
fi

if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    fail "another certificate deployment is already running"
fi

if [ ! -e "$PENDING_FILE" ]; then
    completed=1
    log "No pending certificate"
    exit 0
fi
[ ! -L "$PENDING_FILE" ] || fail "pending payload must not be a symbolic link"

stage_uid=$(id -u certdeploy)
payload_uid=$(stat -c '%u' "$PENDING_FILE")
payload_mode=$(stat -c '%a' "$PENDING_FILE")
[ "$payload_uid" = "$stage_uid" ] || fail "pending payload owner is not certdeploy"
[ "$payload_mode" = 600 ] || fail "pending payload mode must be 600"

for required in "$ARCHIVE_INFO" "$DEFAULT_FILE" "$CA_BUNDLE"; do
    [ -r "$required" ] || fail "required DSM file is unavailable: $required"
done
for command in curl openssl python3 od; do
    command -v "$command" >/dev/null 2>&1 || fail "required command is missing: $command"
done
for command in "$SYNOUSER" "$SYNOGROUP"; do
    [ -x "$command" ] || fail "required DSM command is missing: $command"
done

work_dir=$(mktemp -d /tmp/nas-certificate-deploy.XXXXXX)
cp "$PENDING_FILE" "$work_dir/payload.json"

python3 - "$work_dir/payload.json" "$work_dir" "$TARGET_HOST" <<'PY'
import base64
import binascii
import json
import os
import pathlib
import sys

payload_path = pathlib.Path(sys.argv[1])
output_dir = pathlib.Path(sys.argv[2])
hostname = sys.argv[3]
payload = json.loads(payload_path.read_text(encoding="utf-8"))
if payload.get("hostname") != hostname:
    raise SystemExit("payload hostname does not match the configured hostname")

for field, filename in (("cert", "cert.pem"), ("chain", "chain.pem"), ("privkey", "privkey.pem")):
    try:
        decoded = base64.b64decode(payload[field], validate=True)
    except (KeyError, ValueError, binascii.Error) as exc:
        raise SystemExit(f"invalid {field} payload: {exc}")
    if not decoded or len(decoded) > 96 * 1024:
        raise SystemExit(f"invalid {field} payload size")
    target = output_dir / filename
    fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "wb") as handle:
        handle.write(decoded)
PY

openssl x509 -in "$work_dir/cert.pem" -noout >/dev/null 2>&1 || fail "invalid leaf certificate"
openssl pkey -in "$work_dir/privkey.pem" -check -noout >/dev/null 2>&1 || fail "invalid private key"
host_check=$(openssl x509 -in "$work_dir/cert.pem" -noout -checkhost "$TARGET_HOST" 2>&1 || true)
printf '%s\n' "$host_check" | grep -Fqx "Hostname $TARGET_HOST does match certificate" \
    || fail "certificate does not match $TARGET_HOST"
openssl x509 -in "$work_dir/cert.pem" -noout -checkend 2592000 \
    || fail "certificate expires within 30 days"

cert_pub=$(openssl x509 -in "$work_dir/cert.pem" -pubkey -noout \
    | openssl pkey -pubin -outform DER 2>/dev/null \
    | openssl dgst -sha256 | awk '{print $NF}')
key_pub=$(openssl pkey -in "$work_dir/privkey.pem" -pubout -outform DER 2>/dev/null \
    | openssl dgst -sha256 | awk '{print $NF}')
[ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ] \
    || fail "certificate and private key do not match"
openssl verify -CAfile "$CA_BUNDLE" -untrusted "$work_dir/chain.pem" \
    "$work_dir/cert.pem" >/dev/null || fail "certificate chain validation failed"
cat "$work_dir/cert.pem" "$work_dir/chain.pem" >"$work_dir/fullchain.pem"
new_fingerprint=$(openssl x509 -in "$work_dir/cert.pem" -noout -fingerprint -sha256 \
    | sed 's/^[^=]*=//')
log "Certificate payload validation passed: $new_fingerprint"

target_id=$(tr -d '\r\n' <"$DEFAULT_FILE")
case "$target_id" in
    ''|*[!A-Za-z0-9_-]*) fail "invalid default certificate ID" ;;
esac
target_dir=$ARCHIVE_DIR/$target_id
[ -d "$target_dir" ] || fail "default certificate directory does not exist"
[ -r "$target_dir/cert.pem" ] || fail "current default certificate is unreadable"
[ -r "$target_dir/privkey.pem" ] || fail "current default private key is unreadable"

current_fingerprint=$(openssl x509 -in "$target_dir/cert.pem" -noout -fingerprint -sha256 \
    | sed 's/^[^=]*=//')
if [ "$current_fingerprint" = "$new_fingerprint" ]; then
    archive_pending unchanged "$new_fingerprint"
    completed=1
    log "Certificate is already current: $new_fingerprint"
    exit 0
fi

old_description=$(certificate_description)
timestamp=$(date +%Y%m%d-%H%M%S)
backup_dir=$BACKUP_ROOT/$timestamp-$target_id
mkdir -p "$backup_dir/target"
chmod 700 "$BACKUP_ROOT" "$backup_dir" "$backup_dir/target"
cp -a "$target_dir/." "$backup_dir/target/"
cp -a "$ARCHIVE_INFO" "$backup_dir/INFO"
cp -a "$DEFAULT_FILE" "$backup_dir/DEFAULT"

discover_auth_api
log "DSM authentication API discovered: $auth_path version $auth_version"
create_temp_admin
login_api

log "Importing certificate into DSM default slot $target_id"
if ! api_import "$work_dir/privkey.pem" "$work_dir/cert.pem" "$work_dir/chain.pem" "$TARGET_HOST"; then
    fail "DSM WebAPI rejected the certificate import"
fi

if ! wait_for_fingerprint "$new_fingerprint"; then
    log "ROLLBACK: DSM did not activate the new certificate"
    old_chain=$backup_dir/target/chain.pem
    [ -r "$old_chain" ] || old_chain=/dev/null
    api_import \
        "$backup_dir/target/privkey.pem" \
        "$backup_dir/target/cert.pem" \
        "$old_chain" \
        "$old_description" || true
    wait_for_fingerprint "$current_fingerprint" || true
    fail "new certificate activation could not be verified"
fi

logout_api
delete_temp_admin
archive_pending deployed "$new_fingerprint"
completed=1
log "CERTIFICATE_DEPLOYMENT_COMPLETE target=$target_id fingerprint=$new_fingerprint backup=$backup_dir"
install-nas-certificate-automation.sh
#!/bin/sh

# One-time root installer for the DSM side of certificate automation.

set -eu
umask 077

STAGE_HOME=/var/services/homes/certdeploy
SOURCE_DEPLOY=$STAGE_HOME/deploy-nas-certificate.sh
SOURCE_RECEIVER=$STAGE_HOME/receive-nas-certificate.py
INSTALL_DEPLOY=/usr/local/sbin/deploy-nas-certificate
SERVICE_FILE=/etc/systemd/system/nas-certificate-deploy.service
TIMER_FILE=/etc/systemd/system/nas-certificate-deploy.timer

fail() {
    echo "ERROR: $*" >&2
    exit 1
}

if [ "$(id -u)" -ne 0 ]; then
    fail "Run this installer with sudo."
fi

[ -r "$SOURCE_DEPLOY" ] || fail "Missing $SOURCE_DEPLOY"
[ -r "$SOURCE_RECEIVER" ] || fail "Missing $SOURCE_RECEIVER"
sh -n "$SOURCE_DEPLOY" || fail "Deployment script syntax check failed"
if ! python3 - "$SOURCE_RECEIVER" <<'PY'
import pathlib
import sys
source = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
compile(source, sys.argv[1], "exec")
PY
then
    fail "Receiver script syntax check failed"
fi

install -d -o certdeploy -g users -m 700 "$STAGE_HOME/.certificate-deploy"
chown certdeploy:users "$SOURCE_RECEIVER"
chmod 700 "$SOURCE_RECEIVER"
install -d -o root -g root -m 755 /usr/local/sbin
install -o root -g root -m 700 "$SOURCE_DEPLOY" "$INSTALL_DEPLOY"

cat >"$SERVICE_FILE" <<'EOF'
[Unit]
Description=Deploy nas.example.com certificate to DSM
After=network.target

[Service]
Type=oneshot
User=root
Group=root
ExecStart=/usr/local/sbin/deploy-nas-certificate
Nice=10
EOF

cat >"$TIMER_FILE" <<'EOF'
[Unit]
Description=Check for a renewed nas.example.com certificate

[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
Persistent=true
Unit=nas-certificate-deploy.service

[Install]
WantedBy=timers.target
EOF

chown root:root "$SERVICE_FILE" "$TIMER_FILE"
chmod 644 "$SERVICE_FILE" "$TIMER_FILE"
systemctl daemon-reload

if [ -e "$STAGE_HOME/.certificate-deploy/pending.json" ]; then
    systemctl start nas-certificate-deploy.service
fi

systemctl enable nas-certificate-deploy.timer
systemctl start nas-certificate-deploy.timer
systemctl is-enabled nas-certificate-deploy.timer
systemctl is-active nas-certificate-deploy.timer
echo "NAS_CERTIFICATE_AUTOMATION_INSTALLED"
# DSM
id certdeploy
ls -ld /var/services/homes/certdeploy
for command in python3 openssl curl; do
  command -v "$command" || exit 1
done

# Ubuntu or the administrator workstation
scp \
  receive-nas-certificate.py \
  deploy-nas-certificate.sh \
  install-nas-certificate-automation.sh \
  certdeploy@192.0.2.30:/var/services/homes/certdeploy/

# DSM, signed in as certdeploy
chmod 700 \
  ~/receive-nas-certificate.py \
  ~/deploy-nas-certificate.sh \
  ~/install-nas-certificate-automation.sh

sudo ~/install-nas-certificate-automation.sh
sudo systemctl status nas-certificate-deploy.timer --no-pager
sudo systemctl list-timers nas-certificate-deploy.timer --no-pager
  1. Restrict the deployment SSH key

Run on: Ubuntu, then DSM over SSH

Generate a dedicated SSH key on Ubuntu. Before saving known_hosts, compare the NAS fingerprint with the fingerprint shown on the NAS or another trusted connection. Store the private key and known_hosts under /root/.ssh with mode 600.

sudo install -d -o root -g root -m 700 /root/.ssh
sudo ssh-keygen -t ed25519 \
  -f /root/.ssh/nas-certificate-deploy \
  -N '' \
  -C 'nas-certificate-deploy'

ssh-keyscan -t ed25519 192.0.2.30 > /tmp/nas-host-key
ssh-keygen -lf /tmp/nas-host-key

# Verify that fingerprint out of band before installing it.
sudo install -o root -g root -m 600 \
  /tmp/nas-host-key \
  /root/.ssh/nas-certificate-known_hosts
rm -f /tmp/nas-host-key

On DSM, sign in as certdeploy, create ~/.ssh/authorized_keys, and add the restricted public key. Replace the placeholder with the complete public key line; do not add the private key.

umask 077
mkdir -p ~/.ssh
touch ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

key_line='restrict,command="/var/services/homes/certdeploy/receive-nas-certificate.py" <DEPLOYMENT_PUBLIC_KEY>'
grep -qxF "$key_line" ~/.ssh/authorized_keys || \
  printf '%s\n' "$key_line" >> ~/.ssh/authorized_keys
unset key_line

If the DSM OpenSSH build does not accept restrict, use the explicit equivalent options supported by that build: no-pty,no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-user-rc together with the same forced command.

  1. Install the Certbot deploy hook

Run on: Ubuntu

publish-nas-certificate.sh
#!/bin/sh

# Certbot deploy hook for nas.example.com.  It sends a strict JSON payload
# through a forced SSH command; the NAS receiver can only stage the payload.

set -eu
umask 077

TARGET_HOST=nas.example.com
NAS_HOST=192.0.2.30
NAS_USER=certdeploy
LINEAGE=${RENEWED_LINEAGE:-/etc/letsencrypt/live/nas.example.com}
SSH_KEY=/root/.ssh/nas-certificate-deploy
KNOWN_HOSTS=/root/.ssh/nas-certificate-known_hosts

fail() {
    echo "ERROR: $*" >&2
    exit 1
}

if [ "$(id -u)" -ne 0 ]; then
    fail "This Certbot deploy hook must run as root."
fi

for file in cert.pem chain.pem privkey.pem; do
    [ -r "$LINEAGE/$file" ] || fail "Missing certificate file: $LINEAGE/$file"
done
[ -r "$SSH_KEY" ] || fail "Missing SSH deployment key: $SSH_KEY"
[ -r "$KNOWN_HOSTS" ] || fail "Missing pinned NAS host key: $KNOWN_HOSTS"

host_check=$(openssl x509 -in "$LINEAGE/cert.pem" -noout \
    -checkhost "$TARGET_HOST" 2>&1 || true)
if ! printf '%s\n' "$host_check" \
    | grep -Fqx "Hostname $TARGET_HOST does match certificate"; then
    fail "Certificate does not match $TARGET_HOST"
fi

if ! openssl x509 -in "$LINEAGE/cert.pem" -noout -checkend 2592000; then
    fail "Certificate expires within 30 days"
fi

cert_pub=$(openssl x509 -in "$LINEAGE/cert.pem" -pubkey -noout \
    | openssl pkey -pubin -outform DER 2>/dev/null \
    | openssl dgst -sha256 | awk '{print $NF}')
key_pub=$(openssl pkey -in "$LINEAGE/privkey.pem" -pubout -outform DER 2>/dev/null \
    | openssl dgst -sha256 | awk '{print $NF}')
[ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ] \
    || fail "Certificate and private key do not match"

python3 - "$LINEAGE" "$TARGET_HOST" <<'PY' \
    | ssh -T \
        -o BatchMode=yes \
        -o IdentitiesOnly=yes \
        -o StrictHostKeyChecking=yes \
        -o "UserKnownHostsFile=$KNOWN_HOSTS" \
        -i "$SSH_KEY" \
        "$NAS_USER@$NAS_HOST" stage-certificate
import base64
import datetime
import json
import pathlib
import sys

lineage = pathlib.Path(sys.argv[1])
hostname = sys.argv[2]

def encoded(name):
    return base64.b64encode((lineage / name).read_bytes()).decode("ascii")

payload = {
    "hostname": hostname,
    "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    "cert": encoded("cert.pem"),
    "chain": encoded("chain.pem"),
    "privkey": encoded("privkey.pem"),
}
json.dump(payload, sys.stdout, separators=(",", ":"))
PY

echo "CERTIFICATE_PUBLISH_COMPLETE"

Before installing the hook, set TARGET_HOST, NAS_HOST, NAS_USER, the Certbot LINEAGE, the SSH key path, and the known_hosts path. Replace the documentation address 192.0.2.30 with the NAS address.

sudo install -o root -g root -m 700 \
  publish-nas-certificate.sh \
  /usr/local/sbin/publish-nas-certificate

sudo ln -sfn \
  /usr/local/sbin/publish-nas-certificate \
  /etc/letsencrypt/renewal-hooks/deploy/publish-nas-certificate

The hook runs only after a successful renewal. Step 8 explains how deploy hooks behave during a dry run.

  1. Send a certificate and check the transfer

Run on: Ubuntu, then DSM over SSH

Run the publisher on Ubuntu and look for CERTIFICATE_STAGED. Either wait up to five minutes for the DSM timer or start the worker manually over SSH. If DSM already has the same certificate, already current is normal. The first real renewal is what tests replacement of the DSM certificate.

# Ubuntu
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/nas.example.com \
  /usr/local/sbin/publish-nas-certificate

# DSM
sudo systemctl start nas-certificate-deploy.service
sudo systemctl status nas-certificate-deploy.service --no-pager
sudo journalctl -u nas-certificate-deploy.service -n 50 --no-pager
  1. Verify the active certificate

Run on: Ubuntu or another trusted Linux client, then DSM Control Panel

curl -Iv --resolve nas.example.com:443:192.0.2.30 \
  https://nas.example.com/

openssl s_client \
  -connect 192.0.2.30:443 \
  -servername nas.example.com \
  -verify_hostname nas.example.com \
  -verify_return_error </dev/null

After the TLS test, open DSM Control Panel → Security → Certificate → Settings and confirm that DSM and each required package use the new certificate.


Result

https://nas.example.com should now present the Let’s Encrypt certificate. If you enabled automatic renewal, check it again after the first real renewal and after each DSM update.

Did this guide save you time?

Support this site
Scroll to Top