DSM has a built-in Let’s Encrypt integration, and I didn’t use it, because it only speaks HTTP-01. That challenge type means opening port 80 on the NAS to the internet so Let’s Encrypt can fetch a token, which is exactly backwards for a box that holds every photo my family has ever taken. DNS-01 flips the model: the challenge is a TXT record in the public zone, the NAS never has to be reachable from anywhere, and since the jeakyl.com zone already lives in Route53 courtesy of the blog’s Terraform stack, the plumbing was mostly there. This evening I wired it up on synology-slim.jeakyl.com with acme.sh; the certificate part took about twenty minutes, and then a completely silent curl sent me on a detour that ended two hops away, inside the Pi-hole, on a record type that doesn’t even exist for the host.

A scoped IAM user first

acme.sh needs AWS keys to write the TXT record, and it stores them in its config after the first run, which means a key sitting in plain text on the NAS indefinitely. So the key gets the narrowest policy I could write: ChangeResourceRecordSets and ListResourceRecordSets pinned to the one hosted zone, GetChange on change/* for polling propagation, and the two ListHostedZones actions which annoyingly can’t be resource-scoped. If that key ever leaks, the blast radius is “someone can create records in one zone”, which matters, but it’s a long way from account keys.

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": ["route53:GetChange"],
      "Resource": "arn:aws:route53:::change/*" },
    { "Effect": "Allow",
      "Action": ["route53:ChangeResourceRecordSets", "route53:ListResourceRecordSets"],
      "Resource": "arn:aws:route53:::hostedzone/ZXXXXXXXXXXXXX" },
    { "Effect": "Allow",
      "Action": ["route53:ListHostedZones", "route53:ListHostedZonesByName"],
      "Resource": "*" }
  ]
}

The user and policy are click-ops for now. They shouldn’t be; the whole thing is maybe twenty lines of Terraform with the zone ID as the only input, and it’s on the list.

Installing acme.sh, and the first trap

Downloaded the tarball, extracted to /tmp, ran the installer, and:

root@Synology:/tmp/acme.sh-master# ./acme.sh --install --home /usr/local/share/acme.sh --nocron
-ash: ./acme.sh: Permission denied

The execute bit was fine. DSM mounts /tmp with noexec, so nothing extracted there will ever run directly, and no amount of chmod +x changes that; mount | grep /tmp confirms it. The workaround is to invoke the script through the shell instead, since noexec only blocks direct execution:

sh ./acme.sh --install --home /usr/local/share/acme.sh --nocron

The --nocron flag matters too. acme.sh normally installs its own crontab entry, which doesn’t survive on DSM; the renewal schedule goes into DSM’s Task Scheduler instead, later. One more note for anyone copying this: /usr/local/share has a habit of being reset by DSM major upgrades, so a home on a real volume like /volume1/opt/acme.sh is the more durable choice. I’ve accepted the risk on this box and will regret it at some future upgrade.

Issue and deploy

Issuing is two exported variables and one command; acme.sh writes the TXT record via the Route53 API, waits for propagation, and pulls the certificate down.

export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."

/usr/local/share/acme.sh/acme.sh --issue \
  --dns dns_aws \
  -d synology-slim.jeakyl.com \
  --server letsencrypt \
  --home /usr/local/share/acme.sh

The keys persist into acme.sh’s account config after this, so renewals never need the exports again. DNS-01 also happens to be the only challenge type that supports wildcards, so -d '*.jeakyl.com' is available if this NAS ever fronts more than one service.

Getting the certificate into DSM is the bit people script badly. Dropping PEM files into /usr/syno/etc/certificate by hand breaks in interesting ways; the synology_dsm deploy hook goes through DSM’s own API instead, so the certificate lands properly in Control Panel and nginx gets restarted. On DSM 7.2+ there’s a temp-admin mode that avoids storing any DSM credentials at all:

export SYNO_USE_TEMP_ADMIN=1

/usr/local/share/acme.sh/acme.sh --deploy \
  -d synology-slim.jeakyl.com \
  --deploy-hook synology_dsm \
  --home /usr/local/share/acme.sh
[Mon Jul  6 22:47:04 NZST 2026] Logging into localhost:5000...
[Mon Jul  6 22:47:09 NZST 2026] Upload certificate to the Synology DSM.
[Mon Jul  6 22:47:28 NZST 2026] Restart HTTP services succeeded.
[Mon Jul  6 22:47:30 NZST 2026] Success

On older DSM versions you’d need SYNO_USERNAME and SYNO_PASSWORD, plus SYNO_DEVICE_NAME and a one-time SYNO_OTP_CODE if the account has 2FA. Temp-admin sidesteps all of it, which is one of the better arguments for being current on DSM.

Renewal is a daily root task in Control Panel → Task Scheduler running acme.sh --cron --home /usr/local/share/acme.sh. acme.sh checks certificate age internally and only renews inside the 30-day window, so a daily run costs nothing, and the deploy hook settings persist per-domain, meaning renewal and redeployment happen together without me anywhere near it.

flowchart LR
  task[DSM Task Scheduler<br>daily, root] --> acme[acme.sh --cron]
  acme -->|"< 30 days left?"| r53[(Route53<br>TXT challenge)]
  r53 --> le[Let's Encrypt<br>new certificate]
  le --> hook[synology_dsm hook<br>DSM API, temp admin]
  hook --> nginx[nginx restart<br>cert live on 443/5001]
  classDef start fill:#1b2740,color:#fff,stroke:#1b2740;

The 403, which was correct behaviour

First test: https://synology-slim.jeakyl.com returned a 403. That’s not a certificate problem; DSM’s nginx owns ports 80 and 443 with a default server block that returns Forbidden for anything that doesn’t match a configured rule, and DSM itself listens on 5000/5001. Out of the box, nothing useful answers on 443. The fix lives in Control Panel → Login Portal → DSM tab: enter the hostname in the Customised Domain field, tick the automatic HTTP-to-HTTPS redirect, apply. DSM adds a server block keyed to the Host header, 443 lands on the login page, and 80 returns a 301 up to HTTPS. Ports 5000 and 5001 stay open regardless; if you want them gone, block them at the DSM firewall rather than trying to unbind them, because DSM re-adds the listeners on upgrade.

Worth knowing before relying on that redirect: it’s a plain 301 with no HSTS header, so a client’s first request still travels port 80 in clear. On a LAN, fine. Exposed externally, you’d want HSTS via a reverse proxy entry’s custom headers, because the Login Portal path doesn’t offer it.

The silent curl

Before touching the Login Portal I wanted to confirm the certificate had actually landed, so I hit the real DSM port and grepped for the interesting lines:

curl -v https://synology-slim.jeakyl.com:5001 2>&1 | grep -E 'subject|issuer|expire'

Empty output. Nothing. The grep had swallowed a connection failure; if curl never completes a handshake, none of those lines exist to match. This is the moment the evening forked, because an empty result tells you nothing about which layer failed, and there were four candidates: DNS, routing, the listener, the certificate.

The check that cuts through all of it tests the local listener directly with the right SNI, sidestepping name resolution and routing entirely:

openssl s_client -connect localhost:5001 \
  -servername synology-slim.jeakyl.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
subject=CN = synology-slim.jeakyl.com
issuer=C = US, O = Let's Encrypt, CN = YE1
notBefore=Jul  6 09:48:14 2026 GMT
notAfter=Oct  4 09:48:13 2026 GMT

Certificate correct, deployed, serving. netstat -tlnp | grep 5001 showed nginx bound on all interfaces. So the failure sat entirely in the network path, and nslookup from the NAS produced the strangest output of the night:

Name:	synology-slim.jeakyl.com
Address: 192.168.1.3
;; connection timed out; no servers could be reached

Read that carefully. The A query succeeded; the name resolves internally to the NAS’s LAN address, so split-horizon DNS was already doing its job and there was no hairpin-NAT problem to chase. The timeout is the second query, the AAAA lookup for IPv6, going into a void. A well-behaved resolver answers “no AAAA record” with an empty NOERROR in milliseconds; mine was going silent, and every dual-stack client asking for both address families sat burning a timeout on every lookup of this name. That’s why curl produced nothing: it stalled on the AAAA before ever attempting the connection. Forcing IPv4 proved it instantly:

curl -4 -v https://synology-slim.jeakyl.com:5001 2>&1 | grep -E 'subject|issuer|HTTP'
> GET / HTTP/2
< HTTP/2 200

Narrowing it to the Pi-hole

The LAN resolver at 192.168.1.4 is a Pi-hole, and two dig queries from my Mac split the fault cleanly:

dig AAAA synology-slim.jeakyl.com @192.168.1.4 +time=2 +tries=1   # times out
dig AAAA google.com @192.168.1.4 +time=2 +tries=1                  # NOERROR, 152 ms

Google’s AAAA came back through the normal upstreams in 152 ms, so the Pi-hole’s upstream path handles IPv6 record types perfectly well; only jeakyl.com AAAA queries vanish. And a timeout rather than an NXDOMAIN is the telling detail, because if the query were reaching Route53 the public zone would answer instantly, and if dnsmasq considered the name purely local it would synthesise the empty response itself. Silence means the query was forwarded somewhere that never replies.

The configuration that produces exactly that split is a domain-specific forward: a server=/jeakyl.com/<ip> line, or Pi-hole’s Conditional Forwarding pointed at the router for the local domain. The A record gets answered from Local DNS before forwarding comes into play, but the AAAA has no local entry, so dnsmasq dutifully ships it off to a target that answers A queries for DHCP names and drops everything else on the floor. Routers do this constantly.

I haven’t applied the fix yet; that’s next weekend’s ten minutes. The shape of it is to stop forwarding jeakyl.com into the void, either by restricting Conditional Forwarding to the reverse zone (1.168.192.in-addr.arpa) so hostname lookups stay local without dragging the forward domain along, or by removing the domain forward and defining internal hosts under Local DNS → DNS Records, where dnsmasq owns the names outright and returns clean empty NOERROR responses for the AAAA queries. pihole -t while re-running the dig will name the exact forwarding target first.

Where it landed

Certificate issued via Route53 DNS-01 with a key that can touch one zone and nothing else, deployed through DSM’s own API without stored credentials, renewing daily via Task Scheduler, serving on 443 under the bare hostname with HTTP redirecting up. Total elapsed time was well over an hour, of which the certificate machinery took twenty minutes and the rest went to a resolver two hops away mishandling a record type the host doesn’t have. The lesson I’m keeping is about the empty grep: a filtered curl -v that prints nothing has told you nothing, and the five seconds it takes to re-run unfiltered, or to go straight to openssl s_client against localhost, is cheaper than reasoning about which of four layers might be lying. The certificate was verifiably correct at every layer the whole time; the network just wouldn’t let anyone come and look at it.