Renew Let's Encrypt certificates over the DNS-01 challenge and push them to the appliances that cannot get their own — Synology DSM, FortiGate — on a schedule, from a web UI, in a container.
If your NAS or firewall sits behind NAT, or you want a wildcard certificate, HTTP-01 is not available to you and the appliance's built-in ACME client is of no help. acme-deployer runs lego against your DNS provider instead, then logs into each target and installs the result.
Certificates and their expiry, and the run console: every phase of a renewal and deploy, timed, streamed as it happens and replayable afterwards.
One certificate — its domains, the DNS provider credential that answers the DNS-01 challenge, and the appliances the result gets installed on.
- ACME renewal over DNS-01 — ACME-DNS, Azure DNS, Cloudflare, IONOS and ~20 more with a dedicated form, plus any of lego's ~120 providers through "Other / Manual".
- Synology DSM deploy — uploads the renewed certificate, replaces the existing entry by its stable cert ID rather than by name, and optionally re-assigns DSM services. Handles 2FA through a trusted-device token.
- FortiGate deploy — imports the certificate over the FortiOS REST API and can bind it to the admin GUI and the SSL-VPN portal. VDOM-aware.
- Background scheduler — APScheduler runs a daily check. A mutex keeps a manual run and a scheduled one from ever overlapping, and a reaper recovers runs orphaned by a container kill.
- Verbose run console — a terminal-style dock streams each phase of a renewal and deploy, and can replay past runs from the logs page.
- Encrypted backup to SharePoint (optional) — configuration snapshots
encrypted with a dedicated Fernet key, separate from the runtime one, so
losing
ACME_SECRET_KEYdoes not cost you the backups. Restore from the UI or with an offline CLI that needs nothing butcryptography. - Hardened by default — CSRF on every form, rate-limited login, TOTP 2FA, secrets encrypted at rest, TOFU TLS pinning to the appliances, non-root container with all capabilities dropped.
- Web UI — FastAPI + Jinja2 + HTMX, light/dark/auto theme, English and Italian. Microsoft 365 SSO is optional.
| Component | Requirement |
|---|---|
| Host | Docker + Compose. Runs on x86-64 and ARM, including a Synology NAS itself |
| DNS | A provider lego supports, with API credentials |
| Synology target | DSM 6.2 or later (the deploy uses SYNO.API.Auth version 6) |
| FortiGate target | FortiOS with the REST API enabled and an API-token admin |
| Microsoft 365 | Only for the optional SharePoint backup, Graph email and M365 SSO |
Nothing outside the DNS provider is mandatory: a working install can renew certificates and deploy them to a NAS with no Microsoft tenant in sight.
git clone https://github.com/driin0/acme-deployer.git
cd acme-deployer
cp .env.sample .env
# Generate the two required keys and paste them into .env:
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # → ACME_SECRET_KEY
python3 -c "import secrets; print(secrets.token_urlsafe(48))" # → SESSION_SECRET_KEY
# For production also set TRUSTED_HOSTS=<your.host> and SESSION_COOKIE_SECURE=true.
docker compose up -d --buildThe first start prints a one-time setup token in the container logs:
docker compose logs web | grep "Setup token"Open https://<host>/setup?token=<token> to create the first admin account
(12-character minimum). The token expires after an hour.
The container listens on port 48210. Put it behind whatever reverse proxy
you already run — it expects to be reached over HTTPS in production, and
SESSION_COOKIE_SECURE=true will refuse to issue session cookies otherwise.
.env.sample documents every variable inline. The ones that matter:
| Variable | Required | Notes |
|---|---|---|
ACME_SECRET_KEY |
yes | Fernet key. Encrypts the secrets stored in config.yaml |
SESSION_SECRET_KEY |
yes | Signs session cookies. Known defaults are refused at startup |
TRUSTED_HOSTS |
in production | Comma-separated allowlist of Host: headers. * for local dev |
SESSION_COOKIE_SECURE |
no | true in production, false only for local HTTP |
MS365_TENANT_ID |
for M365 features | Pins the tenant for OAuth, Graph and Azure ARM |
MS365_BOOTSTRAP_CLIENT_ID |
for M365 features | Your own Entra ID app registration. No default — see below |
APP_BASE_URL |
for OAuth | Public HTTPS URL, used as the redirect URI |
TZ |
no | Container timezone, default Europe/Rome. Used for log and backup timestamps |
LEGO_DNS_RESOLVERS |
no | Resolvers lego uses for DNS-01 propagation checks |
LOG_LEVEL |
no | INFO (default), DEBUG, WARNING |
Any lego DNS-provider variable (CF_DNS_API_TOKEN, ACME_DNS_STORAGE_PATH,
…) can also live in .env: the renewal subprocess inherits the container
environment, and a field left blank in the UI is never saved, so it does not
override it. A value typed into the UI wins over the environment. This is handy
for settings that are not really credentials — pointing acme-dns at a persistent
account file under /data, for instance.
Operational state lives in SQLite under /data; configuration lives in
config.yaml beside it, with every secret encrypted. Bind-mount ./data and
both survive a container recreate.
The Azure DNS, OAuth, email and SharePoint wizards all sign in with the device-code flow, and that needs an Entra ID application to sign in through. You have to register your own. There is deliberately no default: a shipped app registration would mean every installation in the world consenting to one stranger's app, appearing in their tenant under that stranger's name, and breaking the day it was deleted.
It takes a couple of minutes:
- In the Entra admin centre, App registrations → New registration. Any name. Under Supported account types pick single-tenant unless you have a reason not to.
- Authentication → Advanced settings → Allow public client flows: Yes. The device-code flow will not start without it.
- Copy the Application (client) ID into
MS365_BOOTSTRAP_CLIENT_ID, and the Directory (tenant) ID intoMS365_TENANT_ID.
No client secret and no certificate are needed on this app: it is a public
client, and each wizard requests its own permissions interactively at the point
of use. Without these two variables the wizards report that
MS365_BOOTSTRAP_CLIENT_ID is unset; the rest of acme-deployer is unaffected.
An acme-dns account file (acmedns.json) can be supplied two ways, and the
DNS-provider form offers both side by side:
- Upload the file your acme-dns portal handed you. It is validated on the
spot: it must be a
domain → accountmap, every entry needsusername,password,subdomain,fulldomainandserver_url, and allserver_urlvalues must agree, because lego takes a singleACME_DNS_API_BASEper run. On successACME_DNS_API_BASEis filled in fromserver_urland the covered domains are listed back to you. The file is stored encrypted insideconfig.yaml, so it is already covered by the backup, and touches the disk only as a0600temporary file that is removed in afinallysurviving both exceptions and the 300-second subprocess timeout. - Point at a path you maintain yourself under
/data.
Three lego facts worth not rediscovering the hard way:
ACME_DNS_API_BASEis mandatory, even though the same address is already inside the account file asserver_url. Without it lego stops with "some credentials information are missing" — having already written an ACME account key, which then poisons the next attempt with "accountDoesNotExist" until.lego/accounts/is emptied.- The provider code is
acmedns, with no hyphen.acme-dnsanswers "not yet supported". - lego 5 moved
--dnsfrom a global flag ontorun.app/engine.pyalready builds the correct form.
The SharePoint backup is optional and needs a Microsoft 365 tenant. Nothing about it is specific to any one organisation: the wizard discovers your sites and document libraries through Microsoft Graph.
- Configure it from
/backup. The wizard generates a dedicated Fernet key, shows it once as eight hex groups, and offers a printable emergency-kit.txt. Put it in a password manager — it is not stored anywhere else. - Snapshots are pushed after every successful renewal, and on demand.
- Restore from
/backup/restore(list, upload, paste key, review the diff, apply) or offline withtools/restore-backup.py, which needs onlycryptography.
The restore replaces the in-app configuration but does not touch the certificate
files under data/lego/. If certificate names changed, force a renewal
afterwards.
python3.12 -m venv venv && source venv/bin/activate
pip install -e '.[dev]'
pytestThe suite runs against an isolated tmp_data_dir fixture; SharePoint, Azure,
Synology and FortiGate are all mocked, so nothing reaches a real appliance.
app/
├── main.py # FastAPI app, middleware stack, lifespan
├── config.py # Pydantic models, encrypted YAML loader, mtime cache
├── auth.py # password + TOTP + OAuth + rate limiting
├── csrf.py # double-submit middleware
├── engine.py # renewal mutex, lego subprocess, deploy orchestration
├── scheduler.py # APScheduler job
├── state.py # SQLAlchemy: User, RenewalRun, CertificateEvent, …
├── steps.py # per-phase recorder behind the run console
├── i18n.py # JSON locale loader
├── routers/ # ui, api, wizard_*, dns_providers, synology_nas, …
├── synology/tls.py # TOFU certificate pinning
├── fortinet/tls.py # the same, for FortiOS
├── ms365/ # device-code flow, Graph, ARM, tenant guard
├── targets/ # synology.py, fortinet.py — the deploy targets
├── backup/ # encrypted snapshot upload + key handling
└── templates/ # Jinja2, HTMX-driven
tools/
└── restore-backup.py # standalone offline restore
Issues and pull requests are welcome. Please keep the test suite green
(pytest) and follow Conventional Commits
in commit messages.
Copyright (C) 2026 Riccardo Riina (driin0)
Licensed under the GNU Affero General Public License v3.0. See LICENSE.
The AGPL is deliberate: this is a tool people run as a network service, and section 13 means anyone offering it to others over a network has to offer them its source too.

