Self-hosted certificate lifecycle management: issue, renew, discover, inventory and deploy TLS certificates across your infrastructure
See the code
CertMate is a self-hosted certificate lifecycle management platform: it issues and renews TLS certificates, discovers the ones you did not issue, keeps a single inventory of what exists across your estate — what is served where, who issued it, when it expires, which cryptography it uses — and deploys renewed certificates to where they are needed. It supports 29 DNS providers, runs its own private CA for internal names, keeps a tamper-evident audit trail of every operation, and exposes all of it through a REST API.

Quick Start • Video Guide • CLI • Documentation • Installation • DNS Providers • CA Providers • Storage Backends • Backup and Recovery • API Reference
CertMate is the open-source core of a small, focused toolset:
Enterprise / high-scale — multi-tenant, mTLS, white-label and NIS2-aligned deployments are available through CertMate-ng (source-available, BSL 1.1, EU-built). For access or a deployment discussion, email [email protected].
The whole certificate lifecycle from your terminal — pip install certmate-cli:

pip install certmate-cli
export CERTMATE_URL=https://certmate.example.com
export CERTMATE_TOKEN=... # omit on a fresh (setup-mode) instance
certmate health
certmate cert create app.example.com --dns cloudflare --wait # issue, block until live
certmate cert ls
certmate cert info app.example.com
certmate audit verify
certmate-cli is a thin layer over certmate-sdk (pip install certmate-sdk) — a small,
httpx-based Python client for the same REST API the web UI and MCP server drive. Both are
first-party, live in clients/, and are published to PyPI. The clip above is a real
issuance over DNS-01 (Let's Encrypt staging); see demo/.
CertMate ships a first-party Model Context Protocol server, so an assistant like Claude can
drive the same REST API the web UI and the CLI use — with the same auth and the same audit trail.
It lives in mcp/, is Node.js (>= 20), and exposes 16 tools: inventory and status
(certmate_list_certificates, certmate_get_certificate, certmate_diagnostics,
certmate_get_activity, …), lifecycle operations (certmate_create_certificate,
certmate_renew_certificate, certmate_get_job, certmate_set_auto_renew, …), and delivery
(certmate_download_certificate, certmate_deploy_certificate).
cd mcp && npm install
// claude_desktop_config.json — or any MCP-capable client
{
"mcpServers": {
"certmate": {
"command": "node",
"args": ["/path/to/certmate/mcp/index.js"],
"env": {
"CERTMATE_URL": "https://certmate.example.com",
"CERTMATE_TOKEN": "<a scoped API key with is_agent: true>"
}
}
}
}
Give it a scoped key, not your admin token. A key created with is_agent: true (a checkbox
under Settings → API Keys, or is_agent in POST /api/keys) makes every action the agent takes
land in the audit chain as actor.kind="agent" rather than being indistinguishable from a human
operator — which is the difference between an audit trail and a rumour. Scope the key to the
domains the agent is allowed to touch.
Full tool reference, attribution model and safety notes: docs/mcp.md.
CertMate solves the complexity of SSL certificate management in modern distributed architectures. Whether you're running a single application or managing certificates across multiple datacenters, CertMate provides:
CertMate runs as a single instance. Its renewal scheduler runs inside the web process, so a second replica is a second scheduler issuing against the same certificate store — duplicate ACME orders, and the CA's duplicate-certificate rate limit. The Helm chart refuses to render more than one replica rather than let that happen quietly.
This is a deliberate design choice, not a missing feature: one writer is what makes certificate state safe without a distributed lock service. It scales up (a single instance manages thousands of certificates across many datacenters), not out. For availability, run active/standby with the data volume on shared storage and fail over — see Availability and failover.
*.example.com and example.comrenews_at)certificate_expiring at 14/7/3/1 days (at the renewal threshold when auto-renew is off), and domain_expiring for the domain registration itself at 60/30/14/7/1 days. Each threshold is announced once per expiry date/api/certificates/{domain}/download)/docs/ and /redoc/CertMate supports a wide range of DNS providers through Let's Encrypt DNS-01 challenge via individual certbot plugins that provide reliable, well-tested DNS challenge support. The complete list is in the table below. Multi-account support is available for major providers, enabling enterprise-grade deployments with separate accounts for production, staging, and disaster recovery.
| Provider | Credentials Required | Multi-Account | Use Case | Availability |
|---|---|---|---|---|
| Cloudflare | API Token | Yes | Global CDN, Free tier available | Stable |
| AWS Route53 | Access Key, Secret Key | Yes | AWS infrastructure, Enterprise | Stable |
| Azure DNS | Service Principal credentials | Yes | Microsoft ecosystem | Stable |
| Google Cloud DNS | Service Account JSON | Yes | Google Cloud Platform | Stable |
| DigitalOcean | API Token | Yes | Cloud infrastructure | Stable |
| PowerDNS | API URL, API Key | Yes | Self-hosted, On-premises | Separate install |
| EfficientIP SOLIDserver | Host, API Credentials | Yes | Enterprise DDI / Smart Architecture | Stable |
| RFC2136 | Nameserver, TSIG Key/Secret | Yes | Standard DNS update protocol | Stable |
| Linode (Akamai Connected Cloud) | API Key | Single | Cloud hosting | Stable |
| Akamai Edge DNS | EdgeGrid (.edgerc) credentials | Single | Enterprise managed DNS | Stable |
| Gandi | API Token | Single | Domain registrar | Stable |
| OVH | API Credentials | Single | European hosting | Stable |
| Namecheap | Username, API Key | Single | Domain registrar | Unavailable |
| Vultr | API Key | Single | Global cloud infrastructure | Stable |
| DNS Made Easy | API Key, Secret Key | Single | Enterprise DNS management | Stable |
| NS1 | API Key | Single | Intelligent DNS platform | Stable |
| Hetzner (legacy DNS) | API Token | Single | European cloud hosting | Stable |
| Hetzner Cloud | API Token | Single | Hetzner Cloud DNS (hcloud) | Stable |
| Porkbun | API Key, Secret Key | Single | Domain registrar with DNS | Stable |
| GoDaddy | API Key, Secret | Single | Popular domain registrar | Stable |
| Hurricane Electric | Username, Password | Single | Free DNS hosting | Extended image |
| Dynu | API Token | Single | Dynamic DNS service | Extended image |
| ArvanCloud | API Key | Single | Iranian cloud provider | Stable |
| Infomaniak | API Token | Single | Swiss ISP & cloud provider | Stable |
| ACME-DNS | JSON Config | Single | Generic ACME-DNS server | Stable |
| Scaleway | API Token (secret key) | Single | European cloud (EU-sovereign) | Separate install |
| deSEC | API Token | Single | Free, non-profit DNSSEC DNS | Stable |
| DuckDNS | Token | Single | Free dynamic DNS | Stable |
| Custom Script | User-provided hook scripts | Single | Any provider via custom hooks | Stable |
What the availability column means. Until now it said Stable on all
twenty-nine rows, which is not a status — a column with one value cannot be
wrong about any particular provider, and four of them were wrong:
| Value | Meaning |
|---|---|
| Stable | The plugin is pinned in requirements.txt, so it is in the default image. |
| Extended image | Pinned in requirements-extended.txt. Build with --build-arg EXTRA_REQUIREMENTS=requirements-extended.txt. |
| Separate install | Not shipped in any image. pip install it yourself, in its own environment where noted. |
| Unavailable | No usable plugin exists for the supported stack. CertMate can be configured for it, but issuance will fail. |
Namecheap is the Unavailable one: the only release on PyPI is
certbot-dns-namecheap 1.0.0, an alpha targeting Python 2.7-3.8, incompatible
with certbot 2.x on Python 3.12 — as NamecheapStrategy's own docstring has
said all along. Use ACME-DNS or the custom-script hook for Namecheap domains.
Scaleway is alpha (0.0.7, declares Python 2.7 support) and PowerDNS pulls
dns-lexicon<=3.5.6, which conflicts with the rest of the extended set — hence
its own environment.
For supported providers, you can configure multiple accounts to enable:
Detailed Setup Instructions: See DNS Providers Guide for provider-specific configuration. Step-by-Step Installation: See Installation Guide for complete setup guide. Multi-Account Examples: See DNS Providers Guide for enterprise configuration examples.
Get CertMate running in under 5 minutes with Docker Compose. One file, the published image, nothing to clone or build.
docker compose)mkdir certmate && cd certmate
curl -fsSLO https://raw.githubusercontent.com/fabriziosalmi/certmate/main/deploy/docker-compose.yml
printf 'API_BEARER_TOKEN=%s\nSECRET_KEY=%s\nCERTMATE_BACKUP_PASSPHRASE=%s\n' \
"$(openssl rand -hex 32)" "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env
chmod 600 .env
.env now holds the three secrets CertMate needs: the API token (also asked once by the first-run screen to create the admin), the session signing key, and the backup passphrase. Optionally add CLOUDFLARE_TOKEN=... to bootstrap a Cloudflare DNS account; every other DNS provider is configured in the web UI. What each setting does, and how to upgrade: docs/docker.md.
Storage Backends: By default, certificates are stored locally. For enterprise deployments, you can configure Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, Infisical, or any S3-compatible object storage via the web interface after startup. See Storage Backends for details.
Backup Best Practices: CertMate includes a unified backup system that creates atomic snapshots of both settings and certificates. Set
CERTMATE_BACKUP_PASSPHRASE. With it, every automatic backup is complete and encrypted at rest, so it can actually restore this instance; without it, automatic backups keep their credentials masked and are configuration snapshots that cannot. The backup list marks which archives can restore, and Settings disables Restore on the ones that cannot. Then keep a copy off the host — archives on the host are pruned after 30 days, and a lost volume takes them with it.POST /api/backups/uploadbrings one back.
docker compose up -d
docker compose ps
docker compose logs -f certmate
CertMate listens on 127.0.0.1:8000 only. For remote access, put a reverse proxy in front and set BEHIND_PROXY=true in .env.
To build from source instead, clone the repository and use its root docker-compose.yml (development).
| Service | URL | Description |
|---|---|---|
| Web Dashboard | http://localhost:8000 | Main certificate management interface |
| API Documentation | http://localhost:8000/docs/ | Interactive Swagger/OpenAPI docs |
| Alternative API Docs | http://localhost:8000/redoc/ | ReDoc documentation |
| Health Check | http://localhost:8000/health | Service health monitoring |
Using the Web Interface:
example.com)Using the API:
curl -X POST "http://localhost:8000/api/certificates/create" \
-H "Authorization: Bearer your_api_token_here" \
-H "Content-Type: application/json" \
-d '{"domain": "example.com"}'
Choose the installation method that best fits your environment:
Isolated, reproducible, and the way CertMate is tested and released. Run one
container (see the single-instance note above); give it more CPU and memory
rather than more replicas. Published images cover two architectures: AMD64 (Intel/AMD) and ARM64 (Apple Silicon, ARM servers). ARM v7 (32-bit Raspberry Pi) is not published; build it yourself with docker buildx build --platform linux/arm/v7 -t YOUR_DOCKERHUB_USERNAME/certmate:armv7 --push . (see Docker).
The production Compose bundle is one file that runs the published image; the Quick Start above shows the commands. To build the image yourself instead, clone the repository and run docker compose up -d with its root docker-compose.yml, after preparing its bind-mounted directories as its comments describe.
Multi-Platform Support:
# Build for multiple architectures (ARM64 + AMD64); add --push to publish the result
docker buildx build --platform linux/amd64,linux/arm64 -t YOUR_DOCKERHUB_USERNAME/certmate:latest .
# Build and push to Docker Hub for all platforms
docker buildx build --platform linux/amd64,linux/arm64 -t YOUR_DOCKERHUB_USERNAME/certmate:latest --push .
# Use pre-built multi-platform image (bound to localhost; put it behind a
# reverse proxy and enable authentication before exposing it externally)
docker run --platform linux/arm64 -d --name certmate --env-file .env -p 127.0.0.1:8000:8000 fabriziosalmi/certmate:latest
Multi-Platform Guide: See Docker Guide for comprehensive multi-architecture setup instructions.
Ideal for development and testing environments.
# Create and activate virtual environment
python3 -m venv certmate-env
source certmate-env/bin/activate # On Windows: certmate-env\Scripts\activate
# Install dependencies
git clone https://github.com/fabriziosalmi/certmate.git
cd certmate
pip install -r requirements.txt
# Set environment variables
export API_BEARER_TOKEN="your_token_here"
export CLOUDFLARE_TOKEN="your_cloudflare_token"
# Run the application
python app.py
For container orchestration and managed rollouts. replicas: 1 is not an
example value — it is the supported configuration, and the Helm chart fails at
template time if you change it.
Install the Helm chart, published to GHCR on every release:
kubectl create namespace certmate
kubectl -n certmate create secret generic certmate-secrets \
--from-literal=API_BEARER_TOKEN="$(openssl rand -hex 32)" \
--from-literal=SECRET_KEY="$(openssl rand -hex 32)" \
--from-literal=CERTMATE_BACKUP_PASSPHRASE="$(openssl rand -hex 32)"
helm install certmate oci://ghcr.io/fabriziosalmi/charts/certmate \
--namespace certmate --set secrets.existingSecret=certmate-secrets
CertMate on Kubernetes covers the first login, Argo CD and Flux, production sizing and OOM troubleshooting. Every chart value is in the chart README.
One command on Debian, Ubuntu, Fedora, RHEL, Rocky or Alma Linux:
curl -fsSL https://raw.githubusercontent.com/fabriziosalmi/certmate/main/deploy/install.sh | sudo sh
The installer puts CertMate in /opt/certmate with its own Python 3.12 (fetched by uv, so the system Python is not touched), generates the secrets into /etc/certmate/certmate.env, and starts the certmate systemd service on 127.0.0.1:8000. Run it again to upgrade: certificates, data and configuration stay. Details and options: docs/installation.md.
To set the service up by hand instead, follow the next section.
For production deployments, CertMate should run as a system service. This section provides comprehensive instructions for setting up CertMate with systemd on Linux distributions.
Create a dedicated user for running CertMate:
# Create system user and group
sudo useradd --system --shell /bin/false --home-dir /opt/certmate --create-home certmate
# Set proper ownership
sudo chown -R certmate:certmate /opt/certmate
Set up the application in /opt/certmate:
# Clone straight into the path. `useradd --create-home` above populated
# /opt/certmate from /etc/skel, so it is not empty and `mv certmate /opt/`
# fails with "Directory not empty" — leaving the next steps to run in a
# directory with no application in it.
sudo -u certmate git clone https://github.com/fabriziosalmi/certmate.git /opt/certmate
cd /opt/certmate
# Create Python virtual environment
sudo -u certmate python3 -m venv venv
sudo -u certmate ./venv/bin/pip install -r requirements.txt
# Create necessary directories. All four: the startup writeability probe
# in modules/factory.py checks certificates, data, backups AND logs,
# and raises at boot if any of them is not writable.
sudo -u certmate mkdir -p certificates data backups logs
Create environment file for the service:
# Create environment file
# The unit reads /etc/certmate/certmate.env (see certmate.service), not a
# .env in the application directory.
sudo install -d -m 750 /etc/certmate
sudo tee /etc/certmate/certmate.env > /dev/null <<EOF
# SECURITY: Change this token!
API_BEARER_TOKEN=your_super_secure_api_token_here_change_this
EOF
# PORT has no effect here: the port is the literal in the unit's ExecStart
# (--bind 0.0.0.0:8000), and nothing in the application reads PORT under
# gunicorn. To change it, edit ExecStart. PORT is honoured only by the
# container image.
# Set proper permissions
sudo chown root:certmate /etc/certmate/certmate.env
sudo chmod 640 /etc/certmate/certmate.env
Install and configure the systemd service:
# Copy service file
sudo cp /opt/certmate/certmate.service /etc/systemd/system/
# Reload systemd configuration
sudo systemctl daemon-reload
# Enable service to start on boot
sudo systemctl enable certmate
# Start the service
sudo systemctl start certmate
Check that the service is running correctly:
# Check service status
sudo systemctl status certmate
# View recent logs
sudo journalctl -u certmate --lines=50
# Follow logs in real-time
sudo journalctl -u certmate -f
Common commands for managing the CertMate service:
# Start service
sudo systemctl start certmate
# Stop service
sudo systemctl stop certmate
# Restart service
sudo systemctl restart certmate
# Reload service configuration
sudo systemctl reload certmate
# Check if service is enabled
sudo systemctl is-enabled certmate
# Check if service is active
sudo systemctl is-active certmate
# Disable service from starting on boot
sudo systemctl disable certmate
Ensure proper file permissions for security:
# Set ownership
sudo chown -R certmate:certmate /opt/certmate
# Set directory permissions
sudo chmod 755 /opt/certmate
sudo chmod 750 /opt/certmate/certificates /opt/certmate/data /opt/certmate/backups /opt/certmate/logs
# Set file permissions
sudo chmod 644 /opt/certmate/*.py /opt/certmate/*.md
sudo chmod 640 /etc/certmate/certmate.env
sudo chmod 755 /opt/certmate/venv/bin/*
/etc/certmate/certmate.env0.0.0.0:8000 by default - consider using a reverse proxy for production.env file contains sensitive data and should be readable only by the certmate user/opt/certmate/certificates with restricted accessIf the service fails to start:
sudo systemctl status certmatesudo journalctl -u certmate --lines=100certmate user can read all necessary filessudo -u certmate /opt/certmate/venv/bin/python /opt/certmate/app.pysudo -u certmate /opt/certmate/venv/bin/certbot --version — the ACME client is the dependency that breaks first when the pins drift.For more detailed installation instructions, see the Installation Guide.
CertMate supports authenticating users against an external OpenID Connect provider (Keycloak, Authentik, Okta, Google Workspace, Microsoft Entra, ...) using the Authorization Code + PKCE flow. SSO is additive: local username/password login and API keys keep working alongside it.
Open the CertMate UI as an admin → Settings → SSO.
Set the Issuer URL to the IdP's base URL (the path before /.well-known/openid-configuration). For example:
https://idp.example.com/realms/mainhttps://idp.example.com/application/o/certmate/https://accounts.google.comFill in the Client ID and Client Secret issued by the IdP for the CertMate application.
In the IdP, register CertMate's callback URL as a valid redirect URI:
https://your-certmate.example.com/api/auth/oidc/callback
Pick the claim names your IdP uses for username (preferred_username by default), email (email), and role (groups). Add Role mappings to translate IdP group/role claim values to CertMate roles — first match wins. Anything that doesn't match falls back to the configured Default role (viewer recommended).
Toggle Enable OIDC/SSO on and save. Visit /login in a new browser session to see the Sign in with button.
For a Keycloak realm that exposes a groups claim, the configuration block in settings.json looks like:
"oidc": {
"enabled": true,
"provider_name": "Keycloak",
"issuer_url": "https://idp.example.com/realms/main",
"client_id": "certmate",
"client_secret": "********",
"scopes": ["openid", "email", "profile", "groups"],
"role_claim": "groups",
"role_mappings": [
{ "claim_value": "certmate-admins", "role": "admin" },
{ "claim_value": "certmate-operators", "role": "operator" }
],
"default_role": "viewer",
"auto_create_users": true,
"link_by_email": true,
"sync_role_on_login": true
}
auto_create_users) creates a CertMate user row on first login. The row has an empty password hash so JIT-provisioned SSO accounts cannot fall back to local login.link_by_email) detects collisions with existing local users and merges identities — the user keeps their existing password hash, so a local-then-linked account can still log in either way during a rollout. Their role is preserved at the moment of linking, and from the next login onwards it is governed by sync_role_on_login like anyone else's (see below): with the default true, a linked local admin whose IdP groups map to viewer becomes a viewer on their second login. Disable link_by_email if you want JIT-only provisioning with no local-password fallback.sub + iss) lookup always wins over email matching, so an already-linked SSO user is never accidentally re-merged when their IdP email changes.sync_role_on_login, default true) re-derives the role from the current claims on every login, so removing someone from an admin group in the IdP demotes them in CertMate too. Set it to false when the IdP only authenticates and roles are managed inside CertMate — an admin promoting someone by hand then survives their next login.client_secret is masked (********) in every GET response and round-tripped safely through the Settings UI.oidc settings block is on the bulk-POST reject list — only the dedicated /api/auth/oidc/settings endpoint can mutate it, with full audit.CertMate provides a comprehensive REST API for programmatic certificate management. All endpoints require Bearer token authentication.
Include the Authorization header in all API requests:
Authorization: Bearer your_api_token_here
The full reference is docs/api.md: every endpoint, the role it needs, the shape it answers with, and the error format.
This section used to be a second catalogue, and the two had drifted: of the endpoints each named, six were in both. A reader had no way to tell which was current, and the reference was missing surfaces this file carried, which is where the storage backend, backup, metrics and zombie-scan sections in docs/api.md came from.
A worked example, so the shape is here even if the list is not:
# Every request carries the token.
curl -H "Authorization: Bearer $CERTMATE_TOKEN" \
https://certmate.example.com/api/certificates
# Issue one. The response is the certificate record, not a job id.
curl -X POST -H "Authorization: Bearer $CERTMATE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"domain": "api.example.com", "dns_provider": "cloudflare"}' \
https://certmate.example.com/api/certificates/create
Certificate downloads for infrastructure automation:
# Download every certificate file as one ZIP
GET /api/certificates/{domain}/download
Authorization: Bearer your_token_here
This endpoint returns a ZIP file containing all certificate files:
cert.pem - Server certificatechain.pem - Intermediate certificate chainfullchain.pem - Full certificate chain (cert + chain)privkey.pem - Private keyFor a single file, use the path form
GET /api/certificates/{domain}/download/{cert|chain|fullchain|privkey|combined}.
privkey and combined require operator; a viewer may pull the public
material only.
curl -H "Authorization: Bearer your_token_here" \
-o example.com-tls.json \
https://your-certmate-server.com/api/certificates/example.com/download?format=json
import requests
from pathlib import Path
class CertMateClient:
def __init__(self, base_url, token):
self.base_url = base_url.rstrip('/')
self.headers = {"Authorization": f"Bearer {token}"}
def download_certificate(self, domain):
"""Download certificate bundle as JSON for domain"""
url = f"{self.base_url}/api/certificates/{domain}/download?format=json"
response = requests.get(url, headers=self.headers)
response.raise_for_status()
return response.json()
def list_certificates(self):
"""List all managed certificates"""
response = requests.get(f"{self.base_url}/api/certificates",
headers=self.headers)
response.raise_for_status()
return response.json()
def create_certificate(self, domain, dns_provider=None):
"""Create new certificate for domain"""
data = {"domain": domain}
if dns_provider:
data["dns_provider"] = dns_provider
response = requests.post(f"{self.base_url}/api/certificates/create",
json=data, headers=self.headers)
response.raise_for_status()
return response.json()
def renew_certificate(self, domain):
"""Renew existing certificate"""
response = requests.post(f"{self.base_url}/api/certificates/{domain}/renew",
headers=self.headers)
response.raise_for_status()
return response.json()
# Usage example
client = CertMateClient("https://certmate.company.com", "your_token_here")
# List and download certificates
certs = client.list_certificates()
bundle = client.download_certificate("api.company.com")
Path("/etc/ssl/certs/api").mkdir(parents=True, exist_ok=True)
for name, key in {
"cert.pem": "cert_pem",
"chain.pem": "chain_pem",
"fullchain.pem": "fullchain_pem",
"privkey.pem": "private_key_pem",
}.items():
Path("/etc/ssl/certs/api", name).write_text(bundle[key])
The same JSON response shape can be consumed directly by Ansible's uri module or Salt's HTTP helpers without unpacking an archive.
Terraform: there is no CertMate Terraform provider. This README used to
show one, with source = "local/certmate" and certmate_certificate
resources; neither the registry nor any repository has it, so the example
could not be run. Drive the REST API from Terraform with the http provider
or a local-exec, or use the Ansible and shell examples below, which are
against the real API.
Bash Automation Script:
#!/bin/bash
set -euo pipefail
# Configuration
CERTMATE_URL="https://certmate.company.com"
API_TOKEN="${CERTMATE_TOKEN}"
DOMAIN="${1:-example.com}"
CERT_DIR="/etc/ssl/certs/${DOMAIN}"
BACKUP_DIR="/backup/certs/${DOMAIN}/$(date +%Y%m%d_%H%M%S)"
# Functions
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
}
create_backup() {
if [[ -d "$CERT_DIR" ]]; then
log "Creating backup of existing certificates"
mkdir -p "$BACKUP_DIR"
cp -r "$CERT_DIR"/* "$BACKUP_DIR/" || true
fi
}
download_certificate() {
log "Downloading certificate for ${DOMAIN}"
# Download with retry logic
for i in {1..3}; do
if curl -f -H "Authorization: Bearer $API_TOKEN" \
-o "${DOMAIN}-tls.json" \
"$CERTMATE_URL/api/certificates/$DOMAIN/download?format=json"; then
log "Certificate downloaded successfully"
return 0
else
log "Download attempt $i failed, retrying..."
sleep 5
fi
done
log "Failed to download certificate after 3 attempts"
return 1
}
extract_certificate() {
log "Extracting certificate to ${CERT_DIR}"
mkdir -p "$CERT_DIR"
jq -r '.cert_pem' "${DOMAIN}-tls.json" > "$CERT_DIR/cert.pem"
jq -r '.chain_pem' "${DOMAIN}-tls.json" > "$CERT_DIR/chain.pem"
jq -r '.fullchain_pem' "${DOMAIN}-tls.json" > "$CERT_DIR/fullchain.pem"
jq -r '.private_key_pem' "${DOMAIN}-tls.json" > "$CERT_DIR/privkey.pem"
# Set proper permissions
chmod 600 "$CERT_DIR"/*.pem
chown root:ssl-cert "$CERT_DIR"/*.pem
}
reload_services() {
log "Reloading web services"
systemctl reload nginx || log "Failed to reload nginx"
systemctl reload apache2 || log "Failed to reload apache2"
systemctl reload haproxy || log "Failed to reload haproxy"
}
cleanup() {
rm -f "${DOMAIN}-tls.json"
}
# Main execution
main() {
log "Starting certificate update for ${DOMAIN}"
create_backup
download_certificate
extract_certificate
reload_services
cleanup
log "Certificate update completed for ${DOMAIN}"
}
# Trap cleanup on exit
trap cleanup EXIT
# Run main function
main "$@"
Advanced Ansible Playbook:
---
- name: Manage SSL certificates with CertMate multi-account support
hosts: web_servers
vars:
certmate_url: "https://certmate.company.com"
certmate_token: "{{ vault_certmate_token }}"
tasks:
- name: Configure Cloudflare accounts
uri:
url: "{{ certmate_url }}/api/dns/cloudflare/accounts"
method: POST
headers:
Authorization: "Bearer {{ certmate_token }}"
Content-Type: "application/json"
body_format: json
body:
account_id: "{{ item.account_id }}"
config:
name: "{{ item.name }}"
description: "{{ item.description }}"
api_token: "{{ item.api_token }}"
loop:
- account_id: "production"
name: "Production Environment"
description: "Main production Cloudflare account"
api_token: "{{ vault_cloudflare_prod_token }}"
- account_id: "staging"
name: "Staging Environment"
description: "Development and testing account"
api_token: "{{ vault_cloudflare_staging_token }}"
- name: Create certificates with specific accounts
uri:
url: "{{ certmate_url }}/api/certificates/create"
method: POST
headers:
Authorization: "Bearer {{ certmate_token }}"
Content-Type: "application/json"
body_format: json
body:
domain: "{{ item.domain }}"
dns_provider: "{{ item.provider }}"
account_id: "{{ item.account_id }}"
loop:
- domain: "api.company.com"
provider: "cloudflare"
account_id: "production"
- domain: "staging.company.com"
provider: "cloudflare"
account_id: "staging"
- domain: "test.company.com"
provider: "route53"
account_id: "backup-aws"
- name: Download and deploy certificates
block:
- name: Download certificate bundle as JSON
uri:
url: "{{ certmate_url }}/api/certificates/{{ item }}/download?format=json"
headers:
Authorization: "Bearer {{ certmate_token }}"
return_content: yes
register: cert_bundle
- name: Write certificate files
copy:
dest: "/etc/ssl/certs/{{ item.0.item }}/{{ item.1.name }}"
content: "{{ item.0.json[item.1.key] }}"
owner: root
group: ssl-cert
mode: "{{ item.1.mode }}"
loop: "{{ cert_bundle.results | product(cert_files) | list }}"
vars:
cert_files:
- { name: "cert.pem", key: "cert_pem", mode: "0644" }
- { name: "chain.pem", key: "chain_pem", mode: "0644" }
- { name: "fullchain.pem", key: "fullchain_pem", mode: "0644" }
- { name: "privkey.pem", key: "private_key_pem", mode: "0600" }
loop:
- "api.company.com"
- "staging.company.com"
- "test.company.com"
Production-Ready Ansible Playbook:
---
- name: Enterprise SSL certificate management with CertMate
hosts: web_servers
become: yes
vars:
certmate_url: "https://certmate.company.com"
api_token: "{{ vault_certmate_token }}"
certificate_domains:
- name: "api.company.com"
dns_provider: "cloudflare"
nginx_sites: ["api"]
services_to_reload: ["nginx"]
- name: "web.company.com"
dns_provider: "route53"
nginx_sites: ["web", "admin"]
services_to_reload: ["nginx", "haproxy"]
tasks:
- name: Create certificate directories
file:
path: "/etc/ssl/certs/{{ item.name }}"
state: directory
owner: root
group: ssl-cert
mode: '0750'
loop: "{{ certificate_domains }}"
- name: Check certificate expiry
uri:
url: "{{ certmate_url }}/api/certificates/{{ item.name }}/deployment-status"
method: GET
headers:
Authorization: "Bearer {{ api_token }}"
register: cert_status
loop: "{{ certificate_domains }}"
- name: Create new certificates if needed
uri:
url: "{{ certmate_url }}/api/certificates/create"
method: POST
headers:
Authorization: "Bearer {{ api_token }}"
Content-Type: "application/json"
body_format: json
body:
domain: "{{ item.name }}"
dns_provider: "{{ item.dns_provider }}"
loop: "{{ certificate_domains }}"
when: cert_status.results[ansible_loop.index0].json.needs_renewal | default(false)
- name: Download certificates
uri:
url: "{{ certmate_url }}/api/certificates/{{ item.name }}/download?format=json"
method: GET
headers:
Authorization: "Bearer {{ api_token }}"
return_content: yes
register: cert_bundle
loop: "{{ certificate_domains }}"
- name: Write certificates
copy:
dest: "/etc/ssl/certs/{{ item.0.item.name }}/{{ item.1.name }}"
content: "{{ item.0.json[item.1.key] }}"
owner: root
group: ssl-cert
mode: "{{ item.1.mode }}"
loop: "{{ cert_bundle.results | product(cert_files) | list }}"
vars:
cert_files:
- { name: "cert.pem", key: "cert_pem", mode: "0644" }
- { name: "chain.pem", key: "chain_pem", mode: "0644" }
- { name: "fullchain.pem", key: "fullchain_pem", mode: "0644" }
- { name: "privkey.pem", key: "private_key_pem", mode: "0600" }
notify:
- reload nginx
- reload haproxy
- restart services
- name: Verify certificate installation
openssl_certificate:
path: "/etc/ssl/certs/{{ item.name }}/fullchain.pem"
provider: assertonly
has_expired: no
valid_in: 86400 # Valid for at least 1 day
loop: "{{ certificate_domains }}"
- name: Update nginx SSL configuration
template:
src: "nginx-ssl.conf.j2"
dest: "/etc/nginx/sites-available/{{ item.1 }}"
backup: yes
loop: "{{ certificate_domains | subelements('nginx_sites') }}"
notify: reload nginx
- name: Cleanup temporary files
file:
path: "/tmp/{{ item.name }}-tls.json"
state: absent
loop: "{{ certificate_domains }}"
handlers:
- name: reload nginx
systemd:
name: nginx
state: reloaded
- name: reload haproxy
systemd:
name: haproxy
state: reloaded
- name: restart services
systemd:
name: "{{ item }}"
state: restarted
loop: "{{ services_to_restart | default([]) }}"
Two versions appear in API responses and they answer different questions.
| what it is | when it moves | |
|---|---|---|
version (in /health, in the Swagger document) | the release number | every release |
api_contract_version (in /health), X-CertMate-API-Version (on every response) | the interface | only when this surface changes |
A client deciding whether it still works should read the second. The first moves on every patch whether or not anything a caller depends on moved with it.
Deprecating something bumps neither — that is the point of deprecating rather than removing.
An endpoint on its way out answers normally and says so in its headers, so a client can warn instead of failing:
Deprecation: @1757289600 # RFC 9745 — when it became deprecated
Sunset: Mon, 08 Mar 2027 00:00:00 GMT # RFC 8594 — earliest it may stop answering
Link: <https://…/docs/api.md#…>; rel="deprecation"
Nothing is deprecated today. The headers appear only on an endpoint that is.
The API validates request bodies against the models published in
/api/swagger.json. A missing required field or a wrong type is refused with
400 and a body naming the field, before the request reaches the application.
One consequence worth knowing: send a field or leave it out — do not send
null. Omitting an optional field means "use the default"; an explicit
null is a type error ("None is not of type 'integer'"), because null is
not an integer.
Values outside a documented enum are refused by the application rather than
by the schema, with a message naming the accepted set — for example
key_size must be one of [2048, 3072, 4096], got 1024.
| Variable | Required | Default | Description |
|---|---|---|---|
API_BEARER_TOKEN | auto-generated | Bearer token for API authentication | |
API_BEARER_TOKEN_FILE | - | Path to a file containing the API bearer token (takes precedence over API_BEARER_TOKEN) | |
SECRET_KEY | auto-generated | Flask secret key for sessions | |
SECRET_KEY_FILE | - | Path to a file containing the Flask secret key (takes precedence over SECRET_KEY). If set and unreadable or empty, CertMate refuses to start rather than inventing one: a secret that failed to mount is a configuration error, and a fresh key would sign out every user on every restart | |
PORT | 8000 | Server port (honoured by the container entrypoint) | |
FLASK_ENV | production | Flask environment. production refuses --debug | |
CERTMATE_LOG_FILE | - | Also write logs to this path. Off by default: the container logs to stdout, which is what docker logs and log shippers expect. Set it (e.g. /app/logs/certmate.log) to keep a file on the mounted volume — it is what the web UI's log stream reads | |
CERTMATE_LOG_MAX_BYTES | 10485760 | Rotate the log file at this size (10 MB). File logging is always rotated — there is no way to configure an unbounded one | |
CERTMATE_LOG_BACKUP_COUNT | 5 | How many rotated files to keep (~60 MB ceiling with the default size) | |
CERTMATE_AUDIT_LOG_MAX_BYTES | 10485760 | Rotate the human-readable audit log (logs/audit/certificate_audit.log) at this size. 0 disables rotation. Does not apply to the tamper-evident hash chain in data/audit/, which is never rotated | |
CERTMATE_AUDIT_LOG_BACKUP_COUNT | 5 | How many rotated audit logs to keep. Note the Activity page tails only the active file, so it shows fewer entries immediately after a roll | |
CERTMATE_LOG_LEVEL | INFO | DEBUG / INFO / WARNING / ERROR | |
CERTMATE_LOG_JSON | true | JSON log lines (set false for human-readable) |
| Variable | Required | Default | Description |
|---|---|---|---|
BEHIND_PROXY | false | Trust X-Forwarded-* from one reverse proxy hop (ProxyFix). Set it only when a proxy actually sits in front: with it on and no proxy, a client can forge its own address, which is what the login rate limit is keyed on | |
PREFERRED_URL_SCHEME | - | Set to https when TLS terminates in front of CertMate. It marks the session cookie Secure, so the browser stops sending it over plain HTTP | |
CORS_ORIGINS | - | Comma-separated origins allowed to call the API from a browser. Empty means the built-in default; this is not a way to open the API to everyone | |
ACME_CHALLENGES_DIR | <cwd>/data/acme-challenges | Where http-01 tokens are written and served from. Override it when the webroot is somewhere else; the hook, the pre-creation and the route all read this one value | |
LETSENCRYPT_EMAIL | - | ACME account email. Takes precedence over the value saved in the UI, so an instance that keeps changing its email back is usually this | |
CLOUDFLARE_TOKEN | - | Seeds the default Cloudflare DNS account on first start, so a container can be brought up already able to issue. Once settings.json exists the stored value is what is used |
| Variable | Required | Default | Description |
|---|---|---|---|
CERTMATE_ENABLE_HSTS | false | Send Strict-Transport-Security, and mark the session cookie Secure. Only set it when TLS really terminates in front — an HSTS header on a plain-HTTP instance locks browsers out of it | |
SESSION_TIMEOUT_HOURS | 8 | How long a login session stays valid. The server record and the browser cookie both use this value, so they cannot drift apart | |
CERTMATE_ALLOW_INTERNAL_WEBHOOKS | false | Allow notification webhooks to private, loopback and link-local addresses. Off by default because a webhook URL is operator-supplied and an SSRF into the host network is the obvious abuse | |
CERTMATE_PROBE_ALLOW_PRIVATE | false | Same relaxation for the certificate probe behind POST /api/probe (discovery sweeps ignore it and use monitored_endpoints.allow_private from settings): allow it to connect to private addresses. Needed to probe a service on the same host, and it removes an SSRF guard | |
CERTMATE_AUDIT_CHAIN | 1 | Set 0 to stop writing the tamper-evident audit hash chain. A kill switch, not a tuning knob: with it off, /api/audit/verify can no longer prove the log was not edited | |
AUDIT_SIGNING_KEY_FILE | - | Path to an Ed25519 private key that signs audit checkpoints, so the key can live off this box. If set and unreadable, signing is DISABLED rather than a new key generated — a fresh key would fork the instance's identity and make earlier signatures unverifiable | |
CERTMATE_ALLOW_SCHEMA_DOWNGRADE | 0 | Set 1 to let this build read and overwrite a settings.json or metadata.json written by a NEWER version. It will drop fields it does not understand — that is the whole reason it refuses by default |
| Variable | Required | Default | Description |
|---|---|---|---|
CERTMATE_DOMAIN_LOCK_TIMEOUT | 5 | Seconds to wait for a domain's lock before answering 409 "operation in progress". Clamped to 0-60 | |
CERTMATE_CERT_INFO_CACHE_TTL | 60 | Seconds a parsed certificate's details stay cached. 0 re-reads from disk on every request. Clamped to 0-3600 | |
CERTMATE_ISSUANCE_WORKERS | 2 | Threads serving asynchronous issuance jobs. Clamped to 1-16; each one can be running a certbot subprocess | |
CERTMATE_CERT_DIR | <install>/certificates | Where issued certificates live. Absolute, or relative to the working directory. The local storage backend follows it too, unless certificate_storage.cert_dir names a directory of its own | |
CERTMATE_DATA_DIR | <install>/data | Settings, the certificate inventory and the audit chain | |
CERTMATE_BACKUP_DIR | <install>/backups | Where backups are written and restored from | |
CERTMATE_LOGS_DIR | <install>/logs | Application and audit logs | |
CERTMATE_ISSUANCE_QUEUE_LIMIT | 20 | How much unfinished issuance may exist at once, counting queued and running jobs. Clamped to 1-500. Beyond it the async endpoints answer 429 ISSUANCE_QUEUE_FULL instead of accepting work that will not be reached for hours | |
CERTMATE_ISSUANCE_JOB_HISTORY | 200 | How many finished issuance jobs stay queryable via /api/certificates/jobs. Clamped to 20-2000 | |
CERTMATE_EVENT_WORKERS | 4 | Threads dispatching event listeners (deploy hooks, cache invalidation). Clamped to 1-32. Nothing is dropped when they are busy; the backlog is logged instead | |
CERTMATE_EVENT_MAX_STREAMS | 4 | Live /api/web/events/stream connections held at once. Each one occupies a gunicorn thread and the image runs --workers 1 --threads 8, so this leaves half the pool for ordinary requests. Past it the route answers 503 with Retry-After. Clamped to 1-64 | |
CERTMATE_EVENT_STREAM_SECONDS | 900 | How long one event stream is held before it is closed and the browser reconnects. EventSource reconnects on its own, so this is invisible — it bounds what a forgotten tab can hold. Clamped to 30-86400 | |
CERTMATE_EVENT_DRAIN_SECONDS | 5 | How long a shutdown waits for queued event dispatches to start before giving up on them. Clamped to 0-60. Whatever is left is logged with its event and domain, so a deploy hook that never ran after a renewal is named rather than lost | |
CERTMATE_CERTBOT_PROBE_TTL | 300 | Seconds before the certbot readiness answer is re-checked. Clamped to 30-3600. The probe used to run once per process, so a transient failure at boot made the instance permanently unready and a certbot that broke later never turned /health/ready red | |
CERTMATE_PROBE_TIMEOUT_SECONDS | 5 | Connection timeout of the certificate probe used by discovery sweeps and POST /api/probe. Clamped to 1-30. The deployment check has its own, CERTMATE_TLS_PROBE_TIMEOUT_SECONDS | |
CERTMATE_LAST_USED_PERSIST_SECONDS | 60 | How often a session's "last used" timestamp is written to disk. 0 writes on every request, which is the original behaviour and one write per request | |
CERTMATE_SLOW_REQUEST_LOGGING | true | Log a warning, with the thread's stack, for requests that outlive the threshold below. The stack is what makes a hung request diagnosable after the fact | |
CERTMATE_SLOW_REQUEST_THRESHOLD_SECONDS | 30 | How long a request must run before it is reported | |
CERTMATE_SLOW_REQUEST_SCAN_SECONDS | 10 | How often the watchdog looks for requests that are still running | |
CERTMATE_SLOW_REQUEST_REPEAT_SECONDS | threshold | How often a still-running request is reported again |
Bind address is not an environment variable. The container always binds
0.0.0.0inside its own network namespace; to expose it only on loopback, publish it that way —-p 127.0.0.1:8000:8000. Runningapp.pydirectly (development only) takes--host/--port/--debugas CLI flags.HOSTandFLASK_DEBUGare read by nothing, and setting them has never had any effect (#429).
DNS:Edit + Zone:Read# Environment variable
CLOUDFLARE_TOKEN=your_cloudflare_api_token_here
Route53FullAccess or custom policy:{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["route53:ListHostedZones",
"route53:GetChange",
"route53:ChangeResourceRecordSets"
],
"Resource": "*"
}
]
}
# Environment variables
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AWS_DEFAULT_REGION=us-east-1
az ad sp create-for-rbac --name "CertMate" --role "DNS Zone Contributor" --scopes "/subscriptions/{subscription-id}/resourceGroups/{resource-group}"
# Environment variables
AZURE_SUBSCRIPTION_ID=your_subscription_id
AZURE_RESOURCE_GROUP=your_resource_group_name
AZURE_TENANT_ID=your_tenant_id
AZURE_CLIENT_ID=your_client_id
AZURE_CLIENT_SECRET=your_client_secret
# Environment variables
GOOGLE_PROJECT_ID=your_project_id
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
# Environment variables
POWERDNS_API_URL=https://your-powerdns-server:8081
POWERDNS_API_KEY=your_api_key
CertMate supports multiple storage backends for certificates, providing flexibility for different deployment scenarios and security requirements. By default, certificates are stored locally on the filesystem, but you can configure enterprise-grade storage backends for enhanced security and compliance.
Choosing the Right Storage Backend:
- Local Filesystem: Perfect for development, testing, and small deployments
- Azure Key Vault: Best for Azure-native environments and Microsoft ecosystem integration
- AWS Secrets Manager: Ideal for AWS infrastructure and cross-region deployments
- HashiCorp Vault: Excellent for multi-cloud environments and advanced secret management
- Infisical: Great for teams wanting open-source secret management with collaboration features
- S3-compatible object storage: One bucket on any S3 endpoint (Hetzner, Contabo, OVHcloud, Scaleway, Exoscale, Wasabi, MinIO, AWS)
The default storage backend stores certificates in the local filesystem with secure permissions:
# Default certificate directory
certificates/
example.com/
cert.pem # Server certificate
chain.pem # Certificate chain
fullchain.pem # Full chain
privkey.pem # Private key (600 permissions)
metadata.json # Certificate metadata (600 permissions)
Configuration:
certificates (configurable)600 for private keys, 644 for certificatesBenefits:
Store certificates securely in Azure Key Vault for enterprise-grade secret management:
Required Dependencies:
pip install -r requirements-azure-storage.txt
Configuration:
{
"certificate_storage": {
"backend": "azure_keyvault",
"azure_keyvault": {
"vault_url": "https://yourvault.vault.azure.net/",
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_secret": "your_client_secret"
}
}
}
Benefits:
Use Cases:
Integrate with AWS Secrets Manager for scalable secret storage:
Required Dependencies:
pip install -r requirements-aws-storage.txt
Configuration:
{
"certificate_storage": {
"backend": "aws_secrets_manager",
"aws_secrets_manager": {
"region": "us-east-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
}
}
For IAM authentication, choose AWS credentials / IAM role (no keys) in
Settings → Storage, enter the region and leave access keys empty. CertMate uses
the AWS credential chain (for example an EC2 instance profile, ECS task role or
EKS pod identity). An optional Assume role ARN uses sts:AssumeRole and
refreshes the temporary credentials automatically. Existing key-based settings
still work. If auth_mode is absent in a manual configuration, access keys
remain required. Equivalent settings without an additional assumed role:
{"certificate_storage":{"backend":"aws_secrets_manager","aws_secrets_manager":{"region":"eu-west-1","auth_mode":"iam_role"}}}
The IAM identity needs secretsmanager:ListSecrets (account-wide, resource
*) and GetSecretValue, DescribeSecret, CreateSecret, UpdateSecret
and DeleteSecret for the certmate/certificates/* secrets. Assuming a
second role also requires permission to call
sts:AssumeRole and a trust policy on the target role. The connection test
reports AWS permission errors rather than treating them as an empty store.
Benefits:
Use Cases:
Use industry-standard HashiCorp Vault for advanced secret management:
Required Dependencies:
pip install -r requirements-vault-storage.txt
Configuration:
{
"certificate_storage": {
"backend": "hashicorp_vault",
"hashicorp_vault": {
"vault_url": "https://vault.example.com:8200",
"vault_token": "hvs.xxxxxxxxxxxxxxxxxxxx",
"mount_point": "secret",
"engine_version": "v2"
}
}
}
Benefits:
Use Cases:
Modern open-source secret management with team collaboration:
Required Dependencies:
pip install -r requirements-infisical-storage.txt
Configuration:
{
"certificate_storage": {
"backend": "infisical",
"infisical": {
"site_url": "https://app.infisical.com",
"client_id": "your_client_id",
"client_secret": "your_client_secret",
"project_id": "your_project_id",
"environment": "prod"
}
}
}
site_url must be https:// (plain http:// is accepted only for a loopback address: localhost, 127.0.0.1 or ::1). The Infisical
SDK follows a redirect and sends the request body on, and here the body is the certificate and its
private key; its HTTP client cannot be configured from CertMate, so the one thing CertMate can
refuse is the position where anyone on the path could answer with a redirect. Point it at a server
you trust with the key, which is what storing it there means anyway.
This backend did not work in v2.44.0 or earlier. It could not run at all: it imported a module
name the pinned infisical-python does not have, and called methods that SDK does not have.
Benefits:
Use Cases:
One backend for S3-compatible object storage: Hetzner, Contabo, OVHcloud, Scaleway, Exoscale, Wasabi, self-hosted MinIO, or AWS S3 itself. Each domain is stored as one JSON object, <prefix>/<domain>.json, holding the certificate files and their metadata.
Required Dependencies: boto3, already in requirements.txt and requirements-storage-all.txt (not in requirements-minimal.txt).
Configuration:
{
"certificate_storage": {
"backend": "s3_compatible",
"s3_compatible": {
"endpoint_url": "https://fsn1.your-objectstorage.com",
"bucket": "certmate",
"access_key_id": "your_access_key_id",
"secret_access_key": "your_secret_access_key",
"region": "us-east-1",
"prefix": "certmate/certificates"
}
}
}
For S3-compatible services, use Access keys and provide both keys, the bucket and the service's endpoint. Existing key-based configurations continue to work unchanged. region defaults to us-east-1 and prefix to certmate/certificates.
For AWS S3 with an IAM role, select AWS credentials / IAM role (no keys) in Settings → Storage, provide the bucket and region, and leave the endpoint URL and access keys empty. Boto3 uses its standard credential chain (for example an EC2 instance profile, ECS task role or EKS pod identity). You can optionally enter an Assume role ARN (arn:aws:iam::123456789012:role/CertMateS3): the current identity must have sts:AssumeRole permission and the destination role must trust it. CertMate refreshes the temporary STS credentials automatically. The role needs s3:ListBucket on the bucket and s3:GetObject, s3:PutObject, and s3:DeleteObject on <prefix>/* (including object existence checks). The Test Storage Backend action lists the prefix and reports authentication/permission failures.
For manual settings.json, explicitly set "auth_mode":"iam_role" to use AWS credentials without keys or an endpoint: {"backend":"s3_compatible","s3_compatible":{"bucket":"certmate","region":"eu-west-1","auth_mode":"iam_role","assume_role_arn":"arn:aws:iam::123456789012:role/CertMateS3"}}. Omit assume_role_arn to use the attached role directly. Without auth_mode, legacy key-pair requirements still apply. The optional off-site S3 backup storage has separate credential settings; this option configures certificate storage only.
Install All Storage Backends:
# Install all storage backends at once
pip install -r requirements-storage-all.txt
Install Individual Storage Backends:
# Azure Key Vault only
pip install -r requirements-azure-storage.txt
# AWS Secrets Manager only
pip install -r requirements-aws-storage.txt
# HashiCorp Vault only
pip install -r requirements-vault-storage.txt
# Infisical only
pip install -r requirements-infisical-storage.txt
# S3-compatible storage: no separate file — boto3 is in requirements.txt
Requirements File Overview:
requirements-storage-all.txt - All storage backends (recommended for production)requirements-azure-storage.txt - Azure Key Vault dependenciesrequirements-aws-storage.txt - AWS Secrets Manager dependenciesrequirements-vault-storage.txt - HashiCorp Vault dependenciesrequirements-infisical-storage.txt - Infisical dependenciesrequirements-minimal.txt - Base CertMate without storage backendsVia Web Interface:
Via API:
# Test storage backend connectivity before switching
curl -X POST "http://localhost:8000/api/storage/test" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"backend": "azure_keyvault",
"config": {
"vault_url": "https://yourvault.vault.azure.net/",
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_secret": "your_client_secret"
}
}'
# Get current storage backend information
curl -X GET "http://localhost:8000/api/storage/info" \
-H "Authorization: Bearer your_token"
# Update storage backend configuration. Unlike /api/storage/test, the settings
# go under a key named after the backend, not under "config" — a "config" key
# here is ignored and the backend is saved with no credentials.
curl -X POST "http://localhost:8000/api/storage/config" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"backend": "hashicorp_vault",
"hashicorp_vault": {
"vault_url": "https://vault.example.com:8200",
"vault_token": "hvs.xxxxxxxxxxxxxxxxxxxx",
"mount_point": "secret",
"engine_version": "v2"
}
}'
Migrating Between Backends:
Migration copies every certificate from a source backend to a target backend,
one domain at a time. When no source_backend is given, the source is the
backend currently saved in settings, so migrate before switching:
POST /api/storage/test)POST /api/storage/migrate, or the migration tool in Settings)migrated_count, failed_count, and migration_results, one true/false per domainPOST /api/storage/config)If you already saved the new backend, choose Local filesystem (or the
previously configured backend) as Source backend in the Settings migration
dialog. The dialog always requires an explicit source choice: when migrating
from bucket A to bucket B within S3, choose the saved S3 backend as source,
even though S3 is also selected as the target. The target remains the backend
selected in Settings. Via the API,
include "source_backend": "local_filesystem" in the migration request.
A zero-domain result means no certificates were found in that source; check
the source selection and certificate directory rather than assuming the
certificates were copied.
Migration via API:
# Migrate all certificates from current backend to new backend
curl -X POST "http://localhost:8000/api/storage/migrate" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"target_backend": "aws_secrets_manager",
"target_config": {
"region": "us-east-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
}'
Each certificate's metadata is copied along with its files. There is no
separate verification pass and no rollback: a domain whose copy failed is
reported as false in migration_results, and the source is left untouched,
so re-running the migration or staying on the old backend are both safe.
Backward Compatibility:
certmate/
app.py # Main Flask application
requirements.txt # Python dependencies
docker-compose.yml # Docker Compose configuration
Dockerfile # Container build instructions
nginx.conf.example # Nginx reverse proxy config template (copy to nginx.conf)
.env.example # Environment template
README.md # This documentation
CONTRIBUTING.md # Contribution guidelines
docs/ # Comprehensive documentation
certificates/ # Certificate storage
{domain}/
cert.pem # Server certificate
chain.pem # Certificate chain
fullchain.pem # Full chain
privkey.pem # Private key
data/ # Application data
settings.json # Persistent settings
logs/ # Application logs
letsencrypt/ # Let's Encrypt working directory
config/ # Certbot configuration
work/ # Certbot working files
logs/ # Certbot logs
modules/ # Modular backend
core/ # Core modules
deployer.py # Deploy hooks manager
notifier.py # Notification channels
events.py # SSE event bus
digest.py # Weekly digest
audit.py # Audit logger
shell.py # Shell executor
web/ # Web routes
api/ # API resources
templates/ # Web interface templates
index.html # Main dashboard
settings.html # Settings page
activity.html # Activity timeline
help.html # Help & documentation
static/js/ # Frontend modules
dashboard.js # Dashboard logic
settings.js # Settings components
cmd-palette.js # Cmd+K palette
shortcuts.js # Keyboard shortcuts
allowed_domains): Restrict a scoped API key to a list of domain patterns. Supports exact (example.com) and wildcard (*.example.com) forms; the wildcard matches subdomains only, not the apex. An operation on an existing certificate needs every name the certificate covers to be in scope, as creating it does. Client certificates, backups and the inventory configuration belong to the instance, not to a domain, so a key restricted this way cannot use those routes; /metrics answers it with the series about its own domains only. An admin key cannot be restricted to domains: creating one is refused, and one stored by an earlier release acts as an operator within its domains. Out-of-scope requests return 403 DOMAIN_OUT_OF_SCOPE and are recorded in the audit log. Leave the field empty for unrestricted access (legacy behavior).POST /api/settings: only documented configuration keys are accepted. Sensitive fields (api_bearer_token, deploy_hooks, users, api_keys, local_auth_enabled) cannot be written through the generic settings endpoint — each has its own dedicated endpoint with its own audit:
POST /api/usersPOST /api/keysPOST /api/deploy/configPOST /api/auth/config (admin-only)
Unknown or rejected keys are returned in a 400 response with a hint field pointing at the correct endpoint.data/audit/certificate_audit.log as one JSON object per line — pipeable into your SIEM of choice.Until the first operator credential exists — a local admin with local auth enabled, API_BEARER_TOKEN, or OIDC — every request is served as admin to anyone who can reach the instance. So during setup CertMate only bootstraps: it creates the first admin and turns login on. API keys and further users are refused with 409 SETUP_BOOTSTRAP_ONLY until setup is complete, because anything created in that window would be created by whoever was there and would outlive it. API keys that an older version let be created during setup stay valid but are flagged in Settings → API Keys and in the startup log, until an operator confirms or revokes each one.
600 permissions700 permissionsCertMate stores DNS provider credentials, ACME account keys, and (legacy) bearer tokens inside data/settings.json. The bearer token itself is migrated to an HMAC-SHA256 hash on first save, but DNS provider credentials remain in the file in their original form so they can be passed to certbot plugins. The file is created with 0600 permissions, which is the first line of defense. Recommended hardening, in order of effort:
certificate_storage.backend at HashiCorp Vault, Infisical, AWS Secrets Manager, or Azure Key Vault. Issued certificates are stored there transparently, keeping the bearer token + DNS credentials in settings.json as the only on-disk secret surface.data/ directory on a LUKS-encrypted partition (Linux), an encrypted APFS volume (macOS), or a Kubernetes Secret mounted in a tmpfs. This protects credentials at rest even if the host disk is removed or imaged.data/ owned by that user and 0700 mode. Verify with ls -la data/ — only the CertMate process user should be able to read the file.data/ from untrusted sources in Docker. Use a named volume managed by Docker, or a CSI-provisioned volume in Kubernetes, rather than a bind from a multi-tenant host.CertMate does not currently encrypt secrets at the application layer. If your threat model requires that the same operator who can read
settings.jsonshould still not be able to read DNS credentials, use option (1) above and configure the per-provider credentials in the external secret backend rather than in CertMate's settings.
# Example firewall rules (iptables)
# Allow only specific IPs to access CertMate
iptables -A INPUT -p tcp --dport 8000 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -s 192.168.0.0/16 -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -j DROP
The rules above confine inbound access. You can also confine outbound traffic: route CertMate's HTTP(S) egress through a forward proxy and deny it any other route to the internet, so it can reach only your CA, DNS provider, object storage, and notification endpoints. With Secure Proxy Manager v3.9.0+ this is a built-in default-deny egress allowlist. See Confining outbound traffic for a worked example.
# docker-compose.prod.yml
version: '3.8'
services:
certmate:
image: certmate:latest
deploy:
# CertMate runs an in-process renewal scheduler and per-domain locks, so
# it must run as a SINGLE writer. Multiple replicas (or >1 gunicorn
# worker) each fire the renewal check and would issue duplicate ACME
# orders and hit the CA's duplicate-certificate rate limit. A host-local
# flock guards multiple workers/containers on a *shared* data volume, but
# the safe default is one writer. For HA, front a single active instance
# rather than scaling this service.
replicas: 1
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
environment:
- FLASK_ENV=production
# The worker and thread counts are fixed in the image
# (`--workers 1 --threads 8`): one worker because the scheduler runs
# in-process and a second would duplicate every renewal. GUNICORN_TIMEOUT
# is the one that is read.
- GUNICORN_TIMEOUT=300 # the image default; raise it for slow DNS providers
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
upstream certmate_backend {
server certmate1:8000;
server certmate2:8000;
server certmate3:8000;
}
server {
listen 443 ssl http2;
server_name certmate.company.com;
ssl_certificate /etc/ssl/certs/certmate.company.com/fullchain.pem;
ssl_certificate_key /etc/ssl/certs/certmate.company.com/privkey.pem;
location / {
proxy_pass http://certmate_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# API rate limiting
limit_req zone=api burst=10 nodelay;
}
}
CertMate provides comprehensive backup and recovery capabilities built directly into the application, ensuring your certificates and configuration data are always protected.
What is Unified Backup?
include_secrets=true) contains everything needed for complete restoration; the default share-safe archive contains certificates, chains, metadata, the audit chain and the inventory — no credentials and no private keysTwo kinds of archive:
privkey.pem, no ACME account key, no private-CA key, no .pfx. Certificates, chains, metadata, the audit chain and the inventory are all there. The manifest says so (secrets_masked, key_material_excluded). Such an archive cannot restore an instance on its own: GET /api/backups reports it with can_restore: false and Settings disables its Restore button. The restore API does not read can_restore. It refuses a share-safe archive when this instance already holds certificates; otherwise it restores it, keeping the credentials already in this instance's settings.json, or — on an instance with no settings.json — leaving every credential masked, to be re-entered before the next renewal.CERTMATE_BACKUP_PASSPHRASE is set. This is the archive to keep off-site.Which one you get automatically depends on one thing: whether a backup passphrase is
configured. With CERTMATE_BACKUP_PASSPHRASE set, automatic backups are disaster-recovery
archives, encrypted at rest. Without it they stay share-safe, because a complete archive
that cannot be encrypted would be a plaintext credential dump written to disk on every
settings change. Without a passphrase, the instance logs a notice once per process, at the first automatic backup it takes.
CertMate never generates or stores that passphrase: keeping it beside the archive it protects would make the encryption meaningless. It is yours to set and to keep.
POST /api/backups/create with {"include_secrets": true} (admin, audited) still takes a
disaster-recovery archive on demand, passphrase or not — a deliberate opt-in.
Getting a backup back onto a new instance: POST /api/backups/upload (admin,
multipart/form-data, field file), or the Restore From Elsewhere control in Settings.
Uploading only stores the archive under a name CertMate generates — the name you send is
discarded. Restoring it is a separate, explicit step, so an upload is never destructive on
its own.
Automatic Backups:
CERTMATE_BACKUP_PASSPHRASE is set, share-safe otherwise (see above)MAX_BACKUPS_PER_TYPE), and any archive older than 30 days is deleted regardless of how few there are (BACKUP_RETENTION_DAYS). Both are constants, not settings. This is why a disaster-recovery archive must be kept off the host: one left in backups/unified/ is deleted after 30 days like any other. Logging in no longer writes a backup, so routine sign-ins do not consume restore pointsManual Backups:
Access backup features from the Settings page:
<!-- Create backup -->
<button onclick="createBackup('unified', this)">Create Backup</button>
<!-- View and manage existing backups -->
- Download backups for external storage
- Restore from any backup point with atomic consistency
- View backup contents and metadata
- Delete specific backups manually
Create Backup:
# Create backup (settings + certificates); "type" is required
curl -X POST "http://localhost:8000/api/backups/create" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{"type": "unified", "reason": "manual_backup"}'
# Response (201)
{
"message": "Backup created successfully",
"backups": [
{"type": "unified", "filename": "backup_20241225_120000_123456_manual_backup.zip"}
],
"secrets_masked": true,
"recommendation": null
}
Archives are named backup_<YYYYmmdd_HHMMSS_ffffff>_<reason>.zip (UTC timestamp), or
.zip.enc when CERTMATE_BACKUP_PASSPHRASE is set.
List and Download Backups:
# List all backups
curl -H "Authorization: Bearer your_token" \
"http://localhost:8000/api/backups"
# Download backup
curl -H "Authorization: Bearer your_token" \
"http://localhost:8000/api/backups/download/unified/backup_20241225_120000_123456_manual_backup.zip" \
-o backup.zip
Backup File (ZIP):
backup_20260821_120000_000000_manual.zip
backup_metadata.json # manifest: secrets_masked, key_material_excluded, key_files_excluded, encrypted
settings.json # application settings — credentials masked in a share-safe archive, plaintext in a DR one
certificates/
domain1.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem # DR archive only (include_secrets=true); absent from a share-safe archive
metadata.json
live/ archive/ renewal/ accounts/ # certbot lineage (keys under accounts/ and live/ DR only)
data/
certs/ca/ca.crt # ca.key: DR only
certs/client/... # client certificates (.key/.pfx: DR only)
audit/ inventory/
Backup Restoration:
Web Interface:
API Restoration:
# Restore from backup
curl -X POST "http://localhost:8000/api/backups/restore/unified" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{"filename": "backup_20241225_120000_123456_manual_backup.zip", "create_backup_before_restore": true}'
For additional protection, integrate with external backup systems:
Automated External Backup Script:
#!/bin/bash
# /opt/scripts/backup-certmate-external.sh
BACKUP_DIR="/backup/certmate/$(date +%Y%m%d_%H%M%S)"
CERT_DIR="/opt/certmate/certificates"
DATA_DIR="/opt/certmate/data"
RETENTION_DAYS=30
# Create backup directory
mkdir -p "$BACKUP_DIR"
# Download the newest backup that can restore (there is no "latest" alias;
# the list is ordered oldest to newest by name)
LATEST=$(curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/backups \
| jq -r '[.unified[] | select(.can_restore)] | last | .filename // empty')
[ -n "$LATEST" ] || { echo "No restorable backup on this instance" >&2; exit 1; }
curl -H "Authorization: Bearer $API_TOKEN" \
"http://localhost:8000/api/backups/download/unified/$LATEST" \
-o "$BACKUP_DIR/$LATEST"
# Backup certificates directory
tar -czf "$BACKUP_DIR/certificates.tar.gz" "$CERT_DIR"
# Backup application data
tar -czf "$BACKUP_DIR/data.tar.gz" "$DATA_DIR"
# Encrypt backups (optional)
gpg --cipher-algo AES256 --compress-algo 1 --symmetric \
--output "$BACKUP_DIR/$LATEST.gpg" \
"$BACKUP_DIR/$LATEST"
# Cleanup old backups
find /backup/certmate -type d -mtime +$RETENTION_DAYS -exec rm -rf {} \;
echo "External backup completed: $BACKUP_DIR"
#!/bin/bash
# Recovery from backup
BACKUP_DATE="20241225_120000"
BACKUP_DIR="/backup/certmate/$BACKUP_DATE"
# Stop services
docker-compose down
# Restore certificates
tar -xzf "$BACKUP_DIR/certificates.tar.gz" -C /opt/certmate/
# Restore data
tar -xzf "$BACKUP_DIR/data.tar.gz" -C /opt/certmate/
# Set permissions
chown -R 1000:1000 /opt/certmate/certificates
chmod -R 700 /opt/certmate/certificates
# Start services
docker-compose up -d
echo "Recovery completed from backup: $BACKUP_DATE"
# Basic health check
curl -f http://localhost:8000/health
# Detailed health with auth
curl -H "Authorization: Bearer your_token" \
http://localhost:8000/api/certificates
Nothing to add: /metrics is already a registered route
(modules/web/misc_routes.py), and the metrics
themselves are declared in
modules/core/metrics.py. This section used to
show a snippet headed "Add to app.py", which would have registered a second,
unauthenticated /metrics on top of the real one.
A ready-to-import Grafana dashboard, Prometheus alert rules, and an
authenticated scrape config ship in monitoring/ — see
monitoring/README.md. The /metrics endpoint requires
the viewer role, so scrape it with a viewer-scoped API token (Bearer) — a
scraper reads, so it does not need admin.
# docker-compose.logging.yml
version: '3.8'
services:
certmate:
logging:
driver: "fluentd"
options:
fluentd-address: localhost:24224
tag: certmate
fluentd-async-connect: "true"
fluentd:
image: fluent/fluentd:v1.14
volumes:
- ./fluentd/conf:/fluentd/etc
- ./logs:/var/log/fluentd
ports:
- "24224:24224"
- "24224:24224/udp"
{
"dashboard": {
"title": "CertMate SSL Certificate Monitoring",
"panels": [{
"title": "Certificate Expiry Status",
"targets": [{
"expr": "certmate_certificate_expiry_days < 30",
"legendFormat": "Expiring Soon ({{domain}})"
}
],
"alert": {
"conditions": [{
"query": {"queryType": "", "refId": "A"},
"reducer": {"type": "last", "params": []},
"evaluator": {"params": [30], "type": "lt"}
}
],
"executionErrorState": "alerting",
"frequency": "1h",
"handler": 1,
"name": "Certificate Expiring",
"noDataState": "no_data",
"notifications": []
}
}
]
}
}
CertMate includes a built-in notification system configurable from Settings > Notifications:
{{placeholders}} so the body fits whatever receives it — see docs/webhooks.mdAll notification channels support per-event filtering (created, renewed, expiring, failed) and can be tested from the settings UI.
Microsoft Teams: no dedicated adapter is needed. Use a Teams channel's built-in email address (channel → ... → Get email address) as a recipient on the Email (SMTP) channel — Teams posts the message into the channel for you.
Downgrading to a version older than the one that wrote settings.json is not supported and may result in a broken configuration or loss of accounts. If you see a DOWNGRADE DETECTED message in the logs after rolling back, restore the most recent backup that can actually restore.
Not every backup is a restore point. Unless
CERTMATE_BACKUP_PASSPHRASEwas set when the backup was taken, automatic backups are written with secrets masked and cannot restore this instance — they are configuration snapshots. On an instance that has never had a passphrase set, the newest archive is almost certainly one of those, so picking "the latest" by timestamp picks one that will be refused. Ask the API which ones qualify: it reportscan_restoreper archive, andrestore_blocked_reasonwhen it is false.
# Which backups can actually restore, newest first
curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/backups \
| jq -r '.unified[] | "\(.filename) can_restore=\(.can_restore) \(.restore_blocked_reason // "")"'
# Restore the newest one whose can_restore is true
curl -H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename": "backup_YYYYmmdd_HHMMSS_ffffff_<reason>.zip", "create_backup_before_restore": true}' \
http://localhost:8000/api/backups/restore/unified
If nothing reports can_restore: true, this instance has no restore point.
Set CERTMATE_BACKUP_PASSPHRASE, take a backup immediately, and keep a copy
off this node — see Backup and Recovery.
If you have lost the admin password and cannot log in, use the emergency reset script (requires container shell access):
docker exec -it certmate python scripts/reset_admin_password.py
Issue: Certificate creation failed: ... with a DNS-01 challenge error (the text after the prefix is certbot's own)
# Check DNS propagation
dig TXT _acme-challenge.example.com @8.8.8.8
# Verify DNS provider credentials
curl -H "Authorization: Bearer cf_token" \
"https://api.cloudflare.com/client/v4/user/tokens/verify"
Issue: Rate limit exceeded
# Let's Encrypt rate limits:
# - 50 certificates per registered domain per week
# - 5 duplicate certificates per week
# - 300 new orders per account per 3 hours
# Check rate limit status
curl "https://crt.sh/?q=example.com&output=json" | jq length
Issue: Permission denied accessing certificate files
# Fix file permissions
sudo chown -R certmate:certmate /opt/certmate/certificates
sudo chmod -R 700 /opt/certmate/certificates
sudo chmod 600 /opt/certmate/certificates/*/privkey.pem
Issue: 401 Unauthorized
# Verify token format
curl -H "Authorization: Bearer your_token_here" \
http://localhost:8000/api/certificates
# The token cannot be read back: settings.json stores only an HMAC of it
# (api_bearer_token_hash). Compare against what you set in API_BEARER_TOKEN
# or API_BEARER_TOKEN_FILE.
Issue: lost or forgotten API token
# Set a new token and restart: at startup, the token from API_BEARER_TOKEN
# (or API_BEARER_TOKEN_FILE) replaces the stored one.
# (32+ characters; a malformed token is ignored and the old one stays.)
openssl rand -hex 32 # put the output in .env as API_BEARER_TOKEN=...
docker-compose up -d --force-recreate certmate
Issue: Container won't start
# Check logs
docker-compose logs certmate
# Verify environment variables
docker-compose config
# Check port conflicts
netstat -tulpn | grep :8000
Issue: Volume mount issues
# Fix volume permissions
sudo chown -R 1000:1000 ./certificates ./data ./logs
# Check volume mounts
docker inspect certmate | jq '.[0].Mounts'
Q: All my certificates show "Backend: Unreachable" even though they are issued, served, and downloadable. Is something broken?
No. The deployment-status badge is an optional health indicator — it does not affect issuance, renewal, or download. CertMate's own process opens a TLS connection to <domain>:<port> and compares the served certificate's fingerprint against the stored one. The badge only answers whether CertMate itself can reach the service and see the expected certificate.
Configuring the probe per certificate
By default the probe connects on port 443 with a direct TLS handshake (HTTPS). You can change the port and protocol per certificate via the API or the dashboard:
| Protocol | Port (default) | Use case |
|---|---|---|
https-tls | 443 | Standard HTTPS |
tls | 465 | SMTPS, IMAPS, or any direct TLS service |
smtp-starttls | 587 | SMTP with STARTTLS (port 25 also works) |
From the dashboard, open a certificate's detail panel and click Configure Probe, then enter the port and protocol.
From the API:
# Set probe to SMTP STARTTLS on port 587
curl -X PATCH https://certmate.example.com/api/certificates/example.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"deployment_port": 587, "deployment_protocol": "smtp-starttls"}'
# Reset to defaults (HTTPS on port 443)
curl -X PATCH https://certmate.example.com/api/certificates/example.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"deployment_port": null, "deployment_protocol": "https-tls"}'
The probe protocol defaults to the global timeout (3s) and can be tuned via the CERTMATE_TLS_PROBE_TIMEOUT_SECONDS environment variable.
Global timeout tuning:
# Accepts 1–30 seconds; default is 3
CERTMATE_TLS_PROBE_TIMEOUT_SECONDS=10
"Unreachable" for every certificate is common in Kubernetes/ingress setups where the CertMate pod cannot dial the public/ingress IP directly (split-horizon DNS, an egress NetworkPolicy, or TLS terminated by an ingress controller / load balancer). If the target is merely slow, raise the probe budget or configure the port/protocol to match your topology.
See docs/kubernetes.md for the Kubernetes-specific note. (Reference: #263.)
Cloudflare:
# Verify API token permissions
curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer your_token_here"
# Check zone access
curl -X GET "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer your_token_here"
AWS Route53:
# Test AWS credentials
aws sts get-caller-identity
# Check Route53 permissions
aws route53 list-hosted-zones
Azure DNS:
# Verify service principal
az login --service-principal \
-u $AZURE_CLIENT_ID \
-p $AZURE_CLIENT_SECRET \
--tenant $AZURE_TENANT_ID
# Check DNS zone access
az network dns zone list
Enable debug logging for troubleshooting:
# Docker / gunicorn: CERTMATE_LOG_LEVEL. The stock docker-compose.yml does not
# pass it through, so add it under the certmate service's environment: and
# recreate the container
- CERTMATE_LOG_LEVEL=DEBUG
# Development server: --log-level overrides CERTMATE_LOG_LEVEL (default INFO)
python app.py --debug --log-level DEBUG
# FLASK_ENV=production makes --debug refuse to start, by design
FLASK_ENV=development
CertMate ships an in-app bug reporter that produces an actionable issue with one click. When an action fails and you're signed in as admin, the error toast surfaces a Report this issue button. Clicking it:
GET /api/diagnostics/snapshot (admin-only) to collect sanitised operational state — CertMate version, Python version, OS, scheduler status, certificate count, DNS provider name, CA name, challenge type, storage backend, disk free, and the last 5 audit-log entries with all identifiers (resource_id, user, IP address, details, error) stripped.endpoint / status / code / message / hint).github.com/fabriziosalmi/certmate/issues/new?template=bug_report.md with the title pre-filled as [Bug] <status> <code> on <method> <endpoint>.Paste the clipboard contents into the issue body, edit if you want, submit. The flow is fully manual — nothing leaves the install without your explicit click, and you can read everything before submitting (it's right there in the textarea). If your browser blocks the clipboard write or pop-ups, the reporter falls back to a modal with the markdown in an editable textarea and a clickable GitHub link.
If the in-app reporter is unavailable (you're not signed in as admin, or you hit the bug before reaching the UI), please provide:
# Collect system information manually (equivalent of the in-app snapshot)
echo "=== CertMate Debug Info ==="
echo "Version: $(docker exec certmate python -c 'import modules; print(modules.__version__)')"
echo "Python: $(docker exec certmate python --version)"
echo "OS: $(docker exec certmate cat /etc/os-release | head -2)"
echo "Certbot: $(docker exec certmate certbot --version)"
echo "DNS Plugins: $(docker exec certmate pip list | grep certbot-dns)"
# DO NOT paste settings.json verbatim into a public issue — it contains
# credentials. The /api/diagnostics/snapshot endpoint returns a redacted
# subset; use it instead:
curl -sS -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/diagnostics/snapshot | jq .
Prefer watching? CertMate in 60 seconds is a video guide of 24 one-minute shorts, made from the documentation on certmate.org.
| Document | Description | Target Audience |
|---|---|---|
| README.md | Main documentation and quick start | All users |
| docs/installation.md | Installation and deployment | System administrators |
| docs/dns-providers.md | DNS provider setup | DevOps engineers |
| docs/custom-dns-script.md | Any DNS provider with no plugin, via your own hooks | DevOps engineers |
| docs/ca-providers.md | Certificate Authority configuration | Enterprise users |
| docs/docker.md | Docker and multi-platform builds | DevOps engineers |
| docs/testing.md | Testing framework and CI/CD | Developers |
| docs/architecture.md | System architecture | Developers |
| docs/api.md | Client certificates API reference | Developers |
| docs/mcp.md | MCP server for AI agents: tools, auth, audit attribution | Developers, SRE |
| docs/guide.md | Step-by-step guide for common tasks | All users |
| docs/discovery-inventory.md | Discovery, inventory, adopt, crypto readiness, domain registration + health | SRE, security |
| docs/deploy-hooks.md | Post-issuance deploy hooks | DevOps engineers |
| docs/csr-only-certificates.md | Issuing from a CSR when the key stays on the device | Appliance operators |
| docs/webhooks.md | Generic webhooks: payload templates, auth, signature verification | Integrators |
| docs/compliance.md | Audit chain, attribution, NIS2/eIDAS posture | Compliance, security |
| docs/kubernetes.md | Install with Helm, Argo CD or Flux; pod sizing, OOM troubleshooting | SRE |
| docs/probes.en.md | Deployment probe configuration | DevOps engineers |
| CONTRIBUTING.md | Development and contribution guide | Developers |
| CODE_OF_CONDUCT.md | Community guidelines | Contributors |
Check out our examples repository for:
We welcome contributions! Areas where we need help:
Running CertMate in production? I offer paid support, custom development, and security consulting — certificate/TLS automation, hardening, WAF, and cloud detection & alerting. Reach out: [email protected].
We love contributions! CertMate is an open-source project and we welcome:
# Fork and clone the repository
git clone https://github.com/fabriziosalmi/certmate.git
cd certmate
# Create the development environment (.venv, from requirements.lock + the test requirements)
make setup
source .venv/bin/activate
# Lint, security-scan and test: the checks CI gates on (`make help` lists the rest)
make check
# Start development server
python app.py
A bare pytest is not the way to run the suite: it includes the Playwright ui tests, which
need Docker. make test runs the selection CONTRIBUTING.md documents.
git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)This project is licensed under the MIT License - see the LICENSE file for details.
Made with ❤️ by Fabrizio Salmi
Star us on GitHub • Report Bug • Request Feature
348 followers · starred Jul 2025
366 followers · starred Jun 2025
271 followers · starred Jul 2025
8 followers · starred Jan 2026
Self-hosted certificate lifecycle management: issue, renew, discover, inventory and deploy TLS certificates across your infrastructure
See the code
CertMate is a self-hosted certificate lifecycle management platform: it issues and renews TLS certificates, discovers the ones you did not issue, keeps a single inventory of what exists across your estate — what is served where, who issued it, when it expires, which cryptography it uses — and deploys renewed certificates to where they are needed. It supports 29 DNS providers, runs its own private CA for internal names, keeps a tamper-evident audit trail of every operation, and exposes all of it through a REST API.

Quick Start • Video Guide • CLI • Documentation • Installation • DNS Providers • CA Providers • Storage Backends • Backup and Recovery • API Reference
CertMate is the open-source core of a small, focused toolset:
Enterprise / high-scale — multi-tenant, mTLS, white-label and NIS2-aligned deployments are available through CertMate-ng (source-available, BSL 1.1, EU-built). For access or a deployment discussion, email [email protected].
The whole certificate lifecycle from your terminal — pip install certmate-cli:

pip install certmate-cli
export CERTMATE_URL=https://certmate.example.com
export CERTMATE_TOKEN=... # omit on a fresh (setup-mode) instance
certmate health
certmate cert create app.example.com --dns cloudflare --wait # issue, block until live
certmate cert ls
certmate cert info app.example.com
certmate audit verify
certmate-cli is a thin layer over certmate-sdk (pip install certmate-sdk) — a small,
httpx-based Python client for the same REST API the web UI and MCP server drive. Both are
first-party, live in clients/, and are published to PyPI. The clip above is a real
issuance over DNS-01 (Let's Encrypt staging); see demo/.
CertMate ships a first-party Model Context Protocol server, so an assistant like Claude can
drive the same REST API the web UI and the CLI use — with the same auth and the same audit trail.
It lives in mcp/, is Node.js (>= 20), and exposes 16 tools: inventory and status
(certmate_list_certificates, certmate_get_certificate, certmate_diagnostics,
certmate_get_activity, …), lifecycle operations (certmate_create_certificate,
certmate_renew_certificate, certmate_get_job, certmate_set_auto_renew, …), and delivery
(certmate_download_certificate, certmate_deploy_certificate).
cd mcp && npm install
// claude_desktop_config.json — or any MCP-capable client
{
"mcpServers": {
"certmate": {
"command": "node",
"args": ["/path/to/certmate/mcp/index.js"],
"env": {
"CERTMATE_URL": "https://certmate.example.com",
"CERTMATE_TOKEN": "<a scoped API key with is_agent: true>"
}
}
}
}
Give it a scoped key, not your admin token. A key created with is_agent: true (a checkbox
under Settings → API Keys, or is_agent in POST /api/keys) makes every action the agent takes
land in the audit chain as actor.kind="agent" rather than being indistinguishable from a human
operator — which is the difference between an audit trail and a rumour. Scope the key to the
domains the agent is allowed to touch.
Full tool reference, attribution model and safety notes: docs/mcp.md.
CertMate solves the complexity of SSL certificate management in modern distributed architectures. Whether you're running a single application or managing certificates across multiple datacenters, CertMate provides:
CertMate runs as a single instance. Its renewal scheduler runs inside the web process, so a second replica is a second scheduler issuing against the same certificate store — duplicate ACME orders, and the CA's duplicate-certificate rate limit. The Helm chart refuses to render more than one replica rather than let that happen quietly.
This is a deliberate design choice, not a missing feature: one writer is what makes certificate state safe without a distributed lock service. It scales up (a single instance manages thousands of certificates across many datacenters), not out. For availability, run active/standby with the data volume on shared storage and fail over — see Availability and failover.
*.example.com and example.comrenews_at)certificate_expiring at 14/7/3/1 days (at the renewal threshold when auto-renew is off), and domain_expiring for the domain registration itself at 60/30/14/7/1 days. Each threshold is announced once per expiry date/api/certificates/{domain}/download)/docs/ and /redoc/CertMate supports a wide range of DNS providers through Let's Encrypt DNS-01 challenge via individual certbot plugins that provide reliable, well-tested DNS challenge support. The complete list is in the table below. Multi-account support is available for major providers, enabling enterprise-grade deployments with separate accounts for production, staging, and disaster recovery.
| Provider | Credentials Required | Multi-Account | Use Case | Availability |
|---|---|---|---|---|
| Cloudflare | API Token | Yes | Global CDN, Free tier available | Stable |
| AWS Route53 | Access Key, Secret Key | Yes | AWS infrastructure, Enterprise | Stable |
| Azure DNS | Service Principal credentials | Yes | Microsoft ecosystem | Stable |
| Google Cloud DNS | Service Account JSON | Yes | Google Cloud Platform | Stable |
| DigitalOcean | API Token | Yes | Cloud infrastructure | Stable |
| PowerDNS | API URL, API Key | Yes | Self-hosted, On-premises | Separate install |
| EfficientIP SOLIDserver | Host, API Credentials | Yes | Enterprise DDI / Smart Architecture | Stable |
| RFC2136 | Nameserver, TSIG Key/Secret | Yes | Standard DNS update protocol | Stable |
| Linode (Akamai Connected Cloud) | API Key | Single | Cloud hosting | Stable |
| Akamai Edge DNS | EdgeGrid (.edgerc) credentials | Single | Enterprise managed DNS | Stable |
| Gandi | API Token | Single | Domain registrar | Stable |
| OVH | API Credentials | Single | European hosting | Stable |
| Namecheap | Username, API Key | Single | Domain registrar | Unavailable |
| Vultr | API Key | Single | Global cloud infrastructure | Stable |
| DNS Made Easy | API Key, Secret Key | Single | Enterprise DNS management | Stable |
| NS1 | API Key | Single | Intelligent DNS platform | Stable |
| Hetzner (legacy DNS) | API Token | Single | European cloud hosting | Stable |
| Hetzner Cloud | API Token | Single | Hetzner Cloud DNS (hcloud) | Stable |
| Porkbun | API Key, Secret Key | Single | Domain registrar with DNS | Stable |
| GoDaddy | API Key, Secret | Single | Popular domain registrar | Stable |
| Hurricane Electric | Username, Password | Single | Free DNS hosting | Extended image |
| Dynu | API Token | Single | Dynamic DNS service | Extended image |
| ArvanCloud | API Key | Single | Iranian cloud provider | Stable |
| Infomaniak | API Token | Single | Swiss ISP & cloud provider | Stable |
| ACME-DNS | JSON Config | Single | Generic ACME-DNS server | Stable |
| Scaleway | API Token (secret key) | Single | European cloud (EU-sovereign) | Separate install |
| deSEC | API Token | Single | Free, non-profit DNSSEC DNS | Stable |
| DuckDNS | Token | Single | Free dynamic DNS | Stable |
| Custom Script | User-provided hook scripts | Single | Any provider via custom hooks | Stable |
What the availability column means. Until now it said Stable on all
twenty-nine rows, which is not a status — a column with one value cannot be
wrong about any particular provider, and four of them were wrong:
| Value | Meaning |
|---|---|
| Stable | The plugin is pinned in requirements.txt, so it is in the default image. |
| Extended image | Pinned in requirements-extended.txt. Build with --build-arg EXTRA_REQUIREMENTS=requirements-extended.txt. |
| Separate install | Not shipped in any image. pip install it yourself, in its own environment where noted. |
| Unavailable | No usable plugin exists for the supported stack. CertMate can be configured for it, but issuance will fail. |
Namecheap is the Unavailable one: the only release on PyPI is
certbot-dns-namecheap 1.0.0, an alpha targeting Python 2.7-3.8, incompatible
with certbot 2.x on Python 3.12 — as NamecheapStrategy's own docstring has
said all along. Use ACME-DNS or the custom-script hook for Namecheap domains.
Scaleway is alpha (0.0.7, declares Python 2.7 support) and PowerDNS pulls
dns-lexicon<=3.5.6, which conflicts with the rest of the extended set — hence
its own environment.
For supported providers, you can configure multiple accounts to enable:
Detailed Setup Instructions: See DNS Providers Guide for provider-specific configuration. Step-by-Step Installation: See Installation Guide for complete setup guide. Multi-Account Examples: See DNS Providers Guide for enterprise configuration examples.
Get CertMate running in under 5 minutes with Docker Compose. One file, the published image, nothing to clone or build.
docker compose)mkdir certmate && cd certmate
curl -fsSLO https://raw.githubusercontent.com/fabriziosalmi/certmate/main/deploy/docker-compose.yml
printf 'API_BEARER_TOKEN=%s\nSECRET_KEY=%s\nCERTMATE_BACKUP_PASSPHRASE=%s\n' \
"$(openssl rand -hex 32)" "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env
chmod 600 .env
.env now holds the three secrets CertMate needs: the API token (also asked once by the first-run screen to create the admin), the session signing key, and the backup passphrase. Optionally add CLOUDFLARE_TOKEN=... to bootstrap a Cloudflare DNS account; every other DNS provider is configured in the web UI. What each setting does, and how to upgrade: docs/docker.md.
Storage Backends: By default, certificates are stored locally. For enterprise deployments, you can configure Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, Infisical, or any S3-compatible object storage via the web interface after startup. See Storage Backends for details.
Backup Best Practices: CertMate includes a unified backup system that creates atomic snapshots of both settings and certificates. Set
CERTMATE_BACKUP_PASSPHRASE. With it, every automatic backup is complete and encrypted at rest, so it can actually restore this instance; without it, automatic backups keep their credentials masked and are configuration snapshots that cannot. The backup list marks which archives can restore, and Settings disables Restore on the ones that cannot. Then keep a copy off the host — archives on the host are pruned after 30 days, and a lost volume takes them with it.POST /api/backups/uploadbrings one back.
docker compose up -d
docker compose ps
docker compose logs -f certmate
CertMate listens on 127.0.0.1:8000 only. For remote access, put a reverse proxy in front and set BEHIND_PROXY=true in .env.
To build from source instead, clone the repository and use its root docker-compose.yml (development).
| Service | URL | Description |
|---|---|---|
| Web Dashboard | http://localhost:8000 | Main certificate management interface |
| API Documentation | http://localhost:8000/docs/ | Interactive Swagger/OpenAPI docs |
| Alternative API Docs | http://localhost:8000/redoc/ | ReDoc documentation |
| Health Check | http://localhost:8000/health | Service health monitoring |
Using the Web Interface:
example.com)Using the API:
curl -X POST "http://localhost:8000/api/certificates/create" \
-H "Authorization: Bearer your_api_token_here" \
-H "Content-Type: application/json" \
-d '{"domain": "example.com"}'
Choose the installation method that best fits your environment:
Isolated, reproducible, and the way CertMate is tested and released. Run one
container (see the single-instance note above); give it more CPU and memory
rather than more replicas. Published images cover two architectures: AMD64 (Intel/AMD) and ARM64 (Apple Silicon, ARM servers). ARM v7 (32-bit Raspberry Pi) is not published; build it yourself with docker buildx build --platform linux/arm/v7 -t YOUR_DOCKERHUB_USERNAME/certmate:armv7 --push . (see Docker).
The production Compose bundle is one file that runs the published image; the Quick Start above shows the commands. To build the image yourself instead, clone the repository and run docker compose up -d with its root docker-compose.yml, after preparing its bind-mounted directories as its comments describe.
Multi-Platform Support:
# Build for multiple architectures (ARM64 + AMD64); add --push to publish the result
docker buildx build --platform linux/amd64,linux/arm64 -t YOUR_DOCKERHUB_USERNAME/certmate:latest .
# Build and push to Docker Hub for all platforms
docker buildx build --platform linux/amd64,linux/arm64 -t YOUR_DOCKERHUB_USERNAME/certmate:latest --push .
# Use pre-built multi-platform image (bound to localhost; put it behind a
# reverse proxy and enable authentication before exposing it externally)
docker run --platform linux/arm64 -d --name certmate --env-file .env -p 127.0.0.1:8000:8000 fabriziosalmi/certmate:latest
Multi-Platform Guide: See Docker Guide for comprehensive multi-architecture setup instructions.
Ideal for development and testing environments.
# Create and activate virtual environment
python3 -m venv certmate-env
source certmate-env/bin/activate # On Windows: certmate-env\Scripts\activate
# Install dependencies
git clone https://github.com/fabriziosalmi/certmate.git
cd certmate
pip install -r requirements.txt
# Set environment variables
export API_BEARER_TOKEN="your_token_here"
export CLOUDFLARE_TOKEN="your_cloudflare_token"
# Run the application
python app.py
For container orchestration and managed rollouts. replicas: 1 is not an
example value — it is the supported configuration, and the Helm chart fails at
template time if you change it.
Install the Helm chart, published to GHCR on every release:
kubectl create namespace certmate
kubectl -n certmate create secret generic certmate-secrets \
--from-literal=API_BEARER_TOKEN="$(openssl rand -hex 32)" \
--from-literal=SECRET_KEY="$(openssl rand -hex 32)" \
--from-literal=CERTMATE_BACKUP_PASSPHRASE="$(openssl rand -hex 32)"
helm install certmate oci://ghcr.io/fabriziosalmi/charts/certmate \
--namespace certmate --set secrets.existingSecret=certmate-secrets
CertMate on Kubernetes covers the first login, Argo CD and Flux, production sizing and OOM troubleshooting. Every chart value is in the chart README.
One command on Debian, Ubuntu, Fedora, RHEL, Rocky or Alma Linux:
curl -fsSL https://raw.githubusercontent.com/fabriziosalmi/certmate/main/deploy/install.sh | sudo sh
The installer puts CertMate in /opt/certmate with its own Python 3.12 (fetched by uv, so the system Python is not touched), generates the secrets into /etc/certmate/certmate.env, and starts the certmate systemd service on 127.0.0.1:8000. Run it again to upgrade: certificates, data and configuration stay. Details and options: docs/installation.md.
To set the service up by hand instead, follow the next section.
For production deployments, CertMate should run as a system service. This section provides comprehensive instructions for setting up CertMate with systemd on Linux distributions.
Create a dedicated user for running CertMate:
# Create system user and group
sudo useradd --system --shell /bin/false --home-dir /opt/certmate --create-home certmate
# Set proper ownership
sudo chown -R certmate:certmate /opt/certmate
Set up the application in /opt/certmate:
# Clone straight into the path. `useradd --create-home` above populated
# /opt/certmate from /etc/skel, so it is not empty and `mv certmate /opt/`
# fails with "Directory not empty" — leaving the next steps to run in a
# directory with no application in it.
sudo -u certmate git clone https://github.com/fabriziosalmi/certmate.git /opt/certmate
cd /opt/certmate
# Create Python virtual environment
sudo -u certmate python3 -m venv venv
sudo -u certmate ./venv/bin/pip install -r requirements.txt
# Create necessary directories. All four: the startup writeability probe
# in modules/factory.py checks certificates, data, backups AND logs,
# and raises at boot if any of them is not writable.
sudo -u certmate mkdir -p certificates data backups logs
Create environment file for the service:
# Create environment file
# The unit reads /etc/certmate/certmate.env (see certmate.service), not a
# .env in the application directory.
sudo install -d -m 750 /etc/certmate
sudo tee /etc/certmate/certmate.env > /dev/null <<EOF
# SECURITY: Change this token!
API_BEARER_TOKEN=your_super_secure_api_token_here_change_this
EOF
# PORT has no effect here: the port is the literal in the unit's ExecStart
# (--bind 0.0.0.0:8000), and nothing in the application reads PORT under
# gunicorn. To change it, edit ExecStart. PORT is honoured only by the
# container image.
# Set proper permissions
sudo chown root:certmate /etc/certmate/certmate.env
sudo chmod 640 /etc/certmate/certmate.env
Install and configure the systemd service:
# Copy service file
sudo cp /opt/certmate/certmate.service /etc/systemd/system/
# Reload systemd configuration
sudo systemctl daemon-reload
# Enable service to start on boot
sudo systemctl enable certmate
# Start the service
sudo systemctl start certmate
Check that the service is running correctly:
# Check service status
sudo systemctl status certmate
# View recent logs
sudo journalctl -u certmate --lines=50
# Follow logs in real-time
sudo journalctl -u certmate -f
Common commands for managing the CertMate service:
# Start service
sudo systemctl start certmate
# Stop service
sudo systemctl stop certmate
# Restart service
sudo systemctl restart certmate
# Reload service configuration
sudo systemctl reload certmate
# Check if service is enabled
sudo systemctl is-enabled certmate
# Check if service is active
sudo systemctl is-active certmate
# Disable service from starting on boot
sudo systemctl disable certmate
Ensure proper file permissions for security:
# Set ownership
sudo chown -R certmate:certmate /opt/certmate
# Set directory permissions
sudo chmod 755 /opt/certmate
sudo chmod 750 /opt/certmate/certificates /opt/certmate/data /opt/certmate/backups /opt/certmate/logs
# Set file permissions
sudo chmod 644 /opt/certmate/*.py /opt/certmate/*.md
sudo chmod 640 /etc/certmate/certmate.env
sudo chmod 755 /opt/certmate/venv/bin/*
/etc/certmate/certmate.env0.0.0.0:8000 by default - consider using a reverse proxy for production.env file contains sensitive data and should be readable only by the certmate user/opt/certmate/certificates with restricted accessIf the service fails to start:
sudo systemctl status certmatesudo journalctl -u certmate --lines=100certmate user can read all necessary filessudo -u certmate /opt/certmate/venv/bin/python /opt/certmate/app.pysudo -u certmate /opt/certmate/venv/bin/certbot --version — the ACME client is the dependency that breaks first when the pins drift.For more detailed installation instructions, see the Installation Guide.
CertMate supports authenticating users against an external OpenID Connect provider (Keycloak, Authentik, Okta, Google Workspace, Microsoft Entra, ...) using the Authorization Code + PKCE flow. SSO is additive: local username/password login and API keys keep working alongside it.
Open the CertMate UI as an admin → Settings → SSO.
Set the Issuer URL to the IdP's base URL (the path before /.well-known/openid-configuration). For example:
https://idp.example.com/realms/mainhttps://idp.example.com/application/o/certmate/https://accounts.google.comFill in the Client ID and Client Secret issued by the IdP for the CertMate application.
In the IdP, register CertMate's callback URL as a valid redirect URI:
https://your-certmate.example.com/api/auth/oidc/callback
Pick the claim names your IdP uses for username (preferred_username by default), email (email), and role (groups). Add Role mappings to translate IdP group/role claim values to CertMate roles — first match wins. Anything that doesn't match falls back to the configured Default role (viewer recommended).
Toggle Enable OIDC/SSO on and save. Visit /login in a new browser session to see the Sign in with button.
For a Keycloak realm that exposes a groups claim, the configuration block in settings.json looks like:
"oidc": {
"enabled": true,
"provider_name": "Keycloak",
"issuer_url": "https://idp.example.com/realms/main",
"client_id": "certmate",
"client_secret": "********",
"scopes": ["openid", "email", "profile", "groups"],
"role_claim": "groups",
"role_mappings": [
{ "claim_value": "certmate-admins", "role": "admin" },
{ "claim_value": "certmate-operators", "role": "operator" }
],
"default_role": "viewer",
"auto_create_users": true,
"link_by_email": true,
"sync_role_on_login": true
}
auto_create_users) creates a CertMate user row on first login. The row has an empty password hash so JIT-provisioned SSO accounts cannot fall back to local login.link_by_email) detects collisions with existing local users and merges identities — the user keeps their existing password hash, so a local-then-linked account can still log in either way during a rollout. Their role is preserved at the moment of linking, and from the next login onwards it is governed by sync_role_on_login like anyone else's (see below): with the default true, a linked local admin whose IdP groups map to viewer becomes a viewer on their second login. Disable link_by_email if you want JIT-only provisioning with no local-password fallback.sub + iss) lookup always wins over email matching, so an already-linked SSO user is never accidentally re-merged when their IdP email changes.sync_role_on_login, default true) re-derives the role from the current claims on every login, so removing someone from an admin group in the IdP demotes them in CertMate too. Set it to false when the IdP only authenticates and roles are managed inside CertMate — an admin promoting someone by hand then survives their next login.client_secret is masked (********) in every GET response and round-tripped safely through the Settings UI.oidc settings block is on the bulk-POST reject list — only the dedicated /api/auth/oidc/settings endpoint can mutate it, with full audit.CertMate provides a comprehensive REST API for programmatic certificate management. All endpoints require Bearer token authentication.
Include the Authorization header in all API requests:
Authorization: Bearer your_api_token_here
The full reference is docs/api.md: every endpoint, the role it needs, the shape it answers with, and the error format.
This section used to be a second catalogue, and the two had drifted: of the endpoints each named, six were in both. A reader had no way to tell which was current, and the reference was missing surfaces this file carried, which is where the storage backend, backup, metrics and zombie-scan sections in docs/api.md came from.
A worked example, so the shape is here even if the list is not:
# Every request carries the token.
curl -H "Authorization: Bearer $CERTMATE_TOKEN" \
https://certmate.example.com/api/certificates
# Issue one. The response is the certificate record, not a job id.
curl -X POST -H "Authorization: Bearer $CERTMATE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"domain": "api.example.com", "dns_provider": "cloudflare"}' \
https://certmate.example.com/api/certificates/create
Certificate downloads for infrastructure automation:
# Download every certificate file as one ZIP
GET /api/certificates/{domain}/download
Authorization: Bearer your_token_here
This endpoint returns a ZIP file containing all certificate files:
cert.pem - Server certificatechain.pem - Intermediate certificate chainfullchain.pem - Full certificate chain (cert + chain)privkey.pem - Private keyFor a single file, use the path form
GET /api/certificates/{domain}/download/{cert|chain|fullchain|privkey|combined}.
privkey and combined require operator; a viewer may pull the public
material only.
curl -H "Authorization: Bearer your_token_here" \
-o example.com-tls.json \
https://your-certmate-server.com/api/certificates/example.com/download?format=json
import requests
from pathlib import Path
class CertMateClient:
def __init__(self, base_url, token):
self.base_url = base_url.rstrip('/')
self.headers = {"Authorization": f"Bearer {token}"}
def download_certificate(self, domain):
"""Download certificate bundle as JSON for domain"""
url = f"{self.base_url}/api/certificates/{domain}/download?format=json"
response = requests.get(url, headers=self.headers)
response.raise_for_status()
return response.json()
def list_certificates(self):
"""List all managed certificates"""
response = requests.get(f"{self.base_url}/api/certificates",
headers=self.headers)
response.raise_for_status()
return response.json()
def create_certificate(self, domain, dns_provider=None):
"""Create new certificate for domain"""
data = {"domain": domain}
if dns_provider:
data["dns_provider"] = dns_provider
response = requests.post(f"{self.base_url}/api/certificates/create",
json=data, headers=self.headers)
response.raise_for_status()
return response.json()
def renew_certificate(self, domain):
"""Renew existing certificate"""
response = requests.post(f"{self.base_url}/api/certificates/{domain}/renew",
headers=self.headers)
response.raise_for_status()
return response.json()
# Usage example
client = CertMateClient("https://certmate.company.com", "your_token_here")
# List and download certificates
certs = client.list_certificates()
bundle = client.download_certificate("api.company.com")
Path("/etc/ssl/certs/api").mkdir(parents=True, exist_ok=True)
for name, key in {
"cert.pem": "cert_pem",
"chain.pem": "chain_pem",
"fullchain.pem": "fullchain_pem",
"privkey.pem": "private_key_pem",
}.items():
Path("/etc/ssl/certs/api", name).write_text(bundle[key])
The same JSON response shape can be consumed directly by Ansible's uri module or Salt's HTTP helpers without unpacking an archive.
Terraform: there is no CertMate Terraform provider. This README used to
show one, with source = "local/certmate" and certmate_certificate
resources; neither the registry nor any repository has it, so the example
could not be run. Drive the REST API from Terraform with the http provider
or a local-exec, or use the Ansible and shell examples below, which are
against the real API.
Bash Automation Script:
#!/bin/bash
set -euo pipefail
# Configuration
CERTMATE_URL="https://certmate.company.com"
API_TOKEN="${CERTMATE_TOKEN}"
DOMAIN="${1:-example.com}"
CERT_DIR="/etc/ssl/certs/${DOMAIN}"
BACKUP_DIR="/backup/certs/${DOMAIN}/$(date +%Y%m%d_%H%M%S)"
# Functions
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
}
create_backup() {
if [[ -d "$CERT_DIR" ]]; then
log "Creating backup of existing certificates"
mkdir -p "$BACKUP_DIR"
cp -r "$CERT_DIR"/* "$BACKUP_DIR/" || true
fi
}
download_certificate() {
log "Downloading certificate for ${DOMAIN}"
# Download with retry logic
for i in {1..3}; do
if curl -f -H "Authorization: Bearer $API_TOKEN" \
-o "${DOMAIN}-tls.json" \
"$CERTMATE_URL/api/certificates/$DOMAIN/download?format=json"; then
log "Certificate downloaded successfully"
return 0
else
log "Download attempt $i failed, retrying..."
sleep 5
fi
done
log "Failed to download certificate after 3 attempts"
return 1
}
extract_certificate() {
log "Extracting certificate to ${CERT_DIR}"
mkdir -p "$CERT_DIR"
jq -r '.cert_pem' "${DOMAIN}-tls.json" > "$CERT_DIR/cert.pem"
jq -r '.chain_pem' "${DOMAIN}-tls.json" > "$CERT_DIR/chain.pem"
jq -r '.fullchain_pem' "${DOMAIN}-tls.json" > "$CERT_DIR/fullchain.pem"
jq -r '.private_key_pem' "${DOMAIN}-tls.json" > "$CERT_DIR/privkey.pem"
# Set proper permissions
chmod 600 "$CERT_DIR"/*.pem
chown root:ssl-cert "$CERT_DIR"/*.pem
}
reload_services() {
log "Reloading web services"
systemctl reload nginx || log "Failed to reload nginx"
systemctl reload apache2 || log "Failed to reload apache2"
systemctl reload haproxy || log "Failed to reload haproxy"
}
cleanup() {
rm -f "${DOMAIN}-tls.json"
}
# Main execution
main() {
log "Starting certificate update for ${DOMAIN}"
create_backup
download_certificate
extract_certificate
reload_services
cleanup
log "Certificate update completed for ${DOMAIN}"
}
# Trap cleanup on exit
trap cleanup EXIT
# Run main function
main "$@"
Advanced Ansible Playbook:
---
- name: Manage SSL certificates with CertMate multi-account support
hosts: web_servers
vars:
certmate_url: "https://certmate.company.com"
certmate_token: "{{ vault_certmate_token }}"
tasks:
- name: Configure Cloudflare accounts
uri:
url: "{{ certmate_url }}/api/dns/cloudflare/accounts"
method: POST
headers:
Authorization: "Bearer {{ certmate_token }}"
Content-Type: "application/json"
body_format: json
body:
account_id: "{{ item.account_id }}"
config:
name: "{{ item.name }}"
description: "{{ item.description }}"
api_token: "{{ item.api_token }}"
loop:
- account_id: "production"
name: "Production Environment"
description: "Main production Cloudflare account"
api_token: "{{ vault_cloudflare_prod_token }}"
- account_id: "staging"
name: "Staging Environment"
description: "Development and testing account"
api_token: "{{ vault_cloudflare_staging_token }}"
- name: Create certificates with specific accounts
uri:
url: "{{ certmate_url }}/api/certificates/create"
method: POST
headers:
Authorization: "Bearer {{ certmate_token }}"
Content-Type: "application/json"
body_format: json
body:
domain: "{{ item.domain }}"
dns_provider: "{{ item.provider }}"
account_id: "{{ item.account_id }}"
loop:
- domain: "api.company.com"
provider: "cloudflare"
account_id: "production"
- domain: "staging.company.com"
provider: "cloudflare"
account_id: "staging"
- domain: "test.company.com"
provider: "route53"
account_id: "backup-aws"
- name: Download and deploy certificates
block:
- name: Download certificate bundle as JSON
uri:
url: "{{ certmate_url }}/api/certificates/{{ item }}/download?format=json"
headers:
Authorization: "Bearer {{ certmate_token }}"
return_content: yes
register: cert_bundle
- name: Write certificate files
copy:
dest: "/etc/ssl/certs/{{ item.0.item }}/{{ item.1.name }}"
content: "{{ item.0.json[item.1.key] }}"
owner: root
group: ssl-cert
mode: "{{ item.1.mode }}"
loop: "{{ cert_bundle.results | product(cert_files) | list }}"
vars:
cert_files:
- { name: "cert.pem", key: "cert_pem", mode: "0644" }
- { name: "chain.pem", key: "chain_pem", mode: "0644" }
- { name: "fullchain.pem", key: "fullchain_pem", mode: "0644" }
- { name: "privkey.pem", key: "private_key_pem", mode: "0600" }
loop:
- "api.company.com"
- "staging.company.com"
- "test.company.com"
Production-Ready Ansible Playbook:
---
- name: Enterprise SSL certificate management with CertMate
hosts: web_servers
become: yes
vars:
certmate_url: "https://certmate.company.com"
api_token: "{{ vault_certmate_token }}"
certificate_domains:
- name: "api.company.com"
dns_provider: "cloudflare"
nginx_sites: ["api"]
services_to_reload: ["nginx"]
- name: "web.company.com"
dns_provider: "route53"
nginx_sites: ["web", "admin"]
services_to_reload: ["nginx", "haproxy"]
tasks:
- name: Create certificate directories
file:
path: "/etc/ssl/certs/{{ item.name }}"
state: directory
owner: root
group: ssl-cert
mode: '0750'
loop: "{{ certificate_domains }}"
- name: Check certificate expiry
uri:
url: "{{ certmate_url }}/api/certificates/{{ item.name }}/deployment-status"
method: GET
headers:
Authorization: "Bearer {{ api_token }}"
register: cert_status
loop: "{{ certificate_domains }}"
- name: Create new certificates if needed
uri:
url: "{{ certmate_url }}/api/certificates/create"
method: POST
headers:
Authorization: "Bearer {{ api_token }}"
Content-Type: "application/json"
body_format: json
body:
domain: "{{ item.name }}"
dns_provider: "{{ item.dns_provider }}"
loop: "{{ certificate_domains }}"
when: cert_status.results[ansible_loop.index0].json.needs_renewal | default(false)
- name: Download certificates
uri:
url: "{{ certmate_url }}/api/certificates/{{ item.name }}/download?format=json"
method: GET
headers:
Authorization: "Bearer {{ api_token }}"
return_content: yes
register: cert_bundle
loop: "{{ certificate_domains }}"
- name: Write certificates
copy:
dest: "/etc/ssl/certs/{{ item.0.item.name }}/{{ item.1.name }}"
content: "{{ item.0.json[item.1.key] }}"
owner: root
group: ssl-cert
mode: "{{ item.1.mode }}"
loop: "{{ cert_bundle.results | product(cert_files) | list }}"
vars:
cert_files:
- { name: "cert.pem", key: "cert_pem", mode: "0644" }
- { name: "chain.pem", key: "chain_pem", mode: "0644" }
- { name: "fullchain.pem", key: "fullchain_pem", mode: "0644" }
- { name: "privkey.pem", key: "private_key_pem", mode: "0600" }
notify:
- reload nginx
- reload haproxy
- restart services
- name: Verify certificate installation
openssl_certificate:
path: "/etc/ssl/certs/{{ item.name }}/fullchain.pem"
provider: assertonly
has_expired: no
valid_in: 86400 # Valid for at least 1 day
loop: "{{ certificate_domains }}"
- name: Update nginx SSL configuration
template:
src: "nginx-ssl.conf.j2"
dest: "/etc/nginx/sites-available/{{ item.1 }}"
backup: yes
loop: "{{ certificate_domains | subelements('nginx_sites') }}"
notify: reload nginx
- name: Cleanup temporary files
file:
path: "/tmp/{{ item.name }}-tls.json"
state: absent
loop: "{{ certificate_domains }}"
handlers:
- name: reload nginx
systemd:
name: nginx
state: reloaded
- name: reload haproxy
systemd:
name: haproxy
state: reloaded
- name: restart services
systemd:
name: "{{ item }}"
state: restarted
loop: "{{ services_to_restart | default([]) }}"
Two versions appear in API responses and they answer different questions.
| what it is | when it moves | |
|---|---|---|
version (in /health, in the Swagger document) | the release number | every release |
api_contract_version (in /health), X-CertMate-API-Version (on every response) | the interface | only when this surface changes |
A client deciding whether it still works should read the second. The first moves on every patch whether or not anything a caller depends on moved with it.
Deprecating something bumps neither — that is the point of deprecating rather than removing.
An endpoint on its way out answers normally and says so in its headers, so a client can warn instead of failing:
Deprecation: @1757289600 # RFC 9745 — when it became deprecated
Sunset: Mon, 08 Mar 2027 00:00:00 GMT # RFC 8594 — earliest it may stop answering
Link: <https://…/docs/api.md#…>; rel="deprecation"
Nothing is deprecated today. The headers appear only on an endpoint that is.
The API validates request bodies against the models published in
/api/swagger.json. A missing required field or a wrong type is refused with
400 and a body naming the field, before the request reaches the application.
One consequence worth knowing: send a field or leave it out — do not send
null. Omitting an optional field means "use the default"; an explicit
null is a type error ("None is not of type 'integer'"), because null is
not an integer.
Values outside a documented enum are refused by the application rather than
by the schema, with a message naming the accepted set — for example
key_size must be one of [2048, 3072, 4096], got 1024.
| Variable | Required | Default | Description |
|---|---|---|---|
API_BEARER_TOKEN | auto-generated | Bearer token for API authentication | |
API_BEARER_TOKEN_FILE | - | Path to a file containing the API bearer token (takes precedence over API_BEARER_TOKEN) | |
SECRET_KEY | auto-generated | Flask secret key for sessions | |
SECRET_KEY_FILE | - | Path to a file containing the Flask secret key (takes precedence over SECRET_KEY). If set and unreadable or empty, CertMate refuses to start rather than inventing one: a secret that failed to mount is a configuration error, and a fresh key would sign out every user on every restart | |
PORT | 8000 | Server port (honoured by the container entrypoint) | |
FLASK_ENV | production | Flask environment. production refuses --debug | |
CERTMATE_LOG_FILE | - | Also write logs to this path. Off by default: the container logs to stdout, which is what docker logs and log shippers expect. Set it (e.g. /app/logs/certmate.log) to keep a file on the mounted volume — it is what the web UI's log stream reads | |
CERTMATE_LOG_MAX_BYTES | 10485760 | Rotate the log file at this size (10 MB). File logging is always rotated — there is no way to configure an unbounded one | |
CERTMATE_LOG_BACKUP_COUNT | 5 | How many rotated files to keep (~60 MB ceiling with the default size) | |
CERTMATE_AUDIT_LOG_MAX_BYTES | 10485760 | Rotate the human-readable audit log (logs/audit/certificate_audit.log) at this size. 0 disables rotation. Does not apply to the tamper-evident hash chain in data/audit/, which is never rotated | |
CERTMATE_AUDIT_LOG_BACKUP_COUNT | 5 | How many rotated audit logs to keep. Note the Activity page tails only the active file, so it shows fewer entries immediately after a roll | |
CERTMATE_LOG_LEVEL | INFO | DEBUG / INFO / WARNING / ERROR | |
CERTMATE_LOG_JSON | true | JSON log lines (set false for human-readable) |
| Variable | Required | Default | Description |
|---|---|---|---|
BEHIND_PROXY | false | Trust X-Forwarded-* from one reverse proxy hop (ProxyFix). Set it only when a proxy actually sits in front: with it on and no proxy, a client can forge its own address, which is what the login rate limit is keyed on | |
PREFERRED_URL_SCHEME | - | Set to https when TLS terminates in front of CertMate. It marks the session cookie Secure, so the browser stops sending it over plain HTTP | |
CORS_ORIGINS | - | Comma-separated origins allowed to call the API from a browser. Empty means the built-in default; this is not a way to open the API to everyone | |
ACME_CHALLENGES_DIR | <cwd>/data/acme-challenges | Where http-01 tokens are written and served from. Override it when the webroot is somewhere else; the hook, the pre-creation and the route all read this one value | |
LETSENCRYPT_EMAIL | - | ACME account email. Takes precedence over the value saved in the UI, so an instance that keeps changing its email back is usually this | |
CLOUDFLARE_TOKEN | - | Seeds the default Cloudflare DNS account on first start, so a container can be brought up already able to issue. Once settings.json exists the stored value is what is used |
| Variable | Required | Default | Description |
|---|---|---|---|
CERTMATE_ENABLE_HSTS | false | Send Strict-Transport-Security, and mark the session cookie Secure. Only set it when TLS really terminates in front — an HSTS header on a plain-HTTP instance locks browsers out of it | |
SESSION_TIMEOUT_HOURS | 8 | How long a login session stays valid. The server record and the browser cookie both use this value, so they cannot drift apart | |
CERTMATE_ALLOW_INTERNAL_WEBHOOKS | false | Allow notification webhooks to private, loopback and link-local addresses. Off by default because a webhook URL is operator-supplied and an SSRF into the host network is the obvious abuse | |
CERTMATE_PROBE_ALLOW_PRIVATE | false | Same relaxation for the certificate probe behind POST /api/probe (discovery sweeps ignore it and use monitored_endpoints.allow_private from settings): allow it to connect to private addresses. Needed to probe a service on the same host, and it removes an SSRF guard | |
CERTMATE_AUDIT_CHAIN | 1 | Set 0 to stop writing the tamper-evident audit hash chain. A kill switch, not a tuning knob: with it off, /api/audit/verify can no longer prove the log was not edited | |
AUDIT_SIGNING_KEY_FILE | - | Path to an Ed25519 private key that signs audit checkpoints, so the key can live off this box. If set and unreadable, signing is DISABLED rather than a new key generated — a fresh key would fork the instance's identity and make earlier signatures unverifiable | |
CERTMATE_ALLOW_SCHEMA_DOWNGRADE | 0 | Set 1 to let this build read and overwrite a settings.json or metadata.json written by a NEWER version. It will drop fields it does not understand — that is the whole reason it refuses by default |
| Variable | Required | Default | Description |
|---|---|---|---|
CERTMATE_DOMAIN_LOCK_TIMEOUT | 5 | Seconds to wait for a domain's lock before answering 409 "operation in progress". Clamped to 0-60 | |
CERTMATE_CERT_INFO_CACHE_TTL | 60 | Seconds a parsed certificate's details stay cached. 0 re-reads from disk on every request. Clamped to 0-3600 | |
CERTMATE_ISSUANCE_WORKERS | 2 | Threads serving asynchronous issuance jobs. Clamped to 1-16; each one can be running a certbot subprocess | |
CERTMATE_CERT_DIR | <install>/certificates | Where issued certificates live. Absolute, or relative to the working directory. The local storage backend follows it too, unless certificate_storage.cert_dir names a directory of its own | |
CERTMATE_DATA_DIR | <install>/data | Settings, the certificate inventory and the audit chain | |
CERTMATE_BACKUP_DIR | <install>/backups | Where backups are written and restored from | |
CERTMATE_LOGS_DIR | <install>/logs | Application and audit logs | |
CERTMATE_ISSUANCE_QUEUE_LIMIT | 20 | How much unfinished issuance may exist at once, counting queued and running jobs. Clamped to 1-500. Beyond it the async endpoints answer 429 ISSUANCE_QUEUE_FULL instead of accepting work that will not be reached for hours | |
CERTMATE_ISSUANCE_JOB_HISTORY | 200 | How many finished issuance jobs stay queryable via /api/certificates/jobs. Clamped to 20-2000 | |
CERTMATE_EVENT_WORKERS | 4 | Threads dispatching event listeners (deploy hooks, cache invalidation). Clamped to 1-32. Nothing is dropped when they are busy; the backlog is logged instead | |
CERTMATE_EVENT_MAX_STREAMS | 4 | Live /api/web/events/stream connections held at once. Each one occupies a gunicorn thread and the image runs --workers 1 --threads 8, so this leaves half the pool for ordinary requests. Past it the route answers 503 with Retry-After. Clamped to 1-64 | |
CERTMATE_EVENT_STREAM_SECONDS | 900 | How long one event stream is held before it is closed and the browser reconnects. EventSource reconnects on its own, so this is invisible — it bounds what a forgotten tab can hold. Clamped to 30-86400 | |
CERTMATE_EVENT_DRAIN_SECONDS | 5 | How long a shutdown waits for queued event dispatches to start before giving up on them. Clamped to 0-60. Whatever is left is logged with its event and domain, so a deploy hook that never ran after a renewal is named rather than lost | |
CERTMATE_CERTBOT_PROBE_TTL | 300 | Seconds before the certbot readiness answer is re-checked. Clamped to 30-3600. The probe used to run once per process, so a transient failure at boot made the instance permanently unready and a certbot that broke later never turned /health/ready red | |
CERTMATE_PROBE_TIMEOUT_SECONDS | 5 | Connection timeout of the certificate probe used by discovery sweeps and POST /api/probe. Clamped to 1-30. The deployment check has its own, CERTMATE_TLS_PROBE_TIMEOUT_SECONDS | |
CERTMATE_LAST_USED_PERSIST_SECONDS | 60 | How often a session's "last used" timestamp is written to disk. 0 writes on every request, which is the original behaviour and one write per request | |
CERTMATE_SLOW_REQUEST_LOGGING | true | Log a warning, with the thread's stack, for requests that outlive the threshold below. The stack is what makes a hung request diagnosable after the fact | |
CERTMATE_SLOW_REQUEST_THRESHOLD_SECONDS | 30 | How long a request must run before it is reported | |
CERTMATE_SLOW_REQUEST_SCAN_SECONDS | 10 | How often the watchdog looks for requests that are still running | |
CERTMATE_SLOW_REQUEST_REPEAT_SECONDS | threshold | How often a still-running request is reported again |
Bind address is not an environment variable. The container always binds
0.0.0.0inside its own network namespace; to expose it only on loopback, publish it that way —-p 127.0.0.1:8000:8000. Runningapp.pydirectly (development only) takes--host/--port/--debugas CLI flags.HOSTandFLASK_DEBUGare read by nothing, and setting them has never had any effect (#429).
DNS:Edit + Zone:Read# Environment variable
CLOUDFLARE_TOKEN=your_cloudflare_api_token_here
Route53FullAccess or custom policy:{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["route53:ListHostedZones",
"route53:GetChange",
"route53:ChangeResourceRecordSets"
],
"Resource": "*"
}
]
}
# Environment variables
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AWS_DEFAULT_REGION=us-east-1
az ad sp create-for-rbac --name "CertMate" --role "DNS Zone Contributor" --scopes "/subscriptions/{subscription-id}/resourceGroups/{resource-group}"
# Environment variables
AZURE_SUBSCRIPTION_ID=your_subscription_id
AZURE_RESOURCE_GROUP=your_resource_group_name
AZURE_TENANT_ID=your_tenant_id
AZURE_CLIENT_ID=your_client_id
AZURE_CLIENT_SECRET=your_client_secret
# Environment variables
GOOGLE_PROJECT_ID=your_project_id
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
# Environment variables
POWERDNS_API_URL=https://your-powerdns-server:8081
POWERDNS_API_KEY=your_api_key
CertMate supports multiple storage backends for certificates, providing flexibility for different deployment scenarios and security requirements. By default, certificates are stored locally on the filesystem, but you can configure enterprise-grade storage backends for enhanced security and compliance.
Choosing the Right Storage Backend:
- Local Filesystem: Perfect for development, testing, and small deployments
- Azure Key Vault: Best for Azure-native environments and Microsoft ecosystem integration
- AWS Secrets Manager: Ideal for AWS infrastructure and cross-region deployments
- HashiCorp Vault: Excellent for multi-cloud environments and advanced secret management
- Infisical: Great for teams wanting open-source secret management with collaboration features
- S3-compatible object storage: One bucket on any S3 endpoint (Hetzner, Contabo, OVHcloud, Scaleway, Exoscale, Wasabi, MinIO, AWS)
The default storage backend stores certificates in the local filesystem with secure permissions:
# Default certificate directory
certificates/
example.com/
cert.pem # Server certificate
chain.pem # Certificate chain
fullchain.pem # Full chain
privkey.pem # Private key (600 permissions)
metadata.json # Certificate metadata (600 permissions)
Configuration:
certificates (configurable)600 for private keys, 644 for certificatesBenefits:
Store certificates securely in Azure Key Vault for enterprise-grade secret management:
Required Dependencies:
pip install -r requirements-azure-storage.txt
Configuration:
{
"certificate_storage": {
"backend": "azure_keyvault",
"azure_keyvault": {
"vault_url": "https://yourvault.vault.azure.net/",
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_secret": "your_client_secret"
}
}
}
Benefits:
Use Cases:
Integrate with AWS Secrets Manager for scalable secret storage:
Required Dependencies:
pip install -r requirements-aws-storage.txt
Configuration:
{
"certificate_storage": {
"backend": "aws_secrets_manager",
"aws_secrets_manager": {
"region": "us-east-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
}
}
For IAM authentication, choose AWS credentials / IAM role (no keys) in
Settings → Storage, enter the region and leave access keys empty. CertMate uses
the AWS credential chain (for example an EC2 instance profile, ECS task role or
EKS pod identity). An optional Assume role ARN uses sts:AssumeRole and
refreshes the temporary credentials automatically. Existing key-based settings
still work. If auth_mode is absent in a manual configuration, access keys
remain required. Equivalent settings without an additional assumed role:
{"certificate_storage":{"backend":"aws_secrets_manager","aws_secrets_manager":{"region":"eu-west-1","auth_mode":"iam_role"}}}
The IAM identity needs secretsmanager:ListSecrets (account-wide, resource
*) and GetSecretValue, DescribeSecret, CreateSecret, UpdateSecret
and DeleteSecret for the certmate/certificates/* secrets. Assuming a
second role also requires permission to call
sts:AssumeRole and a trust policy on the target role. The connection test
reports AWS permission errors rather than treating them as an empty store.
Benefits:
Use Cases:
Use industry-standard HashiCorp Vault for advanced secret management:
Required Dependencies:
pip install -r requirements-vault-storage.txt
Configuration:
{
"certificate_storage": {
"backend": "hashicorp_vault",
"hashicorp_vault": {
"vault_url": "https://vault.example.com:8200",
"vault_token": "hvs.xxxxxxxxxxxxxxxxxxxx",
"mount_point": "secret",
"engine_version": "v2"
}
}
}
Benefits:
Use Cases:
Modern open-source secret management with team collaboration:
Required Dependencies:
pip install -r requirements-infisical-storage.txt
Configuration:
{
"certificate_storage": {
"backend": "infisical",
"infisical": {
"site_url": "https://app.infisical.com",
"client_id": "your_client_id",
"client_secret": "your_client_secret",
"project_id": "your_project_id",
"environment": "prod"
}
}
}
site_url must be https:// (plain http:// is accepted only for a loopback address: localhost, 127.0.0.1 or ::1). The Infisical
SDK follows a redirect and sends the request body on, and here the body is the certificate and its
private key; its HTTP client cannot be configured from CertMate, so the one thing CertMate can
refuse is the position where anyone on the path could answer with a redirect. Point it at a server
you trust with the key, which is what storing it there means anyway.
This backend did not work in v2.44.0 or earlier. It could not run at all: it imported a module
name the pinned infisical-python does not have, and called methods that SDK does not have.
Benefits:
Use Cases:
One backend for S3-compatible object storage: Hetzner, Contabo, OVHcloud, Scaleway, Exoscale, Wasabi, self-hosted MinIO, or AWS S3 itself. Each domain is stored as one JSON object, <prefix>/<domain>.json, holding the certificate files and their metadata.
Required Dependencies: boto3, already in requirements.txt and requirements-storage-all.txt (not in requirements-minimal.txt).
Configuration:
{
"certificate_storage": {
"backend": "s3_compatible",
"s3_compatible": {
"endpoint_url": "https://fsn1.your-objectstorage.com",
"bucket": "certmate",
"access_key_id": "your_access_key_id",
"secret_access_key": "your_secret_access_key",
"region": "us-east-1",
"prefix": "certmate/certificates"
}
}
}
For S3-compatible services, use Access keys and provide both keys, the bucket and the service's endpoint. Existing key-based configurations continue to work unchanged. region defaults to us-east-1 and prefix to certmate/certificates.
For AWS S3 with an IAM role, select AWS credentials / IAM role (no keys) in Settings → Storage, provide the bucket and region, and leave the endpoint URL and access keys empty. Boto3 uses its standard credential chain (for example an EC2 instance profile, ECS task role or EKS pod identity). You can optionally enter an Assume role ARN (arn:aws:iam::123456789012:role/CertMateS3): the current identity must have sts:AssumeRole permission and the destination role must trust it. CertMate refreshes the temporary STS credentials automatically. The role needs s3:ListBucket on the bucket and s3:GetObject, s3:PutObject, and s3:DeleteObject on <prefix>/* (including object existence checks). The Test Storage Backend action lists the prefix and reports authentication/permission failures.
For manual settings.json, explicitly set "auth_mode":"iam_role" to use AWS credentials without keys or an endpoint: {"backend":"s3_compatible","s3_compatible":{"bucket":"certmate","region":"eu-west-1","auth_mode":"iam_role","assume_role_arn":"arn:aws:iam::123456789012:role/CertMateS3"}}. Omit assume_role_arn to use the attached role directly. Without auth_mode, legacy key-pair requirements still apply. The optional off-site S3 backup storage has separate credential settings; this option configures certificate storage only.
Install All Storage Backends:
# Install all storage backends at once
pip install -r requirements-storage-all.txt
Install Individual Storage Backends:
# Azure Key Vault only
pip install -r requirements-azure-storage.txt
# AWS Secrets Manager only
pip install -r requirements-aws-storage.txt
# HashiCorp Vault only
pip install -r requirements-vault-storage.txt
# Infisical only
pip install -r requirements-infisical-storage.txt
# S3-compatible storage: no separate file — boto3 is in requirements.txt
Requirements File Overview:
requirements-storage-all.txt - All storage backends (recommended for production)requirements-azure-storage.txt - Azure Key Vault dependenciesrequirements-aws-storage.txt - AWS Secrets Manager dependenciesrequirements-vault-storage.txt - HashiCorp Vault dependenciesrequirements-infisical-storage.txt - Infisical dependenciesrequirements-minimal.txt - Base CertMate without storage backendsVia Web Interface:
Via API:
# Test storage backend connectivity before switching
curl -X POST "http://localhost:8000/api/storage/test" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"backend": "azure_keyvault",
"config": {
"vault_url": "https://yourvault.vault.azure.net/",
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_secret": "your_client_secret"
}
}'
# Get current storage backend information
curl -X GET "http://localhost:8000/api/storage/info" \
-H "Authorization: Bearer your_token"
# Update storage backend configuration. Unlike /api/storage/test, the settings
# go under a key named after the backend, not under "config" — a "config" key
# here is ignored and the backend is saved with no credentials.
curl -X POST "http://localhost:8000/api/storage/config" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"backend": "hashicorp_vault",
"hashicorp_vault": {
"vault_url": "https://vault.example.com:8200",
"vault_token": "hvs.xxxxxxxxxxxxxxxxxxxx",
"mount_point": "secret",
"engine_version": "v2"
}
}'
Migrating Between Backends:
Migration copies every certificate from a source backend to a target backend,
one domain at a time. When no source_backend is given, the source is the
backend currently saved in settings, so migrate before switching:
POST /api/storage/test)POST /api/storage/migrate, or the migration tool in Settings)migrated_count, failed_count, and migration_results, one true/false per domainPOST /api/storage/config)If you already saved the new backend, choose Local filesystem (or the
previously configured backend) as Source backend in the Settings migration
dialog. The dialog always requires an explicit source choice: when migrating
from bucket A to bucket B within S3, choose the saved S3 backend as source,
even though S3 is also selected as the target. The target remains the backend
selected in Settings. Via the API,
include "source_backend": "local_filesystem" in the migration request.
A zero-domain result means no certificates were found in that source; check
the source selection and certificate directory rather than assuming the
certificates were copied.
Migration via API:
# Migrate all certificates from current backend to new backend
curl -X POST "http://localhost:8000/api/storage/migrate" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"target_backend": "aws_secrets_manager",
"target_config": {
"region": "us-east-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
}'
Each certificate's metadata is copied along with its files. There is no
separate verification pass and no rollback: a domain whose copy failed is
reported as false in migration_results, and the source is left untouched,
so re-running the migration or staying on the old backend are both safe.
Backward Compatibility:
certmate/
app.py # Main Flask application
requirements.txt # Python dependencies
docker-compose.yml # Docker Compose configuration
Dockerfile # Container build instructions
nginx.conf.example # Nginx reverse proxy config template (copy to nginx.conf)
.env.example # Environment template
README.md # This documentation
CONTRIBUTING.md # Contribution guidelines
docs/ # Comprehensive documentation
certificates/ # Certificate storage
{domain}/
cert.pem # Server certificate
chain.pem # Certificate chain
fullchain.pem # Full chain
privkey.pem # Private key
data/ # Application data
settings.json # Persistent settings
logs/ # Application logs
letsencrypt/ # Let's Encrypt working directory
config/ # Certbot configuration
work/ # Certbot working files
logs/ # Certbot logs
modules/ # Modular backend
core/ # Core modules
deployer.py # Deploy hooks manager
notifier.py # Notification channels
events.py # SSE event bus
digest.py # Weekly digest
audit.py # Audit logger
shell.py # Shell executor
web/ # Web routes
api/ # API resources
templates/ # Web interface templates
index.html # Main dashboard
settings.html # Settings page
activity.html # Activity timeline
help.html # Help & documentation
static/js/ # Frontend modules
dashboard.js # Dashboard logic
settings.js # Settings components
cmd-palette.js # Cmd+K palette
shortcuts.js # Keyboard shortcuts
allowed_domains): Restrict a scoped API key to a list of domain patterns. Supports exact (example.com) and wildcard (*.example.com) forms; the wildcard matches subdomains only, not the apex. An operation on an existing certificate needs every name the certificate covers to be in scope, as creating it does. Client certificates, backups and the inventory configuration belong to the instance, not to a domain, so a key restricted this way cannot use those routes; /metrics answers it with the series about its own domains only. An admin key cannot be restricted to domains: creating one is refused, and one stored by an earlier release acts as an operator within its domains. Out-of-scope requests return 403 DOMAIN_OUT_OF_SCOPE and are recorded in the audit log. Leave the field empty for unrestricted access (legacy behavior).POST /api/settings: only documented configuration keys are accepted. Sensitive fields (api_bearer_token, deploy_hooks, users, api_keys, local_auth_enabled) cannot be written through the generic settings endpoint — each has its own dedicated endpoint with its own audit:
POST /api/usersPOST /api/keysPOST /api/deploy/configPOST /api/auth/config (admin-only)
Unknown or rejected keys are returned in a 400 response with a hint field pointing at the correct endpoint.data/audit/certificate_audit.log as one JSON object per line — pipeable into your SIEM of choice.Until the first operator credential exists — a local admin with local auth enabled, API_BEARER_TOKEN, or OIDC — every request is served as admin to anyone who can reach the instance. So during setup CertMate only bootstraps: it creates the first admin and turns login on. API keys and further users are refused with 409 SETUP_BOOTSTRAP_ONLY until setup is complete, because anything created in that window would be created by whoever was there and would outlive it. API keys that an older version let be created during setup stay valid but are flagged in Settings → API Keys and in the startup log, until an operator confirms or revokes each one.
600 permissions700 permissionsCertMate stores DNS provider credentials, ACME account keys, and (legacy) bearer tokens inside data/settings.json. The bearer token itself is migrated to an HMAC-SHA256 hash on first save, but DNS provider credentials remain in the file in their original form so they can be passed to certbot plugins. The file is created with 0600 permissions, which is the first line of defense. Recommended hardening, in order of effort:
certificate_storage.backend at HashiCorp Vault, Infisical, AWS Secrets Manager, or Azure Key Vault. Issued certificates are stored there transparently, keeping the bearer token + DNS credentials in settings.json as the only on-disk secret surface.data/ directory on a LUKS-encrypted partition (Linux), an encrypted APFS volume (macOS), or a Kubernetes Secret mounted in a tmpfs. This protects credentials at rest even if the host disk is removed or imaged.data/ owned by that user and 0700 mode. Verify with ls -la data/ — only the CertMate process user should be able to read the file.data/ from untrusted sources in Docker. Use a named volume managed by Docker, or a CSI-provisioned volume in Kubernetes, rather than a bind from a multi-tenant host.CertMate does not currently encrypt secrets at the application layer. If your threat model requires that the same operator who can read
settings.jsonshould still not be able to read DNS credentials, use option (1) above and configure the per-provider credentials in the external secret backend rather than in CertMate's settings.
# Example firewall rules (iptables)
# Allow only specific IPs to access CertMate
iptables -A INPUT -p tcp --dport 8000 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -s 192.168.0.0/16 -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -j DROP
The rules above confine inbound access. You can also confine outbound traffic: route CertMate's HTTP(S) egress through a forward proxy and deny it any other route to the internet, so it can reach only your CA, DNS provider, object storage, and notification endpoints. With Secure Proxy Manager v3.9.0+ this is a built-in default-deny egress allowlist. See Confining outbound traffic for a worked example.
# docker-compose.prod.yml
version: '3.8'
services:
certmate:
image: certmate:latest
deploy:
# CertMate runs an in-process renewal scheduler and per-domain locks, so
# it must run as a SINGLE writer. Multiple replicas (or >1 gunicorn
# worker) each fire the renewal check and would issue duplicate ACME
# orders and hit the CA's duplicate-certificate rate limit. A host-local
# flock guards multiple workers/containers on a *shared* data volume, but
# the safe default is one writer. For HA, front a single active instance
# rather than scaling this service.
replicas: 1
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
environment:
- FLASK_ENV=production
# The worker and thread counts are fixed in the image
# (`--workers 1 --threads 8`): one worker because the scheduler runs
# in-process and a second would duplicate every renewal. GUNICORN_TIMEOUT
# is the one that is read.
- GUNICORN_TIMEOUT=300 # the image default; raise it for slow DNS providers
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
upstream certmate_backend {
server certmate1:8000;
server certmate2:8000;
server certmate3:8000;
}
server {
listen 443 ssl http2;
server_name certmate.company.com;
ssl_certificate /etc/ssl/certs/certmate.company.com/fullchain.pem;
ssl_certificate_key /etc/ssl/certs/certmate.company.com/privkey.pem;
location / {
proxy_pass http://certmate_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# API rate limiting
limit_req zone=api burst=10 nodelay;
}
}
CertMate provides comprehensive backup and recovery capabilities built directly into the application, ensuring your certificates and configuration data are always protected.
What is Unified Backup?
include_secrets=true) contains everything needed for complete restoration; the default share-safe archive contains certificates, chains, metadata, the audit chain and the inventory — no credentials and no private keysTwo kinds of archive:
privkey.pem, no ACME account key, no private-CA key, no .pfx. Certificates, chains, metadata, the audit chain and the inventory are all there. The manifest says so (secrets_masked, key_material_excluded). Such an archive cannot restore an instance on its own: GET /api/backups reports it with can_restore: false and Settings disables its Restore button. The restore API does not read can_restore. It refuses a share-safe archive when this instance already holds certificates; otherwise it restores it, keeping the credentials already in this instance's settings.json, or — on an instance with no settings.json — leaving every credential masked, to be re-entered before the next renewal.CERTMATE_BACKUP_PASSPHRASE is set. This is the archive to keep off-site.Which one you get automatically depends on one thing: whether a backup passphrase is
configured. With CERTMATE_BACKUP_PASSPHRASE set, automatic backups are disaster-recovery
archives, encrypted at rest. Without it they stay share-safe, because a complete archive
that cannot be encrypted would be a plaintext credential dump written to disk on every
settings change. Without a passphrase, the instance logs a notice once per process, at the first automatic backup it takes.
CertMate never generates or stores that passphrase: keeping it beside the archive it protects would make the encryption meaningless. It is yours to set and to keep.
POST /api/backups/create with {"include_secrets": true} (admin, audited) still takes a
disaster-recovery archive on demand, passphrase or not — a deliberate opt-in.
Getting a backup back onto a new instance: POST /api/backups/upload (admin,
multipart/form-data, field file), or the Restore From Elsewhere control in Settings.
Uploading only stores the archive under a name CertMate generates — the name you send is
discarded. Restoring it is a separate, explicit step, so an upload is never destructive on
its own.
Automatic Backups:
CERTMATE_BACKUP_PASSPHRASE is set, share-safe otherwise (see above)MAX_BACKUPS_PER_TYPE), and any archive older than 30 days is deleted regardless of how few there are (BACKUP_RETENTION_DAYS). Both are constants, not settings. This is why a disaster-recovery archive must be kept off the host: one left in backups/unified/ is deleted after 30 days like any other. Logging in no longer writes a backup, so routine sign-ins do not consume restore pointsManual Backups:
Access backup features from the Settings page:
<!-- Create backup -->
<button onclick="createBackup('unified', this)">Create Backup</button>
<!-- View and manage existing backups -->
- Download backups for external storage
- Restore from any backup point with atomic consistency
- View backup contents and metadata
- Delete specific backups manually
Create Backup:
# Create backup (settings + certificates); "type" is required
curl -X POST "http://localhost:8000/api/backups/create" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{"type": "unified", "reason": "manual_backup"}'
# Response (201)
{
"message": "Backup created successfully",
"backups": [
{"type": "unified", "filename": "backup_20241225_120000_123456_manual_backup.zip"}
],
"secrets_masked": true,
"recommendation": null
}
Archives are named backup_<YYYYmmdd_HHMMSS_ffffff>_<reason>.zip (UTC timestamp), or
.zip.enc when CERTMATE_BACKUP_PASSPHRASE is set.
List and Download Backups:
# List all backups
curl -H "Authorization: Bearer your_token" \
"http://localhost:8000/api/backups"
# Download backup
curl -H "Authorization: Bearer your_token" \
"http://localhost:8000/api/backups/download/unified/backup_20241225_120000_123456_manual_backup.zip" \
-o backup.zip
Backup File (ZIP):
backup_20260821_120000_000000_manual.zip
backup_metadata.json # manifest: secrets_masked, key_material_excluded, key_files_excluded, encrypted
settings.json # application settings — credentials masked in a share-safe archive, plaintext in a DR one
certificates/
domain1.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem # DR archive only (include_secrets=true); absent from a share-safe archive
metadata.json
live/ archive/ renewal/ accounts/ # certbot lineage (keys under accounts/ and live/ DR only)
data/
certs/ca/ca.crt # ca.key: DR only
certs/client/... # client certificates (.key/.pfx: DR only)
audit/ inventory/
Backup Restoration:
Web Interface:
API Restoration:
# Restore from backup
curl -X POST "http://localhost:8000/api/backups/restore/unified" \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{"filename": "backup_20241225_120000_123456_manual_backup.zip", "create_backup_before_restore": true}'
For additional protection, integrate with external backup systems:
Automated External Backup Script:
#!/bin/bash
# /opt/scripts/backup-certmate-external.sh
BACKUP_DIR="/backup/certmate/$(date +%Y%m%d_%H%M%S)"
CERT_DIR="/opt/certmate/certificates"
DATA_DIR="/opt/certmate/data"
RETENTION_DAYS=30
# Create backup directory
mkdir -p "$BACKUP_DIR"
# Download the newest backup that can restore (there is no "latest" alias;
# the list is ordered oldest to newest by name)
LATEST=$(curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/backups \
| jq -r '[.unified[] | select(.can_restore)] | last | .filename // empty')
[ -n "$LATEST" ] || { echo "No restorable backup on this instance" >&2; exit 1; }
curl -H "Authorization: Bearer $API_TOKEN" \
"http://localhost:8000/api/backups/download/unified/$LATEST" \
-o "$BACKUP_DIR/$LATEST"
# Backup certificates directory
tar -czf "$BACKUP_DIR/certificates.tar.gz" "$CERT_DIR"
# Backup application data
tar -czf "$BACKUP_DIR/data.tar.gz" "$DATA_DIR"
# Encrypt backups (optional)
gpg --cipher-algo AES256 --compress-algo 1 --symmetric \
--output "$BACKUP_DIR/$LATEST.gpg" \
"$BACKUP_DIR/$LATEST"
# Cleanup old backups
find /backup/certmate -type d -mtime +$RETENTION_DAYS -exec rm -rf {} \;
echo "External backup completed: $BACKUP_DIR"
#!/bin/bash
# Recovery from backup
BACKUP_DATE="20241225_120000"
BACKUP_DIR="/backup/certmate/$BACKUP_DATE"
# Stop services
docker-compose down
# Restore certificates
tar -xzf "$BACKUP_DIR/certificates.tar.gz" -C /opt/certmate/
# Restore data
tar -xzf "$BACKUP_DIR/data.tar.gz" -C /opt/certmate/
# Set permissions
chown -R 1000:1000 /opt/certmate/certificates
chmod -R 700 /opt/certmate/certificates
# Start services
docker-compose up -d
echo "Recovery completed from backup: $BACKUP_DATE"
# Basic health check
curl -f http://localhost:8000/health
# Detailed health with auth
curl -H "Authorization: Bearer your_token" \
http://localhost:8000/api/certificates
Nothing to add: /metrics is already a registered route
(modules/web/misc_routes.py), and the metrics
themselves are declared in
modules/core/metrics.py. This section used to
show a snippet headed "Add to app.py", which would have registered a second,
unauthenticated /metrics on top of the real one.
A ready-to-import Grafana dashboard, Prometheus alert rules, and an
authenticated scrape config ship in monitoring/ — see
monitoring/README.md. The /metrics endpoint requires
the viewer role, so scrape it with a viewer-scoped API token (Bearer) — a
scraper reads, so it does not need admin.
# docker-compose.logging.yml
version: '3.8'
services:
certmate:
logging:
driver: "fluentd"
options:
fluentd-address: localhost:24224
tag: certmate
fluentd-async-connect: "true"
fluentd:
image: fluent/fluentd:v1.14
volumes:
- ./fluentd/conf:/fluentd/etc
- ./logs:/var/log/fluentd
ports:
- "24224:24224"
- "24224:24224/udp"
{
"dashboard": {
"title": "CertMate SSL Certificate Monitoring",
"panels": [{
"title": "Certificate Expiry Status",
"targets": [{
"expr": "certmate_certificate_expiry_days < 30",
"legendFormat": "Expiring Soon ({{domain}})"
}
],
"alert": {
"conditions": [{
"query": {"queryType": "", "refId": "A"},
"reducer": {"type": "last", "params": []},
"evaluator": {"params": [30], "type": "lt"}
}
],
"executionErrorState": "alerting",
"frequency": "1h",
"handler": 1,
"name": "Certificate Expiring",
"noDataState": "no_data",
"notifications": []
}
}
]
}
}
CertMate includes a built-in notification system configurable from Settings > Notifications:
{{placeholders}} so the body fits whatever receives it — see docs/webhooks.mdAll notification channels support per-event filtering (created, renewed, expiring, failed) and can be tested from the settings UI.
Microsoft Teams: no dedicated adapter is needed. Use a Teams channel's built-in email address (channel → ... → Get email address) as a recipient on the Email (SMTP) channel — Teams posts the message into the channel for you.
Downgrading to a version older than the one that wrote settings.json is not supported and may result in a broken configuration or loss of accounts. If you see a DOWNGRADE DETECTED message in the logs after rolling back, restore the most recent backup that can actually restore.
Not every backup is a restore point. Unless
CERTMATE_BACKUP_PASSPHRASEwas set when the backup was taken, automatic backups are written with secrets masked and cannot restore this instance — they are configuration snapshots. On an instance that has never had a passphrase set, the newest archive is almost certainly one of those, so picking "the latest" by timestamp picks one that will be refused. Ask the API which ones qualify: it reportscan_restoreper archive, andrestore_blocked_reasonwhen it is false.
# Which backups can actually restore, newest first
curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/backups \
| jq -r '.unified[] | "\(.filename) can_restore=\(.can_restore) \(.restore_blocked_reason // "")"'
# Restore the newest one whose can_restore is true
curl -H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename": "backup_YYYYmmdd_HHMMSS_ffffff_<reason>.zip", "create_backup_before_restore": true}' \
http://localhost:8000/api/backups/restore/unified
If nothing reports can_restore: true, this instance has no restore point.
Set CERTMATE_BACKUP_PASSPHRASE, take a backup immediately, and keep a copy
off this node — see Backup and Recovery.
If you have lost the admin password and cannot log in, use the emergency reset script (requires container shell access):
docker exec -it certmate python scripts/reset_admin_password.py
Issue: Certificate creation failed: ... with a DNS-01 challenge error (the text after the prefix is certbot's own)
# Check DNS propagation
dig TXT _acme-challenge.example.com @8.8.8.8
# Verify DNS provider credentials
curl -H "Authorization: Bearer cf_token" \
"https://api.cloudflare.com/client/v4/user/tokens/verify"
Issue: Rate limit exceeded
# Let's Encrypt rate limits:
# - 50 certificates per registered domain per week
# - 5 duplicate certificates per week
# - 300 new orders per account per 3 hours
# Check rate limit status
curl "https://crt.sh/?q=example.com&output=json" | jq length
Issue: Permission denied accessing certificate files
# Fix file permissions
sudo chown -R certmate:certmate /opt/certmate/certificates
sudo chmod -R 700 /opt/certmate/certificates
sudo chmod 600 /opt/certmate/certificates/*/privkey.pem
Issue: 401 Unauthorized
# Verify token format
curl -H "Authorization: Bearer your_token_here" \
http://localhost:8000/api/certificates
# The token cannot be read back: settings.json stores only an HMAC of it
# (api_bearer_token_hash). Compare against what you set in API_BEARER_TOKEN
# or API_BEARER_TOKEN_FILE.
Issue: lost or forgotten API token
# Set a new token and restart: at startup, the token from API_BEARER_TOKEN
# (or API_BEARER_TOKEN_FILE) replaces the stored one.
# (32+ characters; a malformed token is ignored and the old one stays.)
openssl rand -hex 32 # put the output in .env as API_BEARER_TOKEN=...
docker-compose up -d --force-recreate certmate
Issue: Container won't start
# Check logs
docker-compose logs certmate
# Verify environment variables
docker-compose config
# Check port conflicts
netstat -tulpn | grep :8000
Issue: Volume mount issues
# Fix volume permissions
sudo chown -R 1000:1000 ./certificates ./data ./logs
# Check volume mounts
docker inspect certmate | jq '.[0].Mounts'
Q: All my certificates show "Backend: Unreachable" even though they are issued, served, and downloadable. Is something broken?
No. The deployment-status badge is an optional health indicator — it does not affect issuance, renewal, or download. CertMate's own process opens a TLS connection to <domain>:<port> and compares the served certificate's fingerprint against the stored one. The badge only answers whether CertMate itself can reach the service and see the expected certificate.
Configuring the probe per certificate
By default the probe connects on port 443 with a direct TLS handshake (HTTPS). You can change the port and protocol per certificate via the API or the dashboard:
| Protocol | Port (default) | Use case |
|---|---|---|
https-tls | 443 | Standard HTTPS |
tls | 465 | SMTPS, IMAPS, or any direct TLS service |
smtp-starttls | 587 | SMTP with STARTTLS (port 25 also works) |
From the dashboard, open a certificate's detail panel and click Configure Probe, then enter the port and protocol.
From the API:
# Set probe to SMTP STARTTLS on port 587
curl -X PATCH https://certmate.example.com/api/certificates/example.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"deployment_port": 587, "deployment_protocol": "smtp-starttls"}'
# Reset to defaults (HTTPS on port 443)
curl -X PATCH https://certmate.example.com/api/certificates/example.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"deployment_port": null, "deployment_protocol": "https-tls"}'
The probe protocol defaults to the global timeout (3s) and can be tuned via the CERTMATE_TLS_PROBE_TIMEOUT_SECONDS environment variable.
Global timeout tuning:
# Accepts 1–30 seconds; default is 3
CERTMATE_TLS_PROBE_TIMEOUT_SECONDS=10
"Unreachable" for every certificate is common in Kubernetes/ingress setups where the CertMate pod cannot dial the public/ingress IP directly (split-horizon DNS, an egress NetworkPolicy, or TLS terminated by an ingress controller / load balancer). If the target is merely slow, raise the probe budget or configure the port/protocol to match your topology.
See docs/kubernetes.md for the Kubernetes-specific note. (Reference: #263.)
Cloudflare:
# Verify API token permissions
curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer your_token_here"
# Check zone access
curl -X GET "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer your_token_here"
AWS Route53:
# Test AWS credentials
aws sts get-caller-identity
# Check Route53 permissions
aws route53 list-hosted-zones
Azure DNS:
# Verify service principal
az login --service-principal \
-u $AZURE_CLIENT_ID \
-p $AZURE_CLIENT_SECRET \
--tenant $AZURE_TENANT_ID
# Check DNS zone access
az network dns zone list
Enable debug logging for troubleshooting:
# Docker / gunicorn: CERTMATE_LOG_LEVEL. The stock docker-compose.yml does not
# pass it through, so add it under the certmate service's environment: and
# recreate the container
- CERTMATE_LOG_LEVEL=DEBUG
# Development server: --log-level overrides CERTMATE_LOG_LEVEL (default INFO)
python app.py --debug --log-level DEBUG
# FLASK_ENV=production makes --debug refuse to start, by design
FLASK_ENV=development
CertMate ships an in-app bug reporter that produces an actionable issue with one click. When an action fails and you're signed in as admin, the error toast surfaces a Report this issue button. Clicking it:
GET /api/diagnostics/snapshot (admin-only) to collect sanitised operational state — CertMate version, Python version, OS, scheduler status, certificate count, DNS provider name, CA name, challenge type, storage backend, disk free, and the last 5 audit-log entries with all identifiers (resource_id, user, IP address, details, error) stripped.endpoint / status / code / message / hint).github.com/fabriziosalmi/certmate/issues/new?template=bug_report.md with the title pre-filled as [Bug] <status> <code> on <method> <endpoint>.Paste the clipboard contents into the issue body, edit if you want, submit. The flow is fully manual — nothing leaves the install without your explicit click, and you can read everything before submitting (it's right there in the textarea). If your browser blocks the clipboard write or pop-ups, the reporter falls back to a modal with the markdown in an editable textarea and a clickable GitHub link.
If the in-app reporter is unavailable (you're not signed in as admin, or you hit the bug before reaching the UI), please provide:
# Collect system information manually (equivalent of the in-app snapshot)
echo "=== CertMate Debug Info ==="
echo "Version: $(docker exec certmate python -c 'import modules; print(modules.__version__)')"
echo "Python: $(docker exec certmate python --version)"
echo "OS: $(docker exec certmate cat /etc/os-release | head -2)"
echo "Certbot: $(docker exec certmate certbot --version)"
echo "DNS Plugins: $(docker exec certmate pip list | grep certbot-dns)"
# DO NOT paste settings.json verbatim into a public issue — it contains
# credentials. The /api/diagnostics/snapshot endpoint returns a redacted
# subset; use it instead:
curl -sS -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/diagnostics/snapshot | jq .
Prefer watching? CertMate in 60 seconds is a video guide of 24 one-minute shorts, made from the documentation on certmate.org.
| Document | Description | Target Audience |
|---|---|---|
| README.md | Main documentation and quick start | All users |
| docs/installation.md | Installation and deployment | System administrators |
| docs/dns-providers.md | DNS provider setup | DevOps engineers |
| docs/custom-dns-script.md | Any DNS provider with no plugin, via your own hooks | DevOps engineers |
| docs/ca-providers.md | Certificate Authority configuration | Enterprise users |
| docs/docker.md | Docker and multi-platform builds | DevOps engineers |
| docs/testing.md | Testing framework and CI/CD | Developers |
| docs/architecture.md | System architecture | Developers |
| docs/api.md | Client certificates API reference | Developers |
| docs/mcp.md | MCP server for AI agents: tools, auth, audit attribution | Developers, SRE |
| docs/guide.md | Step-by-step guide for common tasks | All users |
| docs/discovery-inventory.md | Discovery, inventory, adopt, crypto readiness, domain registration + health | SRE, security |
| docs/deploy-hooks.md | Post-issuance deploy hooks | DevOps engineers |
| docs/csr-only-certificates.md | Issuing from a CSR when the key stays on the device | Appliance operators |
| docs/webhooks.md | Generic webhooks: payload templates, auth, signature verification | Integrators |
| docs/compliance.md | Audit chain, attribution, NIS2/eIDAS posture | Compliance, security |
| docs/kubernetes.md | Install with Helm, Argo CD or Flux; pod sizing, OOM troubleshooting | SRE |
| docs/probes.en.md | Deployment probe configuration | DevOps engineers |
| CONTRIBUTING.md | Development and contribution guide | Developers |
| CODE_OF_CONDUCT.md | Community guidelines | Contributors |
Check out our examples repository for:
We welcome contributions! Areas where we need help:
Running CertMate in production? I offer paid support, custom development, and security consulting — certificate/TLS automation, hardening, WAF, and cloud detection & alerting. Reach out: [email protected].
We love contributions! CertMate is an open-source project and we welcome:
# Fork and clone the repository
git clone https://github.com/fabriziosalmi/certmate.git
cd certmate
# Create the development environment (.venv, from requirements.lock + the test requirements)
make setup
source .venv/bin/activate
# Lint, security-scan and test: the checks CI gates on (`make help` lists the rest)
make check
# Start development server
python app.py
A bare pytest is not the way to run the suite: it includes the Playwright ui tests, which
need Docker. make test runs the selection CONTRIBUTING.md documents.
git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)This project is licensed under the MIT License - see the LICENSE file for details.
Made with ❤️ by Fabrizio Salmi
Star us on GitHub • Report Bug • Request Feature
348 followers · starred Jul 2025
366 followers · starred Jun 2025
271 followers · starred Jul 2025
8 followers · starred Jan 2026