Production-grade certificate distribution platform for Traefik ACME, Let's Encrypt, ZeroSSL, internal CAs, and future certificate providers.
The goal of CDS is to eliminate manual SSL certificate management across large-scale deployments.
Instead of every customer server requesting certificates directly from Let's Encrypt, a central Certificate Manager will obtain, monitor, validate and distribute certificates securely to hundreds or thousands of servers.
This reduces:
- Let's Encrypt API requests
- Certificate renewal overhead
- Manual deployments
- Operational complexity
- Risk of rate limiting
Current workflow:
Server 1 ---> Let's Encrypt
Server 2 ---> Let's Encrypt
Server 3 ---> Let's Encrypt
...
Server 300 ---> Let's Encrypt
Problems:
- Hundreds of ACME requests
- Rate limit risk
- Difficult certificate management
- No centralized monitoring
- Manual renewals
- Difficult auditing
Let's Encrypt
│
▼
Traefik (ACME)
acme.json
│
▼
Certificate Distribution Service (Manager)
│
Secure REST API + mTLS
│
┌───────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
Certificate Agent Certificate Agent Certificate Agent
Customer 1 Customer 2 Customer N
│ │ │
▼ ▼ ▼
Nginx Nginx Nginx
- Read Traefik ACME certificates
- Support multiple ACME resolvers
- Parse unlimited certificates
- Export certificates securely
- Distribute certificates to remote agents
- Automatically reload Nginx
- Production-grade monitoring
- Production-grade security
- Kubernetes ready
- Docker ready
- Open source
cmd/
manager/
internal/
acme/
api/
certs/
config/
metrics/
storage/
logger/
utils/
examples/
docs/
scripts/
tests/
Status:
✅ Completed
Completed items
- Go module initialized
- Versioned API
- Health endpoint
- Version endpoint
- Configuration package
- Structured logging
- Gin router
- Prometheus metrics endpoint
- Basic project structure
- Successful build
- Docker-ready project layout
Status: ✅ Completed
- Read ACME file
- Validate JSON
- Parse into Go structures
- Store in memory
- Expose certificate metadata through API (
GET /api/v1/certificates,GET /api/v1/certificates/:domain)
Only certificate metadata (domain, SANs, issuer, serial number, validity window) is exposed. Private keys are never returned by the API.
Status: ✅ Completed
- File Watcher — the ACME file is now watched continuously (
internal/acme/watcher.go, fsnotify) and the in-memory inventory + on-disk export are reloaded automatically whenever it changes, instead of only at startup. - Certificate Validation — metadata now includes
expiringSoon(withinCERT_EXPIRY_WARN_DAYS, default 30) andnotYetValidalongsideexpired. - Certificate Export —
fullchain.pem/privkey.pemare written per domain underEXPORT_DIR/<domain>/, atomically (write-temp-then-rename) with0644/0600permissions respectively. - Storage Layer —
internal/storagenow exposes aRepositoryinterface; the in-memoryStoreis one implementation, so a persistent backend can be added later without touching callers.
Status: ✅ Completed
GET /dashboard serves a same-origin HTML/JS page that exercises every route the service exposes and reports pass/fail, for checking the API by eye during development instead of hand-typing curl commands. Not part of the public API contract.
Status: ✅ Completed
- API Authentication —
API_KEY_READONLY/API_KEY_ADMINgate the certificate endpoints and the reload endpoint via anX-API-Keyheader (orAuthorization: Bearer). With neither key configured, the API fails closed (503) rather than serving unauthenticated.GET /api/v1/health,GET /api/v1/version,GET /metrics, andGET /dashboardstay public. - RBAC — two roles,
readonlyandadmin(admin satisfies either requirement). Reading certificate data needs either key; the newPOST /api/v1/reloadendpoint (manually triggers the same reload the file watcher does) needs the admin key. - Audit Logging — every authenticated request logs a structured
"audit": trueentry (method, path, status, role, remote IP, duration) alongside the existing application logs. - mTLS —
TLS_CERT/TLS_KEYenable HTTPS; additionally settingTLS_CLIENT_CArequires clients to present a certificate signed by that CA (mutual TLS). With none of the three set, the server runs plain HTTP (local dev, or behind an external TLS terminator).
Status: ✅ Completed
A second binary, cmd/agent, that runs on customer servers:
- Certificate Agent (core) — polls the manager on
POLL_INTERVAL_SECONDSfor each domain inDOMAINS. Supports mTLS to the manager viaTLS_CLIENT_CERT/TLS_CLIENT_KEY/TLS_CA. - Download Engine — fetches
GET /api/v1/certificates/:domain/bundle(new manager endpoint, gated by a new least-privilegeagentrole/API_KEY_AGENT— never the readonly or admin key) and reads the cert + private key it returns. - Atomic Installation — writes
fullchain.pem/privkey.pemtoINSTALL_DIR/<domain>/the same write-temp-then-rename way the manager's exporter does, and detects whether the content actually changed so unchanged polls don't trigger a reload. - Nginx Integration — runs
NGINX_RELOAD_CMD(defaultnginx -s reload) only when a certificate actually changed.
The bundle endpoint reads whatever the manager's exporter already wrote to EXPORT_DIR, so Milestone 3's Certificate Export directly backs Milestone 5's Download Engine — no separate storage was added for raw key material.
Dockerfile.agent builds the agent alongside Nginx in one image (they run on the same host in a real deployment, since the agent needs to signal Nginx); see deploy/agent/ for the reference Nginx config and entrypoint.
- Bootstrap
- ACME Reader
- ACME Parser
- Certificate Inventory API
- Certificate Export
- File Watcher
- Storage Layer
- Certificate Validation
- API Authentication
- mTLS
- RBAC
- Audit Logging
- Certificate Agent
- Download Engine
- Atomic Installation
- Nginx Integration
- Prometheus Metrics
- Grafana Dashboards
- Alerting
- Docker
- Kubernetes
- GitHub Actions
- Documentation
- Production Release
Never commit directly to main.
Example:
main
develop
feature/acme-reader
feature/acme-parser
feature/export-engine
feature/certificate-agent
Use Conventional Commits.
Examples
feat(api): add health endpoint
feat(acme): implement parser
fix(export): handle empty certificate
docs(readme): update roadmap
refactor(storage): simplify cache
Every Pull Request must:
- Build successfully
- Pass all tests
- Pass
go vet - Pass
golangci-lint - Pass security scans
- Build Docker image
- Update documentation when applicable
Repository uses:
- GitHub Projects
- GitHub Milestones
- GitHub Labels
- GitHub Issues
- Pull Requests
- GitHub Actions
Development workflow:
Issue
↓
Feature Branch
↓
Commit
↓
Pull Request
↓
CI Validation
↓
Code Review
↓
Merge
No Pull Request may be merged unless:
- CI passes
- Go build passes
- Go tests pass
- gofmt passes
- go vet passes
- golangci-lint passes
- govulncheck passes
- Trivy passes
- Gitleaks passes
- Docker build succeeds
- Documentation updated (when applicable)
- Never commit
acme.json - Never commit certificates
- Never commit private keys
- Never hardcode domains
- Never hardcode secrets
Repository uses example domains only:
example.com
*.example.com
Everything must be configurable through environment variables.
Examples
PORT
LOG_LEVEL
ACME_FILE
EXPORT_DIR
CERT_EXPIRY_WARN_DAYS
API_KEY_READONLY
API_KEY_ADMIN
API_KEY_AGENT
TLS_CERT
TLS_KEY
TLS_CLIENT_CA
The agent is a separate binary/process with its own environment variables:
MANAGER_URL
API_KEY
DOMAINS
POLL_INTERVAL_SECONDS
INSTALL_DIR
NGINX_RELOAD_CMD
TLS_CLIENT_CERT
TLS_CLIENT_KEY
TLS_CA
./scripts/generate-demo-data.sh # generates .env (random API keys) and ./.demo (real, throwaway certs) — neither is ever committed
docker compose up -d --build
docker compose logs -f cds agent # watch both services liveBoth the certs and the API keys are generated locally by that script and gitignored — nothing key-shaped ever lives in this repo's tracked files or history, so a secret scanner has nothing to (correctly or incorrectly) flag.
This starts three containers:
export-init— a one-shot container that fixes permissions on theexport-datavolume so the manager (which runs as a non-root user) can write to it, then exits. Without this, exporting a real certificate fails with a permission error the first time the volume is created (the redacted placeholder keys inexamples/acme.sample.jsonnever hit this, since they fail to decode before ever reaching the filesystem write).cds— the manager, on:8080, loaded with 3 demo certificates (one expiring in ~4 days, to showexpiringSoonin action).agent— the Certificate Agent + Nginx, on:8443. It polls the manager every 10s foragent-demo.example.com, installs the certificate it gets back, and reloads Nginx only when the certificate actually changed.
To see it prove itself end to end:
# Nginx is serving the real certificate the manager handed the agent, not a placeholder:
# (uses openssl rather than curl -v — Windows' bundled curl uses the Schannel
# TLS backend, whose -v output has no "subject:" line at all, unlike
# OpenSSL-backed curl builds on Linux/macOS; openssl s_client behaves the
# same everywhere)
echo | openssl s_client -connect localhost:8443 2>/dev/null | openssl x509 -noout -subject
# Full certificate inventory, including the expiring one (reads the key .env just generated):
curl -s -H "X-API-Key: $(grep API_KEY_READONLY .env | cut -d= -f2)" http://localhost:8080/api/v1/certificatesTear down with docker compose down (add -v to also drop the export-data volume).
API_KEY should be the manager's API_KEY_AGENT value — never the readonly or admin key. DOMAINS is a comma-separated list; the agent only ever learns about the domains it's explicitly configured for.
Dockerfile.agent bundles the agent with Nginx because nginx -s reload signals a local process — that only works when they share a container. If Nginx already runs on its own (its own container, its own image you don't want to touch), use this topology instead:
Dockerfile.agent-only— the agent alone, no Nginx bundled. Just fetches from the manager and writes into a shared volume.Dockerfile.reload-watcher(cmd/reload-watcher) — a tiny separate process that watches that same shared volume and reloads Nginx when something changes. It runs in its own container but shares Nginx's PID namespace (pid: "service:nginx"in Compose, or the same Pod in Kubernetes), so it can sendnginxaSIGHUPdirectly — no Docker socket access, no shared/runvolume, and no changes to your existing Nginx image at all.
agent (own container) --writes--> [shared cert volume] <--reads-- nginx (your existing, untouched image)
^
reload-watcher (own container, shares nginx's PID namespace)
Try it:
./scripts/generate-demo-data.sh
docker compose -f docker-compose.separate.yml -p cds-separate up -d --build
docker compose -f docker-compose.separate.yml -p cds-separate logs -f agent reload-watcherThis starts cds (manager), agent (standalone), a stock nginx:alpine (standing in for your own already-deployed Nginx, completely unmodified), and reload-watcher alongside it.
Note: NGINX_RELOAD_CMD has no default — it's opt-in, not assumed. Set it on the agent only if it's the one with Nginx access (the combined Dockerfile.agent topology); leave it unset when a separate reload-watcher handles reload instead.
Tear down with docker compose -f docker-compose.separate.yml -p cds-separate down -v.
CDS should not be tightly coupled to Traefik.
Future providers should include:
- Traefik
- Let's Encrypt
- ZeroSSL
- HashiCorp Vault
- cert-manager
- Internal CA
- AWS ACM
- Azure Key Vault
- High Availability
- Multi-tenancy
- Web UI
- gRPC API
- WebSocket Notifications
- Cluster Support
- Automatic Agent Registration
- Plugin Architecture
This project follows several principles:
- Every commit must build successfully.
- Every feature is developed in a dedicated branch.
- Every Pull Request must pass all quality gates.
- Every new feature should include documentation updates when needed.
- Security takes priority over convenience.
- Avoid hardcoded values and company-specific information.
- Keep components modular so additional certificate providers can be added without major refactoring.
For every implementation:
- Create GitHub Issue
- Create feature branch
- Implement feature
- Run tests
- Update documentation
- Open Pull Request
- CI validation
- Review
- Merge
Current Version:
v0.1.0
Current Milestone:
Milestone 5 / Phase 4 (completed)
Current Task:
Begin Phase 5: Prometheus Metrics, Grafana Dashboards, Alerting
Next Tasks:
- Agent-side metrics (poll success/failure, install/reload counts)
- Grafana dashboards for manager + agent fleet visibility
- Alerting on expiring/failed certificates and agent staleness
This repository is intended to be developed with AI-assisted engineering tools (Claude Code, ChatGPT, Codex, etc.).
Requirements:
- Always maintain production-quality code.
- Follow Go best practices.
- Keep packages small and focused.
- Avoid introducing breaking changes without justification.
- Prefer clear abstractions over tightly coupled implementations.
- Keep the project provider-agnostic so additional certificate backends can be added in the future.
- Always ensure
go build,go test, andgo vetsucceed before considering a milestone complete.