Skip to content

Repository files navigation

acme-deployer

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.

Tests License: AGPL v3


Screenshots

The dashboard, with the run console expanded

Certificates and their expiry, and the run console: every phase of a renewal and deploy, timed, streamed as it happens and replayable afterwards.

Configuring a certificate and its deploy targets

One certificate — its domains, the DNS provider credential that answers the DNS-01 challenge, and the appliances the result gets installed on.

Features

  • 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_KEY does not cost you the backups. Restore from the UI or with an offline CLI that needs nothing but cryptography.
  • 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.

Requirements

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.

Quick start

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 --build

The 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.

Configuration

.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.

Microsoft 365

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:

  1. 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.
  2. Authentication → Advanced settings → Allow public client flows: Yes. The device-code flow will not start without it.
  3. Copy the Application (client) ID into MS365_BOOTSTRAP_CLIENT_ID, and the Directory (tenant) ID into MS365_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.

acme-dns

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 → account map, every entry needs username, password, subdomain, fulldomain and server_url, and all server_url values must agree, because lego takes a single ACME_DNS_API_BASE per run. On success ACME_DNS_API_BASE is filled in from server_url and the covered domains are listed back to you. The file is stored encrypted inside config.yaml, so it is already covered by the backup, and touches the disk only as a 0600 temporary file that is removed in a finally surviving 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_BASE is mandatory, even though the same address is already inside the account file as server_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-dns answers "not yet supported".
  • lego 5 moved --dns from a global flag onto run. app/engine.py already builds the correct form.

Backup and disaster recovery

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.

  1. 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.
  2. Snapshots are pushed after every successful renewal, and on demand.
  3. Restore from /backup/restore (list, upload, paste key, review the diff, apply) or offline with tools/restore-backup.py, which needs only cryptography.

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.

Development

python3.12 -m venv venv && source venv/bin/activate
pip install -e '.[dev]'
pytest

The suite runs against an isolated tmp_data_dir fixture; SharePoint, Azure, Synology and FortiGate are all mocked, so nothing reaches a real appliance.

Project layout

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

Contributing

Issues and pull requests are welcome. Please keep the test suite green (pytest) and follow Conventional Commits in commit messages.

License

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.

About

Renew Let's Encrypt certificates over DNS-01 and deploy them to Synology DSM and FortiGate

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages