From zero to your first free SSL certificate in 15 minutes
A tutorial that gets you from an empty Docker host to a fully-issued Let's Encrypt wildcard certificate via CertMate + Cloudflare. Real commands, no hand-waving. Skip the intro if you already have CertMate running.
This is the "just show me the commands" walkthrough. No theory — we
already have a separate page
for that. The goal is a working *.example.com wildcard certificate signed by Let's
Encrypt, with auto-renewal scheduled, in under fifteen minutes of
wall-clock time.
What you need before starting:
- A Docker host with
docker compose(a $5/month VPS works). - A domain whose DNS is on Cloudflare (we use Cloudflare for the walkthrough because its free tier covers everyone; the same shape works for Route53, Azure, Google, Hetzner — see delegation if your DNS lives elsewhere).
- Five minutes to create a Cloudflare API token.
1. Create a scoped Cloudflare API token
Don't use a Global API Key. Go to My Profile → API Tokens → Create Token → Edit zone DNS. Restrict it to the specific zone you'll issue against. The token needs:
- Zone → DNS → Edit on
example.com - Zone → Zone → Read on
example.com
Copy the token. You'll see it once. Treat it like a password.
2. Boot CertMate
mkdir certmate && cd certmate
# Pull the docker-compose example
curl -fsSL https://raw.githubusercontent.com/fabriziosalmi/certmate/main/docker-compose.yml \
-o docker-compose.yml
# Generate a long random bearer token CertMate will accept for its
# own REST API. Save this — you'll use it below.
openssl rand -hex 32 > api-bearer-token
echo "API_BEARER_TOKEN=$(cat api-bearer-token)" > .env
# Boot
docker compose up -d
docker compose logs -f certmate # wait for "Listening on 0.0.0.0:8000"
CertMate is now serving on http://localhost:8000. In
production you'd put a reverse proxy in front; for the tutorial we
hit it directly.
3. Configure the Cloudflare account
Two ways to do this: the web UI at http://localhost:8000
→ Settings → DNS Providers, or the REST API. We'll use the
REST API because that's what you'll script against later.
export TOKEN=$(cat api-bearer-token)
curl -X POST http://localhost:8000/api/dns/cloudflare/accounts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"account_id": "production",
"name": "Production Cloudflare",
"credentials": {
"api_token": "PASTE_YOUR_CLOUDFLARE_TOKEN_HERE"
}
}'
The response is 201 with the account metadata. The
Cloudflare token is now stored on disk under the CertMate data
volume, mode 600, accessible only by the certmate process user.
4. Issue the certificate
curl -X POST http://localhost:8000/api/certificates/create \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domain": "example.com",
"san_domains": ["*.example.com"],
"dns_provider": "cloudflare",
"auto_renew": true
}'
This kicks off the DNS-01 dance described in
that other page. CertMate
writes a TXT record at _acme-challenge.example.com, waits for propagation
(Cloudflare is fast — usually 5-10 seconds), tells Let's Encrypt
to validate, retrieves the certificate, and cleans up the TXT
record. End-to-end, around 30-90 seconds for a first issuance.
The response when it finishes:
{
"domain": "example.com",
"status": "issued",
"expiry_date": "2027-08-15T12:34:56Z",
"days_until_expiry": 90,
"cert_path": "/app/certificates/example.com/cert.pem",
"san_domains": ["*.example.com"]
} 5. Verify the certificate
# Pull the cert bytes out of the container
docker compose exec certmate \
cat /app/certificates/example.com/fullchain.pem > fullchain.pem
# Read the SAN list
openssl x509 -in fullchain.pem -noout -text \
| grep -A 1 "Subject Alternative Name"
# Should print:
# Subject Alternative Name:
# DNS:example.com, DNS:*.example.com
# Read the expiry
openssl x509 -in fullchain.pem -noout -enddate 6. Wire auto-renewal (you already did)
You set auto_renew: true in step 4, so there's nothing
else to do. CertMate's scheduler wakes every few hours, picks up
any certificate within 30 days of expiry, runs the DNS-01 dance
again (same SAN list, new serial), and atomically swaps the files
on disk.
To confirm:
curl -s http://localhost:8000/api/certificates/example.com \
-H "Authorization: Bearer $TOKEN" \
| jq '{ auto_renew, renewal_threshold_days, days_until_expiry }'
# {
# "auto_renew": true,
# "renewal_threshold_days": 30,
# "days_until_expiry": 90
# } 7. (Optional but recommended) Wire a deploy hook
A certificate that nobody reads doesn't help. The minimal global hook reloads nginx after every renewal:
curl -X POST http://localhost:8000/api/deploy/config \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"deploy_hooks": {
"enabled": true,
"global_hooks": [
{
"id": "nginx-reload",
"name": "Reload nginx after cert change",
"command": "/usr/sbin/nginx -s reload",
"enabled": true,
"timeout": 30,
"on_events": ["created", "renewed"]
}
],
"domain_hooks": {}
}
}' See the deploy hooks page for the recipe catalogue (scp+reload, S3 push, PagerDuty on revocation).
What you have now
- A wildcard Let's Encrypt certificate covering
example.comand every*.example.comsubdomain. - Auto-renewal scheduled, fires within 30 days of expiry.
- A deploy hook that reloads nginx whenever a new cert lands.
- A REST API you can script against, and a web UI for the operators who'd rather click.
- An audit log writing one row per issuance, renewal, and hook firing (see audit).
Total elapsed time, assuming you typed slowly and read the prompts: about twelve minutes.