www-data on a plain Apache + PHP host) once the [jobs directory](#jobs-directory) points at a writable path. There are no Virtualmin/Webmin API calls anywhere in the code.
|
|
||
|---|---|---|
| public_html | ||
| .gitattributes | ||
| .gitignore | ||
| install.sh | ||
| LICENSE | ||
| README.md | ||
Let's Encrypt UI
A self-contained PHP web app that requests Let's Encrypt certificates via the DNS-01 challenge and lets you download them as a PEM bundle (.zip) and a password-protected PFX (.pfx).
It runs without root at runtime and writes nothing into the web root. It was
originally built for a Virtualmin virtual server's user account, but it is not
tied to Virtualmin — it runs under any PHP user (including www-data on a
plain Apache + PHP host) once the jobs directory points at a
writable path. There are no Virtualmin/Webmin API calls anywhere in the code.
Features
- Modern, light-themed single-page form with progressive disclosure: the form stays short until you pick a DNS provider, then reveals the rest.
- Single-domain, multi-domain (SAN), and wildcard certificates.
- Four DNS modes, picked from a dropdown (the form swaps to match):
- Cloudflare, Amazon Route 53, DigitalOcean — API token / key, used per-request, written mode 600, deleted as soon as issuance finishes.
- Manual DNS — no API key; the app shows you the TXT records to add at your registrar, verifies them, then completes issuance.
- Selectable key type for freshly generated keys: ECDSA P-256 (default), ECDSA P-384, or RSA 2048 / 3072 / 4096.
- Optional "use my existing private key" — upload or paste a PEM key (RSA / EC / Ed25519, encrypted or not); certbot signs a CSR built from it.
- Optional CNAME alias mode for domains hosted at registrars without an API (Hover, Network Solutions, etc) — supported on Cloudflare and DigitalOcean.
- CAA pre-flight check — an advisory "Check CAA records" button confirms Let's Encrypt is authorized for each domain before you submit.
- Asynchronous issuance — long-running certbot work happens in a detached background worker with a self-refreshing progress page, so it never trips the web server's request timeout.
- Calendar renewal reminder — the result page offers a downloadable
.ics(Outlook / Google / Apple) dated 7, 14, or 30 days before expiry. - Two downloads:
- PEM zip:
privkey.pem,cert.pem,chain.pem,fullchain.pem,combined.pem, plus aREADME.txt. - PFX: PKCS#12 archive encrypted with a password you enter.
- PEM zip:
- "Delete files and start over" securely removes the per-job sandbox.
- Pre-flight checks: detects missing certbot, missing plugins, disabled
exec(), missing ZipArchive, missing cURL, missingopenssl, missingphpCLI, and a non-writable jobs directory. - Each job runs in its own sandbox under the jobs directory
using certbot's
--config-dir,--work-dir, and--logs-dir, so certbot never needs root and concurrent jobs stay isolated.
Requirements
System-wide (root, one-time):
- Linux (Debian / Ubuntu tested)
- Apache 2.4 with
mod_rewriteandmod_headers - PHP 7.4+ (8.x preferred) with
php-zip,php-curl,php-cli, theposixextension, andexec()/proc_open()enabled certbot(3.2+ recommended; older works for everything except features that were since removed)- For the API-key DNS modes:
python3-certbot-dns-cloudflare,python3-certbot-dns-route53,python3-certbot-dns-digitalocean(only the plugin(s) you'll use) opensslsetsid(util-linux) ornohupfor the background worker
Manual DNS mode needs only PHP + openssl — certbot and the plugins are not
required for it.
Jobs directory
Per-job folders (private keys, CSRs, and the generated PEM/PFX bundles) are written to a jobs directory that must be writable by the PHP user and must live OUTSIDE the web root (it briefly holds unencrypted private keys).
The location resolves in this order:
LE_JOBS_DIRconstant, defined in an optionallib/config.php(copylib/config.example.php).LETSENCRYPT_JOBS_DIRenvironment variable (PHP-FPM poolenv[...]or ApacheSetEnv).- Default:
~/letsencrypt-jobsunder the web user's home directory.
The directory is created mode 0700 on first request. diag.php prints the
resolved path and which source it came from.
Running under Virtualmin
Nothing to configure — each virtual server's user has a home directory, so the
default ~/letsencrypt-jobs works out of the box.
Running under www-data (or any non-home user)
www-data often has no usable $HOME, so set an explicit jobs directory:
mkdir -p /var/lib/letsencrypt-ui/jobs
chown www-data:www-data /var/lib/letsencrypt-ui/jobs
chmod 700 /var/lib/letsencrypt-ui/jobs
Then either copy lib/config.example.php to lib/config.php and set:
define('LE_JOBS_DIR', '/var/lib/letsencrypt-ui/jobs');
or set env[LETSENCRYPT_JOBS_DIR] = /var/lib/letsencrypt-ui/jobs in the
PHP-FPM pool. Keep this path outside your document root.
Installation
1. Install system packages (root)
apt-get install -y certbot openssl php-zip php-curl php-cli \
python3-certbot-dns-cloudflare python3-certbot-dns-route53 python3-certbot-dns-digitalocean
(Install only the certbot DNS plugins you intend to use; Manual DNS mode needs none of them.)
2. Deploy
Copy the contents of public_html/ into your web root (or a subdirectory of
it), and make sure the jobs directory is writable by the PHP
user. Example for a subdirectory deploy under a Virtualmin user:
USER=virtualuser
mkdir -p /home/$USER/public_html/letsencrypt
cp -r public_html/. /home/$USER/public_html/letsencrypt/
chown -R $USER:$USER /home/$USER/public_html/letsencrypt
chmod -R u+rwX,go-rwx /home/$USER/public_html/letsencrypt
3. Verify in the browser
Open the page. If the pre-flight check fails it prints the missing items at the
top — fix those first. diag.php shows the PHP context, cURL status, and the
resolved jobs directory (delete diag.php when you're done).
DNS providers
Pick one from the dropdown and the form swaps to the credentials it needs.
Cloudflare
- Credential: a single API token.
- Create at: https://dash.cloudflare.com/profile/api-tokens
- Permissions: Zone : DNS : Edit on the relevant zones (or all zones).
- Best practice: scope the token to specific zones and add Client IP Address Filtering for the server's egress IP.
Amazon Route 53
- Credential: AWS Access Key ID + Secret Access Key.
- Create at: https://console.aws.amazon.com/iam/home#/security_credentials
- Permissions: either
AmazonRoute53FullAccess, or a tighter policy withroute53:ListHostedZones,route53:GetChange, androute53:ChangeResourceRecordSetson the relevant hosted zone(s). - Best practice: a dedicated IAM user with only those permissions, or temporary STS credentials.
- Note: CNAME alias mode is not supported for Route 53 in this version. The route53 plugin reads from the AWS credential chain, so the keys are passed as environment variables to the certbot subprocess rather than written to a file.
DigitalOcean
- Credential: a Personal Access Token.
- Create at: https://cloud.digitalocean.com/account/api/tokens
- Scopes:
domain:readanddomain:write.
Manual DNS
No API key. After you submit, the app creates the ACME order itself (via its
built-in ACME client) and shows the exact _acme-challenge TXT record(s) to add
at your DNS host. A "Verify DNS" button queries a public resolver until the
records are visible; then "Complete" finalizes issuance. Works with any DNS host
and is the only mode that needs no certbot plugins.
Key type
By default the tool generates a fresh ECDSA P-256 key for each request (smaller and faster, supported by all modern clients). The New key type dropdown also offers ECDSA P-384 and RSA 2048 / 3072 / 4096. This applies to both the certbot and Manual DNS paths and is ignored when you supply your own key.
CAA pre-flight check
If a domain publishes CAA records that don't authorize letsencrypt.org,
issuance fails — a common silent problem. The Check CAA records button in
the Domains section looks up each domain's CAA records (walking up to the first
ancestor that publishes them, per RFC 8659) and reports whether Let's Encrypt is
allowed. It's advisory and never blocks submission.
If you control the zone and need to authorize Let's Encrypt:
example.com. IN CAA 0 issue "letsencrypt.org"
example.com. IN CAA 0 issuewild "letsencrypt.org"
(Skip the issuewild record if you're not using wildcards.)
CNAME alias mode
Use this when the domain you want a cert for is hosted at a registrar without an API (Hover, Network Solutions, a local reseller), but you also control a different zone at a supported provider.
- At the registrar (one-time, manual), for each domain
Dcreate a CNAME:_acme-challenge.D→_acme-challenge.D.<your alias zone> - In the form, pick the provider that hosts the alias zone, paste its
credentials, and put the alias zone (e.g.
acme.example.com) in the "CNAME alias zone" field. - Issue the cert as normal.
Internally the tool runs certbot certonly --manual --preferred-challenges dns-01 with auth/cleanup hooks. The hook is a small PHP CLI script
(lib/hook.php) that calls the provider's REST API to create / delete the TXT
record at _acme-challenge.D.<alias zone>. Let's Encrypt follows the CNAME you
set up at the registrar, sees the TXT, and validates.
CNAME alias mode is supported for Cloudflare and DigitalOcean, not Route 53.
Using an existing private key
By default certbot generates a new key (see Key type). To sign your
own key instead, check "Use an existing private key" and either upload the
key file (one PEM block, up to 32 KB) or paste the PEM text. Provide the
passphrase only if the key is encrypted; it is used once with openssl pkey
to re-emit the key unencrypted, then discarded.
Internally this triggers certbot's --csr mode:
- The key is normalized via
openssl pkey(validates the PEM, decrypts if needed) and saved as<jobdir>/privkey.pemmode 0600. - A CSR is built with
openssl req -new -key <privkey> -addext "subjectAltName=DNS:..."covering every domain on the form. - certbot is invoked with
--csr <jobdir>/request.csr --cert-path ... --chain-path ... --fullchain-path .... - The signed certificate is bundled with your original key.
Common reasons: key continuity across renewals (TLSA / pinning), keys provisioned in an HSM or escrow, and compliance workflows that require keys to be generated off the web server.
Limitations: exactly one PEM private key block is accepted (no PKCS#12, DER,
JKS, or PuTTY .ppk — convert to PEM first). Encrypted keys are decrypted into
the per-job sandbox so certbot can read them; the decrypted key is what ends up
in your downloaded .zip (the PFX is re-encrypted with the password you supply).
How it works
The form posts to process.php, which validates input and then takes one of two
paths. Either way, the slow work runs off the request thread so it can't be
killed by the web server's I/O timeout.
API-key modes (Cloudflare / Route 53 / DigitalOcean):
-
process.phpwrites a run spec (provider, credentials, domains, key type, etc.) into a per-ticket dir under the jobs directory and launches a detached worker, then redirects torunning.php. -
The worker (
lib/run_certbot.php→CertbotRunner) creates a job sandbox withconfig/,work/,logs/, writes any provider credentials to a 0600 file, and runs certbot roughly as:/usr/bin/certbot certonly --non-interactive --agree-tos --email <email> \ --dns-cloudflare --dns-cloudflare-credentials <jobdir>/cf-credentials.ini \ --dns-cloudflare-propagation-seconds 30 \ --config-dir <jobdir>/config --work-dir <jobdir>/work --logs-dir <jobdir>/logs \ --cert-name <primary> --preferred-challenges dns-01 \ --key-type ecdsa --elliptic-curve secp256r1 \ [-d <domain>...] [--staging]It writes progress/result to
status.jsonand deletes the run spec (with the credentials) when done. -
running.phppollsstatus.jsonvia a meta-refresh. On success it builds the bundles and redirects toresult.php; on failure it surfaces the error.
Manual DNS mode: process.php uses the built-in ACME client
(lib/AcmeClient.php) to create the order and compute the TXT values, then
redirects to challenge.php (which uses verify_dns.php to check propagation).
complete.php notifies the challenges, finalizes the order, and builds the
bundles.
In both paths BundleBuilder produces the PEM zip and runs openssl pkcs12 -export for the PFX (the PFX password is passed via -passout file:, never on
the command line). download.php streams files from the sandbox after verifying
the path is still inside the jobs directory. "Delete files and start over"
(finish.php) recursively removes the job directory.
File layout
public_html/
├── index.php Main form (progressive disclosure, provider/key/CAA UI)
├── process.php Validates input; launches API-key worker or manual ACME setup
├── running.php Progress page that polls the background worker
├── result.php Shows downloads, expiry + calendar reminder, cleanup
├── download.php Streams a download from the job sandbox
├── finish.php Deletes the job sandbox
├── calendar.php Generates the .ics renewal reminder
├── check_caa.php AJAX CAA pre-flight check
├── challenge.php Manual DNS: shows TXT records to add
├── verify_dns.php Manual DNS: AJAX TXT propagation check
├── complete.php Manual DNS: finalizes the order and builds bundles
├── cancel_manual.php Manual DNS: abandons an in-progress order
├── diag.php Environment diagnostics (delete after setup)
├── style.css Light-themed CSS
├── .htaccess HTTPS redirect, security headers, blocks lib/ + dotfiles
└── lib/
├── helpers.php jobsBase()/config, preflight, key-type & CAA helpers
├── CertbotRunner.php Builds and executes the certbot command
├── run_certbot.php Detached background worker (CLI)
├── BundleBuilder.php Creates the .zip and runs openssl pkcs12
├── AcmeClient.php Minimal ACME v2 client for Manual DNS mode
├── CertHelper.php Key/CSR/chain helpers for Manual DNS mode
├── DnsVerifier.php Public-resolver TXT lookups for verify_dns
├── HttpClient.php Small cURL wrapper
├── hook.php certbot manual auth/cleanup hook (CNAME alias mode)
├── config.example.php Optional per-deployment config template
├── .htaccess "Require all denied"
└── dns/
├── Provider.php, ProviderRegistry.php
├── CloudflareProvider.php, Route53Provider.php
├── DigitalOceanProvider.php, ManualProvider.php
Security notes
- The page has no authentication. Anyone who can reach it can request certificates against zones the API token they paste in covers. By design each request requires the user to supply their own credentials, so none are stored on the server. To restrict access, put it behind HTTP-Basic, IP-allowlist it, or place it on a private vhost.
- API tokens are written only as 0600 files inside the job sandbox and deleted as soon as issuance finishes.
- Generated bundles live OUTSIDE the web root, in the jobs directory. They are
streamed by
download.phpand never directly linkable. - The PFX password is passed to openssl via
-passout file:, so it isn't in the process arguments, and the file is removed immediately. lib/has both an.htaccessdeny rule and a parentRewriteRuledeny, so the PHP files can't be fetched directly.- The "Delete files and start over" form and the CAA/verify AJAX endpoints are CSRF-protected.
What this tool intentionally does NOT do
- No automatic renewal. It's a one-shot issuance UI. Use the downloadable
calendar reminder, or run system certbot (root) with a credentials file and a
systemdtimer, for unattended renewals. - No certificate storage. Files are deleted on demand — download what you need, then click "Delete files and start over".
- No installation into Apache/NGINX. The output is meant to be downloaded and applied wherever you want.
Troubleshooting
"certbot dns-… plugin not detected"
Install the matching plugin (apt-get install python3-certbot-dns-cloudflare,
etc.) and confirm with certbot plugins.
"Cannot determine a jobs directory" / jobs dir not writable
The PHP user has no usable $HOME, or the resolved path isn't writable. Set
LE_JOBS_DIR (in lib/config.php) or LETSENCRYPT_JOBS_DIR to a writable
directory outside the web root — see Jobs directory. Check
the resolved path with diag.php.
Certbot fails with "DNS problem: NXDOMAIN looking up TXT for _acme-challenge..." The token lacks edit permission on that zone, or the zone isn't hosted on the selected provider. Verify the zone and the token's scope. (Try the CAA check and Manual DNS mode to narrow it down.)
Certbot succeeds but the PFX won't import on Windows Use the exact password you entered. Some legacy Windows tools want 3DES rather than AES; re-export with:
openssl pkcs12 -export -legacy -out new.pfx -in fullchain.pem -inkey privkey.pem
"PHP exec() is disabled"
Remove exec/proc_open from disable_functions in the PHP-FPM pool php.ini,
then reload php-fpm. Manual DNS mode still needs exec (for openssl), so this
applies to all modes.
License
Released under the MIT License.