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](#jobs-directory) points at a writable path. There are no Virtualmin/Webmin API calls anywhere in the code.
Find a file
Kevin Hayashi 590df36af0 Add MIT license
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:28:02 -07:00
public_html Make calendar reminder deployment-neutral 2026-05-25 19:19:26 -07:00
.gitattributes Initial version: Let's Encrypt UI for Virtualmin 2026-05-25 16:18:12 -07:00
.gitignore Make the jobs directory configurable 2026-05-25 18:51:23 -07:00
install.sh Initial version: Let's Encrypt UI for Virtualmin 2026-05-25 16:18:12 -07:00
LICENSE Add MIT license 2026-05-25 19:28:02 -07:00
README.md Add MIT license 2026-05-25 19:28:02 -07:00

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 a README.txt.
    • PFX: PKCS#12 archive encrypted with a password you enter.
  • "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, missing openssl, missing php CLI, 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_rewrite and mod_headers
  • PHP 7.4+ (8.x preferred) with php-zip, php-curl, php-cli, the posix extension, and exec() / 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)
  • openssl
  • setsid (util-linux) or nohup for 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:

  1. LE_JOBS_DIR constant, defined in an optional lib/config.php (copy lib/config.example.php).
  2. LETSENCRYPT_JOBS_DIR environment variable (PHP-FPM pool env[...] or Apache SetEnv).
  3. Default: ~/letsencrypt-jobs under 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 with route53:ListHostedZones, route53:GetChange, and route53:ChangeResourceRecordSets on 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

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.

  1. At the registrar (one-time, manual), for each domain D create a CNAME: _acme-challenge.D_acme-challenge.D.<your alias zone>
  2. 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.
  3. 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:

  1. The key is normalized via openssl pkey (validates the PEM, decrypts if needed) and saved as <jobdir>/privkey.pem mode 0600.
  2. A CSR is built with openssl req -new -key <privkey> -addext "subjectAltName=DNS:..." covering every domain on the form.
  3. certbot is invoked with --csr <jobdir>/request.csr --cert-path ... --chain-path ... --fullchain-path ....
  4. 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):

  1. process.php writes 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 to running.php.

  2. The worker (lib/run_certbot.phpCertbotRunner) creates a job sandbox with config/, 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.json and deletes the run spec (with the credentials) when done.

  3. running.php polls status.json via a meta-refresh. On success it builds the bundles and redirects to result.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.php and 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 .htaccess deny rule and a parent RewriteRule deny, 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 systemd timer, 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.