- Go 59.4%
- Shell 28.4%
- Dockerfile 12.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| api | ||
| docs/superpowers/specs | ||
| .dockerignore | ||
| Dockerfile | ||
| entrypoint.sh | ||
| README.md | ||
| reload-api.sh | ||
| renew.sh | ||
cliencontrol-ca
A ~20 MB container that keeps a TLS certificate up to date with acme.sh (DNS-01) and serves it as a JSON object over plain HTTP.
Security
The API hands out the private key. By default there is no authentication. Bind it to an internal network only and never publish the port:
ports: [] # no host mapping
networks: [internal]
Set API_TOKEN to require Authorization: Bearer <token> on /. /healthz
stays unauthenticated so HEALTHCHECK keeps working.
Usage
services:
ca:
image: git.example.com/you/cliencontrol-ca:latest
environment:
DOMAINS: "example.com,*.example.com"
DNS_API: dns_cf
ACME_EMAIL: admin@example.com
CF_Token: ${CF_TOKEN}
# API_TOKEN: ${API_TOKEN}
volumes:
- acme:/acme.sh
networks: [internal]
volumes:
acme:
Persist /acme.sh. It holds the ACME account key and the issued certificates.
Without it every restart registers a new account and requests a new
certificate, which runs into the Let's Encrypt rate limit of 50 certificates
per registered domain per week.
Environment
| Variable | Default | Notes |
|---|---|---|
DOMAINS |
— | Required. Comma-separated. The first entry is the main domain, the rest become SANs. Wildcards are just entries: example.com,*.example.com |
DNS_API |
— | Required. The acme.sh DNS hook, e.g. dns_cf, dns_hetzner, dns_desec |
ACME_EMAIL |
— | Required |
ACME_SERVER |
letsencrypt |
Any value acme.sh --server accepts |
KEY_TYPE |
ec-256 |
Passed to --keylength. ec-* selects the ECC certificate |
CRON_SCHEDULE |
0 3 * * * |
Renewal check |
LISTEN |
:8080 |
|
API_TOKEN |
empty | Empty means no authentication |
CERT_DIR |
/certs |
|
ACME_HOME |
/acme.sh |
Provider credentials go in as-is — acme.sh reads them straight from the environment. See the dnsapi list for the variable names your provider needs.
Only DNS-01 is supported. That is what wildcards require, and it works without exposing any port to the internet.
API
GET /
{
"domain": "example.com",
"domains": ["example.com", "*.example.com"],
"cert": "-----BEGIN CERTIFICATE-----\n...",
"key": "-----BEGIN PRIVATE KEY-----\n...",
"fullchain": "-----BEGIN CERTIFICATE-----\n...",
"ca": "-----BEGIN CERTIFICATE-----\n...",
"not_before": "2026-08-22T10:00:00Z",
"not_after": "2026-11-20T10:00:00Z",
"serial": "72890ca1308f...",
"updated_at": "2026-08-22T10:00:04Z"
}
domains comes from the certificate itself, so it shows what was actually
issued rather than what was requested.
GET /healthz returns 200 while a certificate is loaded and unexpired, 503
otherwise.
Behaviour
The certificate is issued before the API starts listening, so a request never
sees a half-written bundle. After a renewal acme.sh sends SIGHUP and the API
re-reads the files in place — no restart. If that re-read fails the previous
certificate stays in service and the error is logged.
Changing DOMAINS triggers a forced reissue on the next start; the normalized
list is kept in /acme.sh/.domains for the comparison.
CI
.forgejo/workflows/build.yml builds on pushes to the default branch, on v*
tags, and on pull requests (build only, no push).
Tags pushed: latest on the default branch, sha-<short> always, and
<version> plus <major>.<minor> for v* tags.
Required repository setup:
- Secret
REGISTRY_TOKEN— a Forgejo access token withwrite:package. - Optional variable
REGISTRY— overrides the registry host, which otherwise defaults to the Forgejo instance hosting the repository.
Local build
docker build -t cliencontrol-ca .