> ## Documentation Index
> Fetch the complete documentation index at: https://felimet-hub.jmcores.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Synology NAS Wildcard Certificate Auto-Renewal

> Issue and auto-renew Let's Encrypt wildcard certificates on Synology NAS with acme.sh, Cloudflare DNS-01 validation, and the synology_dsm DSM deploy hook.

export const CertDecision = ({lang = "zh"}) => {
  const t = lang === "en" ? {
    lead: "How does the traffic reach the NAS? Pick a path to see the recommended certificate.",
    rec: "Recommended certificate",
    why: "Why",
    sep: ": "
  } : {
    lead: "流量怎麼到 NAS？選一條路徑看建議憑證。",
    rec: "建議憑證",
    why: "原因",
    sep: "："
  };
  const PATHS = lang === "en" ? [{
    key: "proxy",
    label: "Via Cloudflare proxy",
    sub: "orange cloud",
    cert: "Cloudflare Origin Certificate",
    why: "Full (Strict) mode; the 15-year cert needs no renewal upkeep.",
    note: "Issued by Cloudflare and trusted only on the Cloudflare-to-NAS hop, so no public CA is needed."
  }, {
    key: "direct",
    label: "Direct connection",
    sub: "VPN / Mail / WebDAV, grey cloud",
    cert: "Let's Encrypt Wildcard",
    why: "A public CA that browsers and clients already trust.",
    note: "acme.sh renews it automatically over DNS-01 (only within 60 days of expiry)."
  }] : [{
    key: "proxy",
    label: "走 Cloudflare proxy",
    sub: "橘雲",
    cert: "Cloudflare Origin Certificate",
    why: "Full (Strict) 模式，15 年效期免維護。",
    note: "由 Cloudflare 簽發，僅用於 Cloudflare 到 NAS 這一段，不需公開 CA。"
  }, {
    key: "direct",
    label: "直連",
    sub: "VPN / Mail / WebDAV，灰雲",
    cert: "Let's Encrypt Wildcard",
    why: "公開 CA，瀏覽器與用戶端皆信任。",
    note: "acme.sh 以 DNS-01 自動續期（到期前 60 天內才實際續期）。"
  }];
  const ICONS = {
    proxy: <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
    direct: <><circle cx="6" cy="19" r="3" /><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" /><circle cx="18" cy="5" r="3" /></>
  };
  const [sel, setSel] = useState(0);
  const cur = PATHS[sel];
  const css = `
  .cd-root{--cd-bg:#FAF8F3;--cd-surface:rgba(0,0,0,0.025);--cd-border:rgba(0,0,0,0.09);--cd-text:#2b2722;--cd-dim:#6f6a62;--cd-faint:#8a8378;--cd-accent:#bf7551;border:1px solid var(--cd-border);border-radius:14px;background:var(--cd-bg);color:var(--cd-text);overflow:hidden;}
  .dark .cd-root{--cd-bg:#1b1a18;--cd-surface:rgba(255,255,255,0.03);--cd-border:rgba(255,255,255,0.08);--cd-text:#e7e3da;--cd-dim:#a8a299;--cd-faint:#8a8378;--cd-accent:#cf8a68;}
  .cd-head{padding:13px 18px 11px;border-bottom:1px solid var(--cd-border);font-size:12.5px;color:var(--cd-dim);display:flex;align-items:center;gap:8px;}
  .cd-head-ic{color:var(--cd-accent);flex-shrink:0;}
  .cd-body{padding:14px 16px 16px;}
  .cd-paths{display:flex;gap:9px;flex-wrap:wrap;}
  .cd-path{flex:1 1 160px;min-width:150px;display:flex;align-items:center;gap:10px;text-align:left;background:transparent;border:1px solid var(--cd-border);border-radius:11px;padding:11px 13px;cursor:pointer;color:inherit;font:inherit;transition:border-color .15s,background .15s,box-shadow .15s;}
  .cd-path:hover{background:var(--cd-surface);}
  .cd-path-on{border-color:rgba(191,117,81,.5);background:rgba(191,117,81,.07);box-shadow:0 0 0 3px rgba(191,117,81,.1);}
  .cd-path-ic{color:var(--cd-faint);flex-shrink:0;}
  .cd-path-on .cd-path-ic{color:var(--cd-accent);}
  .cd-path-l{font-size:14px;font-weight:600;line-height:1.25;}
  .cd-path-on .cd-path-l{color:var(--cd-accent);}
  .cd-path-s{font-size:11.5px;color:var(--cd-faint);margin-top:1px;}
  .cd-rec{margin-top:13px;border:1px solid rgba(191,117,81,.3);border-radius:10px;background:rgba(191,117,81,.06);padding:13px 15px;}
  .cd-rec-k{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--cd-accent);margin-bottom:5px;}
  .cd-rec-cert{font-size:16px;font-weight:700;color:var(--cd-text);margin-bottom:9px;}
  .cd-rec-why{font-size:13px;line-height:1.6;color:var(--cd-dim);}
  .cd-rec-why b{color:var(--cd-text);font-weight:600;}
  .cd-rec-note{font-size:12.5px;line-height:1.6;color:var(--cd-faint);margin-top:8px;padding-top:8px;border-top:1px solid var(--cd-border);}
  @media (max-width:520px){.cd-path{flex:1 1 100%;}}
  `;
  return <div className="cd-root">
      <style>{css}</style>
      <div className="cd-head">
        <svg className="cd-head-ic" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></svg>
        {t.lead}
      </div>
      <div className="cd-body">
        <div className="cd-paths">
          {PATHS.map((p, i) => <button key={p.key} type="button" className={"cd-path" + (i === sel ? " cd-path-on" : "")} onClick={() => setSel(i)}>
              <svg className="cd-path-ic" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">{ICONS[p.key]}</svg>
              <span>
                <span className="cd-path-l">{p.label}</span>
                <span className="cd-path-s">{p.sub}</span>
              </span>
            </button>)}
        </div>
        <div className="cd-rec">
          <div className="cd-rec-k">{t.rec}</div>
          <div className="cd-rec-cert">{cur.cert}</div>
          <div className="cd-rec-why"><b>{t.why}{t.sep}</b>{cur.why}</div>
          <div className="cd-rec-note">{cur.note}</div>
        </div>
      </div>
    </div>;
};

export const ArchFlow = ({nodes = [], lang = "zh"}) => {
  const t = lang === "en" ? {
    flow: "Data flow",
    hint: "Click any stage to see what it does",
    stage: "Stage"
  } : {
    flow: "資料流",
    hint: "點任一階段看它做什麼",
    stage: "階段"
  };
  const safe = Array.isArray(nodes) ? nodes : [];
  const [active, setActive] = useState(0);
  if (safe.length === 0) return null;
  const idx = Math.min(active, safe.length - 1);
  const current = safe[idx];
  const css = `
  .af-root{--af-bg:#FAF8F3;--af-surface:rgba(191,117,81,0.06);--af-surface2:rgba(0,0,0,0.025);--af-border:rgba(0,0,0,0.09);--af-text:#2b2722;--af-dim:#6f6a62;--af-faint:#8a8378;--af-rail:rgba(0,0,0,0.12);--af-accent:#bf7551;border:1px solid var(--af-border);border-radius:14px;background:var(--af-bg);color:var(--af-text);overflow:hidden;}
  .dark .af-root{--af-bg:#1b1a18;--af-surface:rgba(207,138,104,0.09);--af-surface2:rgba(255,255,255,0.03);--af-border:rgba(255,255,255,0.08);--af-text:#e7e3da;--af-dim:#a8a299;--af-faint:#8a8378;--af-rail:rgba(255,255,255,0.13);--af-accent:#cf8a68;}
  .af-head{padding:13px 18px 11px;display:flex;align-items:center;gap:9px;flex-wrap:wrap;border-bottom:1px solid var(--af-border);}
  .af-head-ic{color:var(--af-accent);flex-shrink:0;}
  .af-head-t{font-size:12px;font-weight:700;letter-spacing:.6px;color:var(--af-dim);text-transform:uppercase;}
  .af-head-h{font-size:12px;color:var(--af-faint);}
  .af-grid{display:flex;gap:0;align-items:stretch;}
  .af-list{flex:1 1 0;min-width:0;padding:12px 12px 16px;}
  .af-node{display:flex;align-items:stretch;gap:10px;width:100%;text-align:left;background:transparent;border:none;cursor:pointer;padding:0;color:inherit;font:inherit;-webkit-tap-highlight-color:transparent;}
  .af-railcol{width:22px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;}
  .af-dot{width:9px;height:9px;border-radius:50%;margin-top:14px;background:var(--af-faint);opacity:.5;flex-shrink:0;transition:background .15s,box-shadow .15s,opacity .15s;}
  .af-line{width:2px;flex:1 1 0;background:var(--af-rail);min-height:10px;}
  .af-body{flex:1 1 0;min-width:0;padding:9px 12px;margin:2px 0;border-radius:9px;border:1px solid transparent;transition:background .15s,border-color .15s;}
  .af-node:hover .af-body{background:var(--af-surface2);}
  .af-node-on .af-body{background:var(--af-surface);border-color:rgba(191,117,81,.30);}
  .af-node-on .af-dot{background:var(--af-accent);opacity:1;box-shadow:0 0 0 4px rgba(191,117,81,.15);}
  .af-label{font-size:14.5px;font-weight:550;line-height:1.45;}
  .af-node-on .af-label{color:var(--af-accent);}
  .af-detail{width:300px;flex-shrink:0;border-left:1px solid var(--af-border);padding:16px 18px;background:var(--af-surface2);}
  .af-detail-k{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--af-accent);margin-bottom:7px;}
  .af-detail-l{font-size:16px;font-weight:600;margin-bottom:9px;line-height:1.35;}
  .af-detail-d{font-size:13.5px;line-height:1.65;color:var(--af-dim);}
  @media (max-width:640px){.af-grid{flex-direction:column;}.af-detail{width:auto;border-left:none;border-top:1px solid var(--af-border);}}
  `;
  return <div className="af-root">
      <style>{css}</style>
      <div className="af-head">
        <svg className="af-head-ic" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="8" height="8" x="3" y="3" rx="2" /><path d="M7 11v4a2 2 0 0 0 2 2h4" /><rect width="8" height="8" x="13" y="13" rx="2" /></svg>
        <span className="af-head-t">{t.flow}</span>
        <span className="af-head-h">{t.hint}</span>
      </div>
      <div className="af-grid">
        <div className="af-list">
          {safe.map((n, i) => <button key={i} type="button" className={"af-node" + (i === idx ? " af-node-on" : "")} onClick={() => setActive(i)} onMouseEnter={() => setActive(i)}>
              <div className="af-railcol">
                <div className="af-dot" />
                {i < safe.length - 1 && <div className="af-line" />}
              </div>
              <div className="af-body">
                <div className="af-label">{n.label}</div>
              </div>
            </button>)}
        </div>
        <div className="af-detail">
          <div className="af-detail-k">{t.stage} {idx + 1} / {safe.length}</div>
          <div className="af-detail-l">{current.label}</div>
          <div className="af-detail-d">{current.detail}</div>
        </div>
      </div>
    </div>;
};

`Synology` · `acme.sh` · `Let's Encrypt` · `Cloudflare` · `TLS` · `Shell`

## Overview

DSM 7's built-in Let's Encrypt support only covers HTTP-01 validation, which requires port 80 open to the internet and cannot issue wildcard certificates. This project uses acme.sh with Cloudflare DNS-01 validation to issue a `*.example.com` wildcard certificate without opening any inbound ports, then deploys it to DSM automatically via the `synology_dsm` deploy hook. It also covers a dual-certificate strategy: Cloudflare Origin Certificate for orange-cloud (proxied) traffic, and the Let's Encrypt wildcard for direct-access services such as VPN, Mail, and WebDAV.

## Core features

* DNS-01 validation via Cloudflare API: writes a TXT record to fulfil the ACME challenge with no ports opened
* Single wildcard issuance (`example.com` + `*.example.com`) covers all subdomains
* `synology_dsm` deploy hook pushes and reloads the certificate through the DSM Web API after each issuance or renewal
* DSM Task Scheduler triggers acme.sh renew daily; acme.sh only acts when the certificate is within 60 days of expiry
* Dual certificates: DSM lets you assign Origin Certificate or Let's Encrypt per service independently

## Deployment architecture

<ArchFlow
  lang="en"
  nodes={[
{ label: "Cloudflare DNS API", detail: "acme.sh calls the Cloudflare API to write a TXT record, completing the DNS-01 ACME challenge with no inbound ports opened." },
{ label: "Let's Encrypt CA", detail: "Validates the TXT record and issues the wildcard certificate covering example.com and *.example.com." },
{ label: "acme.sh", detail: "Manages issuance and renewal. It only acts when the certificate is within 60 days of expiry, so running it daily from DSM Task Scheduler is safe." },
{ label: "synology_dsm deploy hook", detail: "Pushes the new certificate to DSM and triggers a reload via the DSM Web API, with no manual steps required." },
{ label: "DSM certificate store", detail: "Holds both certificates. Each service is assigned independently: Origin Certificate for orange-cloud traffic, Let's Encrypt wildcard for VPN, Mail, WebDAV, and other direct-access services." }
]}
/>

## Certificate strategy

```
Browser / client
  |- HTTPS -> Cloudflare Edge (Universal SSL, automatic)
  |              -> orange-cloud proxy -> NAS: Cloudflare Origin Certificate (15-year validity)
  -> Direct (grey-cloud / VPN / Mail / WebDAV)
                 -> NAS: Let's Encrypt Wildcard (acme.sh auto-renewed)
```

<CertDecision lang="en" />

## Quick start

<Steps>
  <Step title="Create a Cloudflare API Token">
    Cloudflare Dashboard → My Profile → API Tokens → Create Token → Custom Token.

    Set permissions: Zone → DNS → **Edit**, zone resource scoped to the target zone. Copy the token (shown only once) and the Zone ID (right-hand panel on the Dashboard overview page).
  </Step>

  <Step title="Install acme.sh">
    ```bash theme={null}
    sudo -i
    rm -rf /usr/local/share/acme.sh /root/.acme.sh
    cd /tmp && wget https://github.com/acmesh-official/acme.sh/archive/master.tar.gz
    tar xvf master.tar.gz && cd acme.sh-master/
    ./acme.sh --install --nocron \
        --home /usr/local/share/acme.sh \
        --accountemail "your-email@example.com"
    chown -R your_username /usr/local/share/acme.sh/
    ```

    `--nocron` is required because DSM upgrades wipe non-native crontab entries. `--home` is required because Synology's `/root` directory structure is non-standard and causes errors without it.
  </Step>

  <Step title="Register a Let's Encrypt account">
    ```bash theme={null}
    /usr/local/share/acme.sh/acme.sh --register-account \
        -m "your-email@example.com" \
        --home /usr/local/share/acme.sh \
        --server letsencrypt
    ```

    `--server letsencrypt` is required. Recent acme.sh versions default to ZeroSSL.
  </Step>

  <Step title="Issue the wildcard certificate (staging first, then production)">
    ```bash theme={null}
    export CF_Token="your_cloudflare_api_token"
    export CF_Zone_ID="your_zone_id"

    # Validate config against the staging environment first
    /usr/local/share/acme.sh/acme.sh --issue --dns dns_cf \
        -d "example.com" -d "*.example.com" \
        --home /usr/local/share/acme.sh \
        --server letsencrypt --test

    # Issue for real once staging passes
    /usr/local/share/acme.sh/acme.sh --issue --dns dns_cf \
        -d "example.com" -d "*.example.com" \
        --home /usr/local/share/acme.sh \
        --server letsencrypt --force
    ```
  </Step>

  <Step title="Deploy the certificate to DSM">
    ```bash theme={null}
    export SYNO_Hostname="localhost"
    export SYNO_Port="5000"
    export SYNO_Username="your_username"
    export SYNO_Password='your_password'
    export SYNO_Certificate="Let's Encrypt Wildcard"
    export SYNO_Create=1

    /usr/local/share/acme.sh/acme.sh --deploy \
        -d "example.com" -d "*.example.com" \
        --deploy-hook synology_dsm \
        --home /usr/local/share/acme.sh
    ```

    Confirm in DSM Control Panel → Security → Certificate that "Let's Encrypt Wildcard" appears.
  </Step>

  <Step title="Set up automatic renewal via DSM Task Scheduler">
    Control Panel → Task Scheduler → Create → Scheduled Task → User-defined script, scheduled daily at 03:00:

    ```bash theme={null}
    /usr/local/share/acme.sh/acme.sh --renew \
        -d "example.com" -d "*.example.com" \
        --deploy-hook synology_dsm \
        --home /usr/local/share/acme.sh
    ```

    Or use `scripts/renew-cert.sh` from the repo. acme.sh skips renewal internally unless the certificate is within 60 days of expiry, so running daily is safe.
  </Step>
</Steps>

## Notes

<Warning>
  Let's Encrypt production enforces a rate limit of 5 certificates per domain per week. Always run `--test` against the staging environment before the production issuance; staging has no rate limits. Use a Custom API Token with Zone DNS Edit scope only, never the Global API Key. If `SYNO_CERTIFICATE` contains a single quote (as in `Let's Encrypt Wildcard`), wrap the value in double quotes in `account.conf` to avoid shell quoting errors.
</Warning>

## In practice

The lab NAS runs VPN, Mail, WebDAV, and Synology Drive simultaneously, all of which need a publicly trusted certificate. A single wildcard covers all subdomains with one renewal cycle. Orange-cloud services get the Origin Certificate assigned separately in DSM, leaving the Let's Encrypt wildcard for direct-access services.

## Links

* GitHub: [felimet/synology-letsencrypt-cloudflare](https://github.com/felimet/synology-letsencrypt-cloudflare)
* acme.sh: [acmesh-official/acme.sh](https://github.com/acmesh-official/acme.sh)
* acme.sh Cloudflare DNS API guide: [acme.sh/wiki/dnsapi#dns\_cf](https://github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_cf)
* acme.sh Synology DSM deploy hook: [acme.sh/wiki/deployhooks#synology\_dsm](https://github.com/acmesh-official/acme.sh/wiki/deployhooks#synology_dsm)
