No description
  • Go 59.4%
  • Shell 28.4%
  • Dockerfile 12.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Philipp Harms f83297d791
All checks were successful
build / image (push) Successful in 1m59s
add nsupdate for rfc2136 challegens
2026-08-23 02:46:15 +02:00
.forgejo/workflows feat: acme.sh cert container with JSON API and Forgejo CI 2026-08-22 22:25:20 +02:00
api feat: acme.sh cert container with JSON API and Forgejo CI 2026-08-22 22:25:20 +02:00
docs/superpowers/specs feat: acme.sh cert container with JSON API and Forgejo CI 2026-08-22 22:25:20 +02:00
.dockerignore feat: acme.sh cert container with JSON API and Forgejo CI 2026-08-22 22:25:20 +02:00
Dockerfile add nsupdate for rfc2136 challegens 2026-08-23 02:46:15 +02:00
entrypoint.sh feat: acme.sh cert container with JSON API and Forgejo CI 2026-08-22 22:25:20 +02:00
README.md feat: acme.sh cert container with JSON API and Forgejo CI 2026-08-22 22:25:20 +02:00
reload-api.sh feat: acme.sh cert container with JSON API and Forgejo CI 2026-08-22 22:25:20 +02:00
renew.sh feat: acme.sh cert container with JSON API and Forgejo CI 2026-08-22 22:25:20 +02:00

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 with write: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 .