Repository files navigation

acme.api

Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to acme.sh.

Warning

This project is entirely experimental currently. Do not use it for production, important certificates, or anything you are not prepared to delete and rebuild.

acme.api is meant to be consumed only by applications that enforce strict access controls as part of the application stack — it is never intended for direct exposure on the internet or general internal systems. A system like this has severe security implications if misconfigured or misused, and the author is not responsible for any damage caused by its use. I considered a tool like this to be necessary for some specific systems I was working on, and I'd have had to essentially build it regardless as part of that other system, so I thought why not genericize it and share it.

Status

Prototype v1 implementation. The core API, SQLite state, API key auth, acme.sh backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, Docker packaging, mock-backed end-to-end integration tests, and GitHub Actions CI are implemented. Real staging/Pebble ACME tests remain optional and credential-gated.

Quick Start

Build and start the local container:

make build
make start
curl http://localhost:8080/health
curl http://localhost:8080/ready

The compose file uses named volumes for persistent runtime state:

VolumeContainer pathPurpose
acme-api-data/dataSQLite database
acme-api-certificates/certificatesAtomically deployed certificate files
acme-api-acmesh/acmeshacme.sh account and certificate state

The bundled compose config at docker/config.yaml is intentionally minimal so the service can boot for health checks. For real issuance, copy config.example.yaml, configure ACME accounts, DNS provider aliases, and credential file mounts, then set ACME_API_CONFIG to that file inside the container.

Local Development

make dev
make verify

Useful targets:

CommandDescription
make testRun the unit, integration, and ordinary end-to-end suites plus coverage gates
make test-unitRun deterministic unit tests
make test-integrationRun mock-backed integration tests
make test-e2eRun the Pebble-backed Docker Compose end-to-end test stack
make deps-checkVerify uv.lock and both hashed requirements exports
make deps-updateUpgrade dependencies and regenerate uv.lock and exports
make format-checkCheck Ruff formatting
make lintRun Ruff and Pylint
make type-checkRun strict type checking
make verifyRun the full local quality and test gate
make simulate-ciExecute the GitHub Actions workflow locally with act
make buildBuild the Docker image
make startStart the Docker compose service
make stopStop the Docker compose service
make logsFollow container logs

Development uses uv for locking and export verification. make dev follows Vulpine's hashed-install workflow: it bootstraps .venv and installs requirements-dev.txt with --require-hashes --no-deps.

Configuration

Configuration is YAML. By default the app loads ./config.yaml; set ACME_API_CONFIG=/path/to/config.yaml to override it. See config.example.yaml for a complete reference.

Certificate issuance requires an acme_accounts entry and an authenticated API client. Fresh acme.api installations intentionally create no API clients:

printf'%s'"$ADMIN_KEY"| acme-api admin initialize --key-stdin

This stdin-only command is the one-time local administrative trust boundary and can run only while the persisted API-client table is empty. It creates the initial admin client; afterward, authenticated admins create, rotate, revoke, and list admin, operator, and readonly clients at /v1/admin/clients. Configuration has no api_keys setting. If every admin credential is lost, stop the service, back up the database, and remove only the API-client records; preserve certificate, account, renewal, deployment, and audit rows. Standard DNS-01 issuance additionally requires a configured dns_providers alias and a provider credential file readable by the container. DNS Persist issuance does not require either: its one-time TXT record is generated from the selected account.

Deployment configuration

deployment.directory is the artifact root. Mount it read/write only in the acme.api container and read-only in certificate consumers. permissions_cert and permissions_key are decimal file modes; their defaults are 420 (0644) and 384 (0600) respectively.

Set deployment.artifact_group_id only when a separate unprivileged consumer must read private keys. It is a numeric GID, not a group name, and acme.api must run with that GID as a supplementary group. If it cannot assign the group to a deployment directory or artifact, issuance or renewal records a deployment failure rather than publishing an unexpected access policy. A typical shared-volume configuration uses permissions_key: 416 (0640) and grants

read-only consumers membership in the same GID. When configured, acme.api sets directories it owns—or whose group it changes—to that group with 0750 mode, ensuring consumers can traverse them even with a restrictive umask. Pre-provisioned directories owned by another user retain their ownership and mode only when they already belong to the configured GID, so a non-root service can use an administrator-managed volume without CAP_CHOWN.

Enable deployment.generation_aware to preserve every successful issuance or renewal as an immutable artifact set. acme.api publishes a complete generation directory, then atomically switches the current symlink. The established cert.pem, chain.pem, fullchain.pem, privkey.pem, and metadata.json paths become symlinks through that pointer, so existing consumers keep their predictable paths. generation_retention_count and generation_retention_days may be set independently; a generation is removed only when it exceeds every configured limit. The selected generation and any explicitly pinned generation are never removed.

Example certificate request:

{
"name": "wildcard-example",
"domains": ["*.example.com", "example.com"],
"acme_account_ref": "letsencrypt-production",
"dns_provider_ref": "production",
"key_algorithm": "ecdsa"
}

DNS Persist certificates

For a zone managed manually, create a request with "challenge_method": "dns-persist" and omit dns_provider_ref. The response remains pending_dns and contains an account-bound TXT instruction at _validation-persist.<primary-domain>. Publish that exact value and retain it for the certificate's lifetime, then call POST /v1/certificates/{id}/authorize. The service issues with the selected account only after that explicit authorization. DNS Persist SANs must be the primary domain or its subdomains. Multi-SAN and wildcard requests receive a policy=wildcard instruction, which deliberately authorizes that primary domain's subdomains; use separate requests for unrelated domains.

Creation with the same name, domains, and account resumes the stored request and instruction; it does not create another ACME order. A different account creates a distinct instruction and cannot replace an existing request's account. Once valid, DNS Persist certificates renew unattended through the normal scheduler without DNS provider credentials or another TXT update. The instruction is returned only from authenticated certificate endpoints.

Held DNS Persist workflow

Set "held": true when creating a DNS Persist request to persist its stable TXT instruction without allowing issuance. After publishing the record, call POST /v1/certificates/{id}/authorize; this advances the request to authorization_ready but still does not issue. To release the current prepared revision, call POST /v1/certificates/{id}/release with an Idempotency-Key header and a JSON body containing the response's current revision, for example:

{"revision": 1}

Release is accepted only once for that revision and queues asynchronous issuance. Retry the same request with the same idempotency key if the client does not receive the response; a retry also re-queues issuance if the stored request is still released. Delete a held, authorization-ready, released, or release-derived issuing request to cancel it.

REST API

OpenAPI is generated at /openapi.json; Swagger UI is available at /docs.

MethodPathAuthDescription
GET/healthnoneLiveness probe with uptime
GET/readynoneDB and acme.sh readiness
POST/v1/certificatesoperatorCreate a certificate request; DNS Persist returns its stored TXT instruction
GET/v1/certificatesreadonlyList certificates
GET/v1/certificates/{id}readonlyRead certificate detail and DNS Persist instruction
POST/v1/certificates/{id}/authorizeoperatorAuthorize or retry DNS Persist issuance after publishing TXT
POST/v1/certificates/{id}/releaseoperatorRelease a held DNS Persist revision; requires Idempotency-Key and { "revision": n }
POST/v1/certificates/{id}/renewoperatorQueue manual renewal
POST/v1/certificates/{id}/revokeoperatorRevoke the issued primary domain through acme.sh; requires Idempotency-Key
DELETE/v1/certificates/{id}operatorSoft-delete as revoked
GET/v1/accountsreadonlyList configured ACME accounts
GET/v1/providersreadonlyList configured DNS providers
GET/v1/eventsreadonlyQuery audit events
GET/v1/admin/clientsadminList safe API-client metadata
POST/v1/admin/clientsadminCreate an API client and return its credential once
POST/v1/admin/clients/{id}/rotateadminRotate a client credential and return its replacement once
POST/v1/admin/clients/{id}/revokeadminRevoke an API client

Authenticated requests use bearer API keys:

curl \
-H "Authorization: Bearer $ACME_API_KEY" \
http://localhost:8080/v1/certificates

Certificate revocation

DELETE /v1/certificates/{id} only changes the local request record; it does not contact a certificate authority. To revoke an issued certificate at its configured CA, call POST /v1/certificates/{id}/revoke with an Idempotency-Key header and, optionally, an RFC 5280 reason:

curl -X POST \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Idempotency-Key: revoke-example-20260724" \
-H "Content-Type: application/json" \
--data '{"reason": 1}' \
http://localhost:8080/v1/certificates/$CERTIFICATE_ID/revoke

The operation invokes acme.sh as --revoke --domain <primary-domain> and adds --revoke-reason when requested. It does not delete deployed artifacts, disable renewal, or otherwise modify the local certificate record. Reusing the same key returns the durable original result without another acme.sh command. Reasons 0 through 10 are accepted except 7, which RFC 5280 leaves unused.

acme.sh selects the certificate it revokes from its managed domain and key-type slot; it does not accept a certificate file, serial number, fingerprint, or deployment generation. Generation selection only repoints acme.api's deployed artifact view and does not modify acme.sh's managed certificate. Consequently, CA revocation through this endpoint cannot target a retained historical generation independently of the certificate currently managed by acme.sh.

Certificate Deployment

Successful issuance and renewal deploy artifacts under the deployment_directory reported by every authenticated certificate API response, relative to the configured deployment root. For ordinary certificates it is the first requested domain:

/certificates/example.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

With generation-aware deployment enabled, each immutable publication is stored below a dedicated namespace:

/certificates/example.com/
current -> generations/<generation_id>
fullchain.pem -> current/fullchain.pem
privkey.pem -> current/privkey.pem
generations/<generation_id>/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

The generations/ component is intentional rather than redundant: it unambiguously separates immutable historical artifacts from the stable compatibility projection and future deployment control files. Wildcard domains use a portable collision-free name: a request for *.example.com reports deployment_directory: "@wildcard@.example.com" and deploys under /certificates/@wildcard@.example.com/. This cannot collide with a separate request for the valid literal name wildcard.example.com. Clients consuming the shared certificate volume must always resolve artifact paths from the API's deployment_directory field, never derive them from the requested identifier.

Files are copied to temporary names. acme.api assigns configured group and mode through each open file descriptor, then fsyncs the content and access-control metadata before atomically renaming artifacts into place. It also sets the deployment root and target directory to the configured group with 0750 traversal mode. The same process runs for initial issuance and renewal, so consumers never need to repair ownership or permissions themselves.

Architecture

 REST API
|
+---------------+---------------+
| |
Certificate Lifecycle Renewal Scheduler
| |
+---------------+---------------+
|
ACME Backend
|
acme.sh

The public API is independent of the ACME backend. v1 supports DNS-01 through acme.sh; future backends can be added behind the same internal protocol.

Non-Goals For v1

  • HTTP-01 validation
  • TLS-ALPN-01 validation
  • per-request DNS credentials
  • web UI
  • high availability or clustering
  • implementing the ACME protocol directly

About

acme.api is a lightweight, self-hosted REST service for managing ACME certificates; in the first instance focusing on acme.sh as a backend.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

acme.api

Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to acme.sh.

Warning

This project is entirely experimental currently. Do not use it for production, important certificates, or anything you are not prepared to delete and rebuild.

acme.api is meant to be consumed only by applications that enforce strict access controls as part of the application stack — it is never intended for direct exposure on the internet or general internal systems. A system like this has severe security implications if misconfigured or misused, and the author is not responsible for any damage caused by its use. I considered a tool like this to be necessary for some specific systems I was working on, and I'd have had to essentially build it regardless as part of that other system, so I thought why not genericize it and share it.

Status

Prototype v1 implementation. The core API, SQLite state, API key auth, acme.sh backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, Docker packaging, mock-backed end-to-end integration tests, and GitHub Actions CI are implemented. Real staging/Pebble ACME tests remain optional and credential-gated.

Quick Start

Build and start the local container:

make build
make start
curl http://localhost:8080/health
curl http://localhost:8080/ready

The compose file uses named volumes for persistent runtime state:

VolumeContainer pathPurpose
acme-api-data/dataSQLite database
acme-api-certificates/certificatesAtomically deployed certificate files
acme-api-acmesh/acmeshacme.sh account and certificate state

The bundled compose config at docker/config.yaml is intentionally minimal so the service can boot for health checks. For real issuance, copy config.example.yaml, configure ACME accounts, DNS provider aliases, and credential file mounts, then set ACME_API_CONFIG to that file inside the container.

Local Development

make dev
make verify

Useful targets:

CommandDescription
make testRun the unit, integration, and ordinary end-to-end suites plus coverage gates
make test-unitRun deterministic unit tests
make test-integrationRun mock-backed integration tests
make test-e2eRun the Pebble-backed Docker Compose end-to-end test stack
make deps-checkVerify uv.lock and both hashed requirements exports
make deps-updateUpgrade dependencies and regenerate uv.lock and exports
make format-checkCheck Ruff formatting
make lintRun Ruff and Pylint
make type-checkRun strict type checking
make verifyRun the full local quality and test gate
make simulate-ciExecute the GitHub Actions workflow locally with act
make buildBuild the Docker image
make startStart the Docker compose service
make stopStop the Docker compose service
make logsFollow container logs

Development uses uv for locking and export verification. make dev follows Vulpine's hashed-install workflow: it bootstraps .venv and installs requirements-dev.txt with --require-hashes --no-deps.

Configuration

Configuration is YAML. By default the app loads ./config.yaml; set ACME_API_CONFIG=/path/to/config.yaml to override it. See config.example.yaml for a complete reference.

Certificate issuance requires an acme_accounts entry and an authenticated API client. Fresh acme.api installations intentionally create no API clients:

printf'%s'"$ADMIN_KEY"| acme-api admin initialize --key-stdin

This stdin-only command is the one-time local administrative trust boundary and can run only while the persisted API-client table is empty. It creates the initial admin client; afterward, authenticated admins create, rotate, revoke, and list admin, operator, and readonly clients at /v1/admin/clients. Configuration has no api_keys setting. If every admin credential is lost, stop the service, back up the database, and remove only the API-client records; preserve certificate, account, renewal, deployment, and audit rows. Standard DNS-01 issuance additionally requires a configured dns_providers alias and a provider credential file readable by the container. DNS Persist issuance does not require either: its one-time TXT record is generated from the selected account.

Deployment configuration

deployment.directory is the artifact root. Mount it read/write only in the acme.api container and read-only in certificate consumers. permissions_cert and permissions_key are decimal file modes; their defaults are 420 (0644) and 384 (0600) respectively.

Set deployment.artifact_group_id only when a separate unprivileged consumer must read private keys. It is a numeric GID, not a group name, and acme.api must run with that GID as a supplementary group. If it cannot assign the group to a deployment directory or artifact, issuance or renewal records a deployment failure rather than publishing an unexpected access policy. A typical shared-volume configuration uses permissions_key: 416 (0640) and grants

read-only consumers membership in the same GID. When configured, acme.api sets directories it owns—or whose group it changes—to that group with 0750 mode, ensuring consumers can traverse them even with a restrictive umask. Pre-provisioned directories owned by another user retain their ownership and mode only when they already belong to the configured GID, so a non-root service can use an administrator-managed volume without CAP_CHOWN.

Enable deployment.generation_aware to preserve every successful issuance or renewal as an immutable artifact set. acme.api publishes a complete generation directory, then atomically switches the current symlink. The established cert.pem, chain.pem, fullchain.pem, privkey.pem, and metadata.json paths become symlinks through that pointer, so existing consumers keep their predictable paths. generation_retention_count and generation_retention_days may be set independently; a generation is removed only when it exceeds every configured limit. The selected generation and any explicitly pinned generation are never removed.

Example certificate request:

{
"name": "wildcard-example",
"domains": ["*.example.com", "example.com"],
"acme_account_ref": "letsencrypt-production",
"dns_provider_ref": "production",
"key_algorithm": "ecdsa"
}

DNS Persist certificates

For a zone managed manually, create a request with "challenge_method": "dns-persist" and omit dns_provider_ref. The response remains pending_dns and contains an account-bound TXT instruction at _validation-persist.<primary-domain>. Publish that exact value and retain it for the certificate's lifetime, then call POST /v1/certificates/{id}/authorize. The service issues with the selected account only after that explicit authorization. DNS Persist SANs must be the primary domain or its subdomains. Multi-SAN and wildcard requests receive a policy=wildcard instruction, which deliberately authorizes that primary domain's subdomains; use separate requests for unrelated domains.

Creation with the same name, domains, and account resumes the stored request and instruction; it does not create another ACME order. A different account creates a distinct instruction and cannot replace an existing request's account. Once valid, DNS Persist certificates renew unattended through the normal scheduler without DNS provider credentials or another TXT update. The instruction is returned only from authenticated certificate endpoints.

Held DNS Persist workflow

Set "held": true when creating a DNS Persist request to persist its stable TXT instruction without allowing issuance. After publishing the record, call POST /v1/certificates/{id}/authorize; this advances the request to authorization_ready but still does not issue. To release the current prepared revision, call POST /v1/certificates/{id}/release with an Idempotency-Key header and a JSON body containing the response's current revision, for example:

{"revision": 1}

Release is accepted only once for that revision and queues asynchronous issuance. Retry the same request with the same idempotency key if the client does not receive the response; a retry also re-queues issuance if the stored request is still released. Delete a held, authorization-ready, released, or release-derived issuing request to cancel it.

REST API

OpenAPI is generated at /openapi.json; Swagger UI is available at /docs.

MethodPathAuthDescription
GET/healthnoneLiveness probe with uptime
GET/readynoneDB and acme.sh readiness
POST/v1/certificatesoperatorCreate a certificate request; DNS Persist returns its stored TXT instruction
GET/v1/certificatesreadonlyList certificates
GET/v1/certificates/{id}readonlyRead certificate detail and DNS Persist instruction
POST/v1/certificates/{id}/authorizeoperatorAuthorize or retry DNS Persist issuance after publishing TXT
POST/v1/certificates/{id}/releaseoperatorRelease a held DNS Persist revision; requires Idempotency-Key and { "revision": n }
POST/v1/certificates/{id}/renewoperatorQueue manual renewal
POST/v1/certificates/{id}/revokeoperatorRevoke the issued primary domain through acme.sh; requires Idempotency-Key
DELETE/v1/certificates/{id}operatorSoft-delete as revoked
GET/v1/accountsreadonlyList configured ACME accounts
GET/v1/providersreadonlyList configured DNS providers
GET/v1/eventsreadonlyQuery audit events
GET/v1/admin/clientsadminList safe API-client metadata
POST/v1/admin/clientsadminCreate an API client and return its credential once
POST/v1/admin/clients/{id}/rotateadminRotate a client credential and return its replacement once
POST/v1/admin/clients/{id}/revokeadminRevoke an API client

Authenticated requests use bearer API keys:

curl \
-H "Authorization: Bearer $ACME_API_KEY" \
http://localhost:8080/v1/certificates

Certificate revocation

DELETE /v1/certificates/{id} only changes the local request record; it does not contact a certificate authority. To revoke an issued certificate at its configured CA, call POST /v1/certificates/{id}/revoke with an Idempotency-Key header and, optionally, an RFC 5280 reason:

curl -X POST \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Idempotency-Key: revoke-example-20260724" \
-H "Content-Type: application/json" \
--data '{"reason": 1}' \
http://localhost:8080/v1/certificates/$CERTIFICATE_ID/revoke

The operation invokes acme.sh as --revoke --domain <primary-domain> and adds --revoke-reason when requested. It does not delete deployed artifacts, disable renewal, or otherwise modify the local certificate record. Reusing the same key returns the durable original result without another acme.sh command. Reasons 0 through 10 are accepted except 7, which RFC 5280 leaves unused.

acme.sh selects the certificate it revokes from its managed domain and key-type slot; it does not accept a certificate file, serial number, fingerprint, or deployment generation. Generation selection only repoints acme.api's deployed artifact view and does not modify acme.sh's managed certificate. Consequently, CA revocation through this endpoint cannot target a retained historical generation independently of the certificate currently managed by acme.sh.

Certificate Deployment

Successful issuance and renewal deploy artifacts under the deployment_directory reported by every authenticated certificate API response, relative to the configured deployment root. For ordinary certificates it is the first requested domain:

/certificates/example.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

With generation-aware deployment enabled, each immutable publication is stored below a dedicated namespace:

/certificates/example.com/
current -> generations/<generation_id>
fullchain.pem -> current/fullchain.pem
privkey.pem -> current/privkey.pem
generations/<generation_id>/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

The generations/ component is intentional rather than redundant: it unambiguously separates immutable historical artifacts from the stable compatibility projection and future deployment control files. Wildcard domains use a portable collision-free name: a request for *.example.com reports deployment_directory: "@wildcard@.example.com" and deploys under /certificates/@wildcard@.example.com/. This cannot collide with a separate request for the valid literal name wildcard.example.com. Clients consuming the shared certificate volume must always resolve artifact paths from the API's deployment_directory field, never derive them from the requested identifier.

Files are copied to temporary names. acme.api assigns configured group and mode through each open file descriptor, then fsyncs the content and access-control metadata before atomically renaming artifacts into place. It also sets the deployment root and target directory to the configured group with 0750 traversal mode. The same process runs for initial issuance and renewal, so consumers never need to repair ownership or permissions themselves.

Architecture

 REST API
|
+---------------+---------------+
| |
Certificate Lifecycle Renewal Scheduler
| |
+---------------+---------------+
|
ACME Backend
|
acme.sh

The public API is independent of the ACME backend. v1 supports DNS-01 through acme.sh; future backends can be added behind the same internal protocol.

Non-Goals For v1

  • HTTP-01 validation
  • TLS-ALPN-01 validation
  • per-request DNS credentials
  • web UI
  • high availability or clustering
  • implementing the ACME protocol directly

About

acme.api is a lightweight, self-hosted REST service for managing ACME certificates; in the first instance focusing on acme.sh as a backend.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

acme.api

Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to acme.sh.

Warning

This project is entirely experimental currently. Do not use it for production, important certificates, or anything you are not prepared to delete and rebuild.

acme.api is meant to be consumed only by applications that enforce strict access controls as part of the application stack — it is never intended for direct exposure on the internet or general internal systems. A system like this has severe security implications if misconfigured or misused, and the author is not responsible for any damage caused by its use. I considered a tool like this to be necessary for some specific systems I was working on, and I'd have had to essentially build it regardless as part of that other system, so I thought why not genericize it and share it.

Status

Prototype v1 implementation. The core API, SQLite state, API key auth, acme.sh backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, Docker packaging, mock-backed end-to-end integration tests, and GitHub Actions CI are implemented. Real staging/Pebble ACME tests remain optional and credential-gated.

Quick Start

Build and start the local container:

make build
make start
curl http://localhost:8080/health
curl http://localhost:8080/ready

The compose file uses named volumes for persistent runtime state:

VolumeContainer pathPurpose
acme-api-data/dataSQLite database
acme-api-certificates/certificatesAtomically deployed certificate files
acme-api-acmesh/acmeshacme.sh account and certificate state

The bundled compose config at docker/config.yaml is intentionally minimal so the service can boot for health checks. For real issuance, copy config.example.yaml, configure ACME accounts, DNS provider aliases, and credential file mounts, then set ACME_API_CONFIG to that file inside the container.

Local Development

make dev
make verify

Useful targets:

CommandDescription
make testRun the unit, integration, and ordinary end-to-end suites plus coverage gates
make test-unitRun deterministic unit tests
make test-integrationRun mock-backed integration tests
make test-e2eRun the Pebble-backed Docker Compose end-to-end test stack
make deps-checkVerify uv.lock and both hashed requirements exports
make deps-updateUpgrade dependencies and regenerate uv.lock and exports
make format-checkCheck Ruff formatting
make lintRun Ruff and Pylint
make type-checkRun strict type checking
make verifyRun the full local quality and test gate
make simulate-ciExecute the GitHub Actions workflow locally with act
make buildBuild the Docker image
make startStart the Docker compose service
make stopStop the Docker compose service
make logsFollow container logs

Development uses uv for locking and export verification. make dev follows Vulpine's hashed-install workflow: it bootstraps .venv and installs requirements-dev.txt with --require-hashes --no-deps.

Configuration

Configuration is YAML. By default the app loads ./config.yaml; set ACME_API_CONFIG=/path/to/config.yaml to override it. See config.example.yaml for a complete reference.

Certificate issuance requires an acme_accounts entry and an authenticated API client. Fresh acme.api installations intentionally create no API clients:

printf'%s'"$ADMIN_KEY"| acme-api admin initialize --key-stdin

This stdin-only command is the one-time local administrative trust boundary and can run only while the persisted API-client table is empty. It creates the initial admin client; afterward, authenticated admins create, rotate, revoke, and list admin, operator, and readonly clients at /v1/admin/clients. Configuration has no api_keys setting. If every admin credential is lost, stop the service, back up the database, and remove only the API-client records; preserve certificate, account, renewal, deployment, and audit rows. Standard DNS-01 issuance additionally requires a configured dns_providers alias and a provider credential file readable by the container. DNS Persist issuance does not require either: its one-time TXT record is generated from the selected account.

Deployment configuration

deployment.directory is the artifact root. Mount it read/write only in the acme.api container and read-only in certificate consumers. permissions_cert and permissions_key are decimal file modes; their defaults are 420 (0644) and 384 (0600) respectively.

Set deployment.artifact_group_id only when a separate unprivileged consumer must read private keys. It is a numeric GID, not a group name, and acme.api must run with that GID as a supplementary group. If it cannot assign the group to a deployment directory or artifact, issuance or renewal records a deployment failure rather than publishing an unexpected access policy. A typical shared-volume configuration uses permissions_key: 416 (0640) and grants

read-only consumers membership in the same GID. When configured, acme.api sets directories it owns—or whose group it changes—to that group with 0750 mode, ensuring consumers can traverse them even with a restrictive umask. Pre-provisioned directories owned by another user retain their ownership and mode only when they already belong to the configured GID, so a non-root service can use an administrator-managed volume without CAP_CHOWN.

Enable deployment.generation_aware to preserve every successful issuance or renewal as an immutable artifact set. acme.api publishes a complete generation directory, then atomically switches the current symlink. The established cert.pem, chain.pem, fullchain.pem, privkey.pem, and metadata.json paths become symlinks through that pointer, so existing consumers keep their predictable paths. generation_retention_count and generation_retention_days may be set independently; a generation is removed only when it exceeds every configured limit. The selected generation and any explicitly pinned generation are never removed.

Example certificate request:

{
"name": "wildcard-example",
"domains": ["*.example.com", "example.com"],
"acme_account_ref": "letsencrypt-production",
"dns_provider_ref": "production",
"key_algorithm": "ecdsa"
}

DNS Persist certificates

For a zone managed manually, create a request with "challenge_method": "dns-persist" and omit dns_provider_ref. The response remains pending_dns and contains an account-bound TXT instruction at _validation-persist.<primary-domain>. Publish that exact value and retain it for the certificate's lifetime, then call POST /v1/certificates/{id}/authorize. The service issues with the selected account only after that explicit authorization. DNS Persist SANs must be the primary domain or its subdomains. Multi-SAN and wildcard requests receive a policy=wildcard instruction, which deliberately authorizes that primary domain's subdomains; use separate requests for unrelated domains.

Creation with the same name, domains, and account resumes the stored request and instruction; it does not create another ACME order. A different account creates a distinct instruction and cannot replace an existing request's account. Once valid, DNS Persist certificates renew unattended through the normal scheduler without DNS provider credentials or another TXT update. The instruction is returned only from authenticated certificate endpoints.

Held DNS Persist workflow

Set "held": true when creating a DNS Persist request to persist its stable TXT instruction without allowing issuance. After publishing the record, call POST /v1/certificates/{id}/authorize; this advances the request to authorization_ready but still does not issue. To release the current prepared revision, call POST /v1/certificates/{id}/release with an Idempotency-Key header and a JSON body containing the response's current revision, for example:

{"revision": 1}

Release is accepted only once for that revision and queues asynchronous issuance. Retry the same request with the same idempotency key if the client does not receive the response; a retry also re-queues issuance if the stored request is still released. Delete a held, authorization-ready, released, or release-derived issuing request to cancel it.

REST API

OpenAPI is generated at /openapi.json; Swagger UI is available at /docs.

MethodPathAuthDescription
GET/healthnoneLiveness probe with uptime
GET/readynoneDB and acme.sh readiness
POST/v1/certificatesoperatorCreate a certificate request; DNS Persist returns its stored TXT instruction
GET/v1/certificatesreadonlyList certificates
GET/v1/certificates/{id}readonlyRead certificate detail and DNS Persist instruction
POST/v1/certificates/{id}/authorizeoperatorAuthorize or retry DNS Persist issuance after publishing TXT
POST/v1/certificates/{id}/releaseoperatorRelease a held DNS Persist revision; requires Idempotency-Key and { "revision": n }
POST/v1/certificates/{id}/renewoperatorQueue manual renewal
POST/v1/certificates/{id}/revokeoperatorRevoke the issued primary domain through acme.sh; requires Idempotency-Key
DELETE/v1/certificates/{id}operatorSoft-delete as revoked
GET/v1/accountsreadonlyList configured ACME accounts
GET/v1/providersreadonlyList configured DNS providers
GET/v1/eventsreadonlyQuery audit events
GET/v1/admin/clientsadminList safe API-client metadata
POST/v1/admin/clientsadminCreate an API client and return its credential once
POST/v1/admin/clients/{id}/rotateadminRotate a client credential and return its replacement once
POST/v1/admin/clients/{id}/revokeadminRevoke an API client

Authenticated requests use bearer API keys:

curl \
-H "Authorization: Bearer $ACME_API_KEY" \
http://localhost:8080/v1/certificates

Certificate revocation

DELETE /v1/certificates/{id} only changes the local request record; it does not contact a certificate authority. To revoke an issued certificate at its configured CA, call POST /v1/certificates/{id}/revoke with an Idempotency-Key header and, optionally, an RFC 5280 reason:

curl -X POST \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Idempotency-Key: revoke-example-20260724" \
-H "Content-Type: application/json" \
--data '{"reason": 1}' \
http://localhost:8080/v1/certificates/$CERTIFICATE_ID/revoke

The operation invokes acme.sh as --revoke --domain <primary-domain> and adds --revoke-reason when requested. It does not delete deployed artifacts, disable renewal, or otherwise modify the local certificate record. Reusing the same key returns the durable original result without another acme.sh command. Reasons 0 through 10 are accepted except 7, which RFC 5280 leaves unused.

acme.sh selects the certificate it revokes from its managed domain and key-type slot; it does not accept a certificate file, serial number, fingerprint, or deployment generation. Generation selection only repoints acme.api's deployed artifact view and does not modify acme.sh's managed certificate. Consequently, CA revocation through this endpoint cannot target a retained historical generation independently of the certificate currently managed by acme.sh.

Certificate Deployment

Successful issuance and renewal deploy artifacts under the deployment_directory reported by every authenticated certificate API response, relative to the configured deployment root. For ordinary certificates it is the first requested domain:

/certificates/example.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

With generation-aware deployment enabled, each immutable publication is stored below a dedicated namespace:

/certificates/example.com/
current -> generations/<generation_id>
fullchain.pem -> current/fullchain.pem
privkey.pem -> current/privkey.pem
generations/<generation_id>/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

The generations/ component is intentional rather than redundant: it unambiguously separates immutable historical artifacts from the stable compatibility projection and future deployment control files. Wildcard domains use a portable collision-free name: a request for *.example.com reports deployment_directory: "@wildcard@.example.com" and deploys under /certificates/@wildcard@.example.com/. This cannot collide with a separate request for the valid literal name wildcard.example.com. Clients consuming the shared certificate volume must always resolve artifact paths from the API's deployment_directory field, never derive them from the requested identifier.

Files are copied to temporary names. acme.api assigns configured group and mode through each open file descriptor, then fsyncs the content and access-control metadata before atomically renaming artifacts into place. It also sets the deployment root and target directory to the configured group with 0750 traversal mode. The same process runs for initial issuance and renewal, so consumers never need to repair ownership or permissions themselves.

Architecture

 REST API
|
+---------------+---------------+
| |
Certificate Lifecycle Renewal Scheduler
| |
+---------------+---------------+
|
ACME Backend
|
acme.sh

The public API is independent of the ACME backend. v1 supports DNS-01 through acme.sh; future backends can be added behind the same internal protocol.

Non-Goals For v1

  • HTTP-01 validation
  • TLS-ALPN-01 validation
  • per-request DNS credentials
  • web UI
  • high availability or clustering
  • implementing the ACME protocol directly

About

acme.api is a lightweight, self-hosted REST service for managing ACME certificates; in the first instance focusing on acme.sh as a backend.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

acme.api

Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to acme.sh.

Warning

This project is entirely experimental currently. Do not use it for production, important certificates, or anything you are not prepared to delete and rebuild.

acme.api is meant to be consumed only by applications that enforce strict access controls as part of the application stack — it is never intended for direct exposure on the internet or general internal systems. A system like this has severe security implications if misconfigured or misused, and the author is not responsible for any damage caused by its use. I considered a tool like this to be necessary for some specific systems I was working on, and I'd have had to essentially build it regardless as part of that other system, so I thought why not genericize it and share it.

Status

Prototype v1 implementation. The core API, SQLite state, API key auth, acme.sh backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, Docker packaging, mock-backed end-to-end integration tests, and GitHub Actions CI are implemented. Real staging/Pebble ACME tests remain optional and credential-gated.

Quick Start

Build and start the local container:

make build
make start
curl http://localhost:8080/health
curl http://localhost:8080/ready

The compose file uses named volumes for persistent runtime state:

VolumeContainer pathPurpose
acme-api-data/dataSQLite database
acme-api-certificates/certificatesAtomically deployed certificate files
acme-api-acmesh/acmeshacme.sh account and certificate state

The bundled compose config at docker/config.yaml is intentionally minimal so the service can boot for health checks. For real issuance, copy config.example.yaml, configure ACME accounts, DNS provider aliases, and credential file mounts, then set ACME_API_CONFIG to that file inside the container.

Local Development

make dev
make verify

Useful targets:

CommandDescription
make testRun the unit, integration, and ordinary end-to-end suites plus coverage gates
make test-unitRun deterministic unit tests
make test-integrationRun mock-backed integration tests
make test-e2eRun the Pebble-backed Docker Compose end-to-end test stack
make deps-checkVerify uv.lock and both hashed requirements exports
make deps-updateUpgrade dependencies and regenerate uv.lock and exports
make format-checkCheck Ruff formatting
make lintRun Ruff and Pylint
make type-checkRun strict type checking
make verifyRun the full local quality and test gate
make simulate-ciExecute the GitHub Actions workflow locally with act
make buildBuild the Docker image
make startStart the Docker compose service
make stopStop the Docker compose service
make logsFollow container logs

Development uses uv for locking and export verification. make dev follows Vulpine's hashed-install workflow: it bootstraps .venv and installs requirements-dev.txt with --require-hashes --no-deps.

Configuration

Configuration is YAML. By default the app loads ./config.yaml; set ACME_API_CONFIG=/path/to/config.yaml to override it. See config.example.yaml for a complete reference.

Certificate issuance requires an acme_accounts entry and an authenticated API client. Fresh acme.api installations intentionally create no API clients:

printf'%s'"$ADMIN_KEY"| acme-api admin initialize --key-stdin

This stdin-only command is the one-time local administrative trust boundary and can run only while the persisted API-client table is empty. It creates the initial admin client; afterward, authenticated admins create, rotate, revoke, and list admin, operator, and readonly clients at /v1/admin/clients. Configuration has no api_keys setting. If every admin credential is lost, stop the service, back up the database, and remove only the API-client records; preserve certificate, account, renewal, deployment, and audit rows. Standard DNS-01 issuance additionally requires a configured dns_providers alias and a provider credential file readable by the container. DNS Persist issuance does not require either: its one-time TXT record is generated from the selected account.

Deployment configuration

deployment.directory is the artifact root. Mount it read/write only in the acme.api container and read-only in certificate consumers. permissions_cert and permissions_key are decimal file modes; their defaults are 420 (0644) and 384 (0600) respectively.

Set deployment.artifact_group_id only when a separate unprivileged consumer must read private keys. It is a numeric GID, not a group name, and acme.api must run with that GID as a supplementary group. If it cannot assign the group to a deployment directory or artifact, issuance or renewal records a deployment failure rather than publishing an unexpected access policy. A typical shared-volume configuration uses permissions_key: 416 (0640) and grants

read-only consumers membership in the same GID. When configured, acme.api sets directories it owns—or whose group it changes—to that group with 0750 mode, ensuring consumers can traverse them even with a restrictive umask. Pre-provisioned directories owned by another user retain their ownership and mode only when they already belong to the configured GID, so a non-root service can use an administrator-managed volume without CAP_CHOWN.

Enable deployment.generation_aware to preserve every successful issuance or renewal as an immutable artifact set. acme.api publishes a complete generation directory, then atomically switches the current symlink. The established cert.pem, chain.pem, fullchain.pem, privkey.pem, and metadata.json paths become symlinks through that pointer, so existing consumers keep their predictable paths. generation_retention_count and generation_retention_days may be set independently; a generation is removed only when it exceeds every configured limit. The selected generation and any explicitly pinned generation are never removed.

Example certificate request:

{
"name": "wildcard-example",
"domains": ["*.example.com", "example.com"],
"acme_account_ref": "letsencrypt-production",
"dns_provider_ref": "production",
"key_algorithm": "ecdsa"
}

DNS Persist certificates

For a zone managed manually, create a request with "challenge_method": "dns-persist" and omit dns_provider_ref. The response remains pending_dns and contains an account-bound TXT instruction at _validation-persist.<primary-domain>. Publish that exact value and retain it for the certificate's lifetime, then call POST /v1/certificates/{id}/authorize. The service issues with the selected account only after that explicit authorization. DNS Persist SANs must be the primary domain or its subdomains. Multi-SAN and wildcard requests receive a policy=wildcard instruction, which deliberately authorizes that primary domain's subdomains; use separate requests for unrelated domains.

Creation with the same name, domains, and account resumes the stored request and instruction; it does not create another ACME order. A different account creates a distinct instruction and cannot replace an existing request's account. Once valid, DNS Persist certificates renew unattended through the normal scheduler without DNS provider credentials or another TXT update. The instruction is returned only from authenticated certificate endpoints.

Held DNS Persist workflow

Set "held": true when creating a DNS Persist request to persist its stable TXT instruction without allowing issuance. After publishing the record, call POST /v1/certificates/{id}/authorize; this advances the request to authorization_ready but still does not issue. To release the current prepared revision, call POST /v1/certificates/{id}/release with an Idempotency-Key header and a JSON body containing the response's current revision, for example:

{"revision": 1}

Release is accepted only once for that revision and queues asynchronous issuance. Retry the same request with the same idempotency key if the client does not receive the response; a retry also re-queues issuance if the stored request is still released. Delete a held, authorization-ready, released, or release-derived issuing request to cancel it.

REST API

OpenAPI is generated at /openapi.json; Swagger UI is available at /docs.

MethodPathAuthDescription
GET/healthnoneLiveness probe with uptime
GET/readynoneDB and acme.sh readiness
POST/v1/certificatesoperatorCreate a certificate request; DNS Persist returns its stored TXT instruction
GET/v1/certificatesreadonlyList certificates
GET/v1/certificates/{id}readonlyRead certificate detail and DNS Persist instruction
POST/v1/certificates/{id}/authorizeoperatorAuthorize or retry DNS Persist issuance after publishing TXT
POST/v1/certificates/{id}/releaseoperatorRelease a held DNS Persist revision; requires Idempotency-Key and { "revision": n }
POST/v1/certificates/{id}/renewoperatorQueue manual renewal
POST/v1/certificates/{id}/revokeoperatorRevoke the issued primary domain through acme.sh; requires Idempotency-Key
DELETE/v1/certificates/{id}operatorSoft-delete as revoked
GET/v1/accountsreadonlyList configured ACME accounts
GET/v1/providersreadonlyList configured DNS providers
GET/v1/eventsreadonlyQuery audit events
GET/v1/admin/clientsadminList safe API-client metadata
POST/v1/admin/clientsadminCreate an API client and return its credential once
POST/v1/admin/clients/{id}/rotateadminRotate a client credential and return its replacement once
POST/v1/admin/clients/{id}/revokeadminRevoke an API client

Authenticated requests use bearer API keys:

curl \
-H "Authorization: Bearer $ACME_API_KEY" \
http://localhost:8080/v1/certificates

Certificate revocation

DELETE /v1/certificates/{id} only changes the local request record; it does not contact a certificate authority. To revoke an issued certificate at its configured CA, call POST /v1/certificates/{id}/revoke with an Idempotency-Key header and, optionally, an RFC 5280 reason:

curl -X POST \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Idempotency-Key: revoke-example-20260724" \
-H "Content-Type: application/json" \
--data '{"reason": 1}' \
http://localhost:8080/v1/certificates/$CERTIFICATE_ID/revoke

The operation invokes acme.sh as --revoke --domain <primary-domain> and adds --revoke-reason when requested. It does not delete deployed artifacts, disable renewal, or otherwise modify the local certificate record. Reusing the same key returns the durable original result without another acme.sh command. Reasons 0 through 10 are accepted except 7, which RFC 5280 leaves unused.

acme.sh selects the certificate it revokes from its managed domain and key-type slot; it does not accept a certificate file, serial number, fingerprint, or deployment generation. Generation selection only repoints acme.api's deployed artifact view and does not modify acme.sh's managed certificate. Consequently, CA revocation through this endpoint cannot target a retained historical generation independently of the certificate currently managed by acme.sh.

Certificate Deployment

Successful issuance and renewal deploy artifacts under the deployment_directory reported by every authenticated certificate API response, relative to the configured deployment root. For ordinary certificates it is the first requested domain:

/certificates/example.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

With generation-aware deployment enabled, each immutable publication is stored below a dedicated namespace:

/certificates/example.com/
current -> generations/<generation_id>
fullchain.pem -> current/fullchain.pem
privkey.pem -> current/privkey.pem
generations/<generation_id>/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

The generations/ component is intentional rather than redundant: it unambiguously separates immutable historical artifacts from the stable compatibility projection and future deployment control files. Wildcard domains use a portable collision-free name: a request for *.example.com reports deployment_directory: "@wildcard@.example.com" and deploys under /certificates/@wildcard@.example.com/. This cannot collide with a separate request for the valid literal name wildcard.example.com. Clients consuming the shared certificate volume must always resolve artifact paths from the API's deployment_directory field, never derive them from the requested identifier.

Files are copied to temporary names. acme.api assigns configured group and mode through each open file descriptor, then fsyncs the content and access-control metadata before atomically renaming artifacts into place. It also sets the deployment root and target directory to the configured group with 0750 traversal mode. The same process runs for initial issuance and renewal, so consumers never need to repair ownership or permissions themselves.

Architecture

 REST API
|
+---------------+---------------+
| |
Certificate Lifecycle Renewal Scheduler
| |
+---------------+---------------+
|
ACME Backend
|
acme.sh

The public API is independent of the ACME backend. v1 supports DNS-01 through acme.sh; future backends can be added behind the same internal protocol.

Non-Goals For v1

  • HTTP-01 validation
  • TLS-ALPN-01 validation
  • per-request DNS credentials
  • web UI
  • high availability or clustering
  • implementing the ACME protocol directly

About

acme.api is a lightweight, self-hosted REST service for managing ACME certificates; in the first instance focusing on acme.sh as a backend.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

acme.api

Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to acme.sh.

Warning

This project is entirely experimental currently. Do not use it for production, important certificates, or anything you are not prepared to delete and rebuild.

acme.api is meant to be consumed only by applications that enforce strict access controls as part of the application stack — it is never intended for direct exposure on the internet or general internal systems. A system like this has severe security implications if misconfigured or misused, and the author is not responsible for any damage caused by its use. I considered a tool like this to be necessary for some specific systems I was working on, and I'd have had to essentially build it regardless as part of that other system, so I thought why not genericize it and share it.

Status

Prototype v1 implementation. The core API, SQLite state, API key auth, acme.sh backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, Docker packaging, mock-backed end-to-end integration tests, and GitHub Actions CI are implemented. Real staging/Pebble ACME tests remain optional and credential-gated.

Quick Start

Build and start the local container:

make build
make start
curl http://localhost:8080/health
curl http://localhost:8080/ready

The compose file uses named volumes for persistent runtime state:

VolumeContainer pathPurpose
acme-api-data/dataSQLite database
acme-api-certificates/certificatesAtomically deployed certificate files
acme-api-acmesh/acmeshacme.sh account and certificate state

The bundled compose config at docker/config.yaml is intentionally minimal so the service can boot for health checks. For real issuance, copy config.example.yaml, configure ACME accounts, DNS provider aliases, and credential file mounts, then set ACME_API_CONFIG to that file inside the container.

Local Development

make dev
make verify

Useful targets:

CommandDescription
make testRun the unit, integration, and ordinary end-to-end suites plus coverage gates
make test-unitRun deterministic unit tests
make test-integrationRun mock-backed integration tests
make test-e2eRun the Pebble-backed Docker Compose end-to-end test stack
make deps-checkVerify uv.lock and both hashed requirements exports
make deps-updateUpgrade dependencies and regenerate uv.lock and exports
make format-checkCheck Ruff formatting
make lintRun Ruff and Pylint
make type-checkRun strict type checking
make verifyRun the full local quality and test gate
make simulate-ciExecute the GitHub Actions workflow locally with act
make buildBuild the Docker image
make startStart the Docker compose service
make stopStop the Docker compose service
make logsFollow container logs

Development uses uv for locking and export verification. make dev follows Vulpine's hashed-install workflow: it bootstraps .venv and installs requirements-dev.txt with --require-hashes --no-deps.

Configuration

Configuration is YAML. By default the app loads ./config.yaml; set ACME_API_CONFIG=/path/to/config.yaml to override it. See config.example.yaml for a complete reference.

Certificate issuance requires an acme_accounts entry and an authenticated API client. Fresh acme.api installations intentionally create no API clients:

printf'%s'"$ADMIN_KEY"| acme-api admin initialize --key-stdin

This stdin-only command is the one-time local administrative trust boundary and can run only while the persisted API-client table is empty. It creates the initial admin client; afterward, authenticated admins create, rotate, revoke, and list admin, operator, and readonly clients at /v1/admin/clients. Configuration has no api_keys setting. If every admin credential is lost, stop the service, back up the database, and remove only the API-client records; preserve certificate, account, renewal, deployment, and audit rows. Standard DNS-01 issuance additionally requires a configured dns_providers alias and a provider credential file readable by the container. DNS Persist issuance does not require either: its one-time TXT record is generated from the selected account.

Deployment configuration

deployment.directory is the artifact root. Mount it read/write only in the acme.api container and read-only in certificate consumers. permissions_cert and permissions_key are decimal file modes; their defaults are 420 (0644) and 384 (0600) respectively.

Set deployment.artifact_group_id only when a separate unprivileged consumer must read private keys. It is a numeric GID, not a group name, and acme.api must run with that GID as a supplementary group. If it cannot assign the group to a deployment directory or artifact, issuance or renewal records a deployment failure rather than publishing an unexpected access policy. A typical shared-volume configuration uses permissions_key: 416 (0640) and grants

read-only consumers membership in the same GID. When configured, acme.api sets directories it owns—or whose group it changes—to that group with 0750 mode, ensuring consumers can traverse them even with a restrictive umask. Pre-provisioned directories owned by another user retain their ownership and mode only when they already belong to the configured GID, so a non-root service can use an administrator-managed volume without CAP_CHOWN.

Enable deployment.generation_aware to preserve every successful issuance or renewal as an immutable artifact set. acme.api publishes a complete generation directory, then atomically switches the current symlink. The established cert.pem, chain.pem, fullchain.pem, privkey.pem, and metadata.json paths become symlinks through that pointer, so existing consumers keep their predictable paths. generation_retention_count and generation_retention_days may be set independently; a generation is removed only when it exceeds every configured limit. The selected generation and any explicitly pinned generation are never removed.

Example certificate request:

{
"name": "wildcard-example",
"domains": ["*.example.com", "example.com"],
"acme_account_ref": "letsencrypt-production",
"dns_provider_ref": "production",
"key_algorithm": "ecdsa"
}

DNS Persist certificates

For a zone managed manually, create a request with "challenge_method": "dns-persist" and omit dns_provider_ref. The response remains pending_dns and contains an account-bound TXT instruction at _validation-persist.<primary-domain>. Publish that exact value and retain it for the certificate's lifetime, then call POST /v1/certificates/{id}/authorize. The service issues with the selected account only after that explicit authorization. DNS Persist SANs must be the primary domain or its subdomains. Multi-SAN and wildcard requests receive a policy=wildcard instruction, which deliberately authorizes that primary domain's subdomains; use separate requests for unrelated domains.

Creation with the same name, domains, and account resumes the stored request and instruction; it does not create another ACME order. A different account creates a distinct instruction and cannot replace an existing request's account. Once valid, DNS Persist certificates renew unattended through the normal scheduler without DNS provider credentials or another TXT update. The instruction is returned only from authenticated certificate endpoints.

Held DNS Persist workflow

Set "held": true when creating a DNS Persist request to persist its stable TXT instruction without allowing issuance. After publishing the record, call POST /v1/certificates/{id}/authorize; this advances the request to authorization_ready but still does not issue. To release the current prepared revision, call POST /v1/certificates/{id}/release with an Idempotency-Key header and a JSON body containing the response's current revision, for example:

{"revision": 1}

Release is accepted only once for that revision and queues asynchronous issuance. Retry the same request with the same idempotency key if the client does not receive the response; a retry also re-queues issuance if the stored request is still released. Delete a held, authorization-ready, released, or release-derived issuing request to cancel it.

REST API

OpenAPI is generated at /openapi.json; Swagger UI is available at /docs.

MethodPathAuthDescription
GET/healthnoneLiveness probe with uptime
GET/readynoneDB and acme.sh readiness
POST/v1/certificatesoperatorCreate a certificate request; DNS Persist returns its stored TXT instruction
GET/v1/certificatesreadonlyList certificates
GET/v1/certificates/{id}readonlyRead certificate detail and DNS Persist instruction
POST/v1/certificates/{id}/authorizeoperatorAuthorize or retry DNS Persist issuance after publishing TXT
POST/v1/certificates/{id}/releaseoperatorRelease a held DNS Persist revision; requires Idempotency-Key and { "revision": n }
POST/v1/certificates/{id}/renewoperatorQueue manual renewal
POST/v1/certificates/{id}/revokeoperatorRevoke the issued primary domain through acme.sh; requires Idempotency-Key
DELETE/v1/certificates/{id}operatorSoft-delete as revoked
GET/v1/accountsreadonlyList configured ACME accounts
GET/v1/providersreadonlyList configured DNS providers
GET/v1/eventsreadonlyQuery audit events
GET/v1/admin/clientsadminList safe API-client metadata
POST/v1/admin/clientsadminCreate an API client and return its credential once
POST/v1/admin/clients/{id}/rotateadminRotate a client credential and return its replacement once
POST/v1/admin/clients/{id}/revokeadminRevoke an API client

Authenticated requests use bearer API keys:

curl \
-H "Authorization: Bearer $ACME_API_KEY" \
http://localhost:8080/v1/certificates

Certificate revocation

DELETE /v1/certificates/{id} only changes the local request record; it does not contact a certificate authority. To revoke an issued certificate at its configured CA, call POST /v1/certificates/{id}/revoke with an Idempotency-Key header and, optionally, an RFC 5280 reason:

curl -X POST \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Idempotency-Key: revoke-example-20260724" \
-H "Content-Type: application/json" \
--data '{"reason": 1}' \
http://localhost:8080/v1/certificates/$CERTIFICATE_ID/revoke

The operation invokes acme.sh as --revoke --domain <primary-domain> and adds --revoke-reason when requested. It does not delete deployed artifacts, disable renewal, or otherwise modify the local certificate record. Reusing the same key returns the durable original result without another acme.sh command. Reasons 0 through 10 are accepted except 7, which RFC 5280 leaves unused.

acme.sh selects the certificate it revokes from its managed domain and key-type slot; it does not accept a certificate file, serial number, fingerprint, or deployment generation. Generation selection only repoints acme.api's deployed artifact view and does not modify acme.sh's managed certificate. Consequently, CA revocation through this endpoint cannot target a retained historical generation independently of the certificate currently managed by acme.sh.

Certificate Deployment

Successful issuance and renewal deploy artifacts under the deployment_directory reported by every authenticated certificate API response, relative to the configured deployment root. For ordinary certificates it is the first requested domain:

/certificates/example.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

With generation-aware deployment enabled, each immutable publication is stored below a dedicated namespace:

/certificates/example.com/
current -> generations/<generation_id>
fullchain.pem -> current/fullchain.pem
privkey.pem -> current/privkey.pem
generations/<generation_id>/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

The generations/ component is intentional rather than redundant: it unambiguously separates immutable historical artifacts from the stable compatibility projection and future deployment control files. Wildcard domains use a portable collision-free name: a request for *.example.com reports deployment_directory: "@wildcard@.example.com" and deploys under /certificates/@wildcard@.example.com/. This cannot collide with a separate request for the valid literal name wildcard.example.com. Clients consuming the shared certificate volume must always resolve artifact paths from the API's deployment_directory field, never derive them from the requested identifier.

Files are copied to temporary names. acme.api assigns configured group and mode through each open file descriptor, then fsyncs the content and access-control metadata before atomically renaming artifacts into place. It also sets the deployment root and target directory to the configured group with 0750 traversal mode. The same process runs for initial issuance and renewal, so consumers never need to repair ownership or permissions themselves.

Architecture

 REST API
|
+---------------+---------------+
| |
Certificate Lifecycle Renewal Scheduler
| |
+---------------+---------------+
|
ACME Backend
|
acme.sh

The public API is independent of the ACME backend. v1 supports DNS-01 through acme.sh; future backends can be added behind the same internal protocol.

Non-Goals For v1

  • HTTP-01 validation
  • TLS-ALPN-01 validation
  • per-request DNS credentials
  • web UI
  • high availability or clustering
  • implementing the ACME protocol directly

About

acme.api is a lightweight, self-hosted REST service for managing ACME certificates; in the first instance focusing on acme.sh as a backend.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

acme.api

Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to acme.sh.

Warning

This project is entirely experimental currently. Do not use it for production, important certificates, or anything you are not prepared to delete and rebuild.

acme.api is meant to be consumed only by applications that enforce strict access controls as part of the application stack — it is never intended for direct exposure on the internet or general internal systems. A system like this has severe security implications if misconfigured or misused, and the author is not responsible for any damage caused by its use. I considered a tool like this to be necessary for some specific systems I was working on, and I'd have had to essentially build it regardless as part of that other system, so I thought why not genericize it and share it.

Status

Prototype v1 implementation. The core API, SQLite state, API key auth, acme.sh backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, Docker packaging, mock-backed end-to-end integration tests, and GitHub Actions CI are implemented. Real staging/Pebble ACME tests remain optional and credential-gated.

Quick Start

Build and start the local container:

make build
make start
curl http://localhost:8080/health
curl http://localhost:8080/ready

The compose file uses named volumes for persistent runtime state:

VolumeContainer pathPurpose
acme-api-data/dataSQLite database
acme-api-certificates/certificatesAtomically deployed certificate files
acme-api-acmesh/acmeshacme.sh account and certificate state

The bundled compose config at docker/config.yaml is intentionally minimal so the service can boot for health checks. For real issuance, copy config.example.yaml, configure ACME accounts, DNS provider aliases, and credential file mounts, then set ACME_API_CONFIG to that file inside the container.

Local Development

make dev
make verify

Useful targets:

CommandDescription
make testRun the unit, integration, and ordinary end-to-end suites plus coverage gates
make test-unitRun deterministic unit tests
make test-integrationRun mock-backed integration tests
make test-e2eRun the Pebble-backed Docker Compose end-to-end test stack
make deps-checkVerify uv.lock and both hashed requirements exports
make deps-updateUpgrade dependencies and regenerate uv.lock and exports
make format-checkCheck Ruff formatting
make lintRun Ruff and Pylint
make type-checkRun strict type checking
make verifyRun the full local quality and test gate
make simulate-ciExecute the GitHub Actions workflow locally with act
make buildBuild the Docker image
make startStart the Docker compose service
make stopStop the Docker compose service
make logsFollow container logs

Development uses uv for locking and export verification. make dev follows Vulpine's hashed-install workflow: it bootstraps .venv and installs requirements-dev.txt with --require-hashes --no-deps.

Configuration

Configuration is YAML. By default the app loads ./config.yaml; set ACME_API_CONFIG=/path/to/config.yaml to override it. See config.example.yaml for a complete reference.

Certificate issuance requires an acme_accounts entry and an authenticated API client. Fresh acme.api installations intentionally create no API clients:

printf'%s'"$ADMIN_KEY"| acme-api admin initialize --key-stdin

This stdin-only command is the one-time local administrative trust boundary and can run only while the persisted API-client table is empty. It creates the initial admin client; afterward, authenticated admins create, rotate, revoke, and list admin, operator, and readonly clients at /v1/admin/clients. Configuration has no api_keys setting. If every admin credential is lost, stop the service, back up the database, and remove only the API-client records; preserve certificate, account, renewal, deployment, and audit rows. Standard DNS-01 issuance additionally requires a configured dns_providers alias and a provider credential file readable by the container. DNS Persist issuance does not require either: its one-time TXT record is generated from the selected account.

Deployment configuration

deployment.directory is the artifact root. Mount it read/write only in the acme.api container and read-only in certificate consumers. permissions_cert and permissions_key are decimal file modes; their defaults are 420 (0644) and 384 (0600) respectively.

Set deployment.artifact_group_id only when a separate unprivileged consumer must read private keys. It is a numeric GID, not a group name, and acme.api must run with that GID as a supplementary group. If it cannot assign the group to a deployment directory or artifact, issuance or renewal records a deployment failure rather than publishing an unexpected access policy. A typical shared-volume configuration uses permissions_key: 416 (0640) and grants

read-only consumers membership in the same GID. When configured, acme.api sets directories it owns—or whose group it changes—to that group with 0750 mode, ensuring consumers can traverse them even with a restrictive umask. Pre-provisioned directories owned by another user retain their ownership and mode only when they already belong to the configured GID, so a non-root service can use an administrator-managed volume without CAP_CHOWN.

Enable deployment.generation_aware to preserve every successful issuance or renewal as an immutable artifact set. acme.api publishes a complete generation directory, then atomically switches the current symlink. The established cert.pem, chain.pem, fullchain.pem, privkey.pem, and metadata.json paths become symlinks through that pointer, so existing consumers keep their predictable paths. generation_retention_count and generation_retention_days may be set independently; a generation is removed only when it exceeds every configured limit. The selected generation and any explicitly pinned generation are never removed.

Example certificate request:

{
"name": "wildcard-example",
"domains": ["*.example.com", "example.com"],
"acme_account_ref": "letsencrypt-production",
"dns_provider_ref": "production",
"key_algorithm": "ecdsa"
}

DNS Persist certificates

For a zone managed manually, create a request with "challenge_method": "dns-persist" and omit dns_provider_ref. The response remains pending_dns and contains an account-bound TXT instruction at _validation-persist.<primary-domain>. Publish that exact value and retain it for the certificate's lifetime, then call POST /v1/certificates/{id}/authorize. The service issues with the selected account only after that explicit authorization. DNS Persist SANs must be the primary domain or its subdomains. Multi-SAN and wildcard requests receive a policy=wildcard instruction, which deliberately authorizes that primary domain's subdomains; use separate requests for unrelated domains.

Creation with the same name, domains, and account resumes the stored request and instruction; it does not create another ACME order. A different account creates a distinct instruction and cannot replace an existing request's account. Once valid, DNS Persist certificates renew unattended through the normal scheduler without DNS provider credentials or another TXT update. The instruction is returned only from authenticated certificate endpoints.

Held DNS Persist workflow

Set "held": true when creating a DNS Persist request to persist its stable TXT instruction without allowing issuance. After publishing the record, call POST /v1/certificates/{id}/authorize; this advances the request to authorization_ready but still does not issue. To release the current prepared revision, call POST /v1/certificates/{id}/release with an Idempotency-Key header and a JSON body containing the response's current revision, for example:

{"revision": 1}

Release is accepted only once for that revision and queues asynchronous issuance. Retry the same request with the same idempotency key if the client does not receive the response; a retry also re-queues issuance if the stored request is still released. Delete a held, authorization-ready, released, or release-derived issuing request to cancel it.

REST API

OpenAPI is generated at /openapi.json; Swagger UI is available at /docs.

MethodPathAuthDescription
GET/healthnoneLiveness probe with uptime
GET/readynoneDB and acme.sh readiness
POST/v1/certificatesoperatorCreate a certificate request; DNS Persist returns its stored TXT instruction
GET/v1/certificatesreadonlyList certificates
GET/v1/certificates/{id}readonlyRead certificate detail and DNS Persist instruction
POST/v1/certificates/{id}/authorizeoperatorAuthorize or retry DNS Persist issuance after publishing TXT
POST/v1/certificates/{id}/releaseoperatorRelease a held DNS Persist revision; requires Idempotency-Key and { "revision": n }
POST/v1/certificates/{id}/renewoperatorQueue manual renewal
POST/v1/certificates/{id}/revokeoperatorRevoke the issued primary domain through acme.sh; requires Idempotency-Key
DELETE/v1/certificates/{id}operatorSoft-delete as revoked
GET/v1/accountsreadonlyList configured ACME accounts
GET/v1/providersreadonlyList configured DNS providers
GET/v1/eventsreadonlyQuery audit events
GET/v1/admin/clientsadminList safe API-client metadata
POST/v1/admin/clientsadminCreate an API client and return its credential once
POST/v1/admin/clients/{id}/rotateadminRotate a client credential and return its replacement once
POST/v1/admin/clients/{id}/revokeadminRevoke an API client

Authenticated requests use bearer API keys:

curl \
-H "Authorization: Bearer $ACME_API_KEY" \
http://localhost:8080/v1/certificates

Certificate revocation

DELETE /v1/certificates/{id} only changes the local request record; it does not contact a certificate authority. To revoke an issued certificate at its configured CA, call POST /v1/certificates/{id}/revoke with an Idempotency-Key header and, optionally, an RFC 5280 reason:

curl -X POST \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Idempotency-Key: revoke-example-20260724" \
-H "Content-Type: application/json" \
--data '{"reason": 1}' \
http://localhost:8080/v1/certificates/$CERTIFICATE_ID/revoke

The operation invokes acme.sh as --revoke --domain <primary-domain> and adds --revoke-reason when requested. It does not delete deployed artifacts, disable renewal, or otherwise modify the local certificate record. Reusing the same key returns the durable original result without another acme.sh command. Reasons 0 through 10 are accepted except 7, which RFC 5280 leaves unused.

acme.sh selects the certificate it revokes from its managed domain and key-type slot; it does not accept a certificate file, serial number, fingerprint, or deployment generation. Generation selection only repoints acme.api's deployed artifact view and does not modify acme.sh's managed certificate. Consequently, CA revocation through this endpoint cannot target a retained historical generation independently of the certificate currently managed by acme.sh.

Certificate Deployment

Successful issuance and renewal deploy artifacts under the deployment_directory reported by every authenticated certificate API response, relative to the configured deployment root. For ordinary certificates it is the first requested domain:

/certificates/example.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

With generation-aware deployment enabled, each immutable publication is stored below a dedicated namespace:

/certificates/example.com/
current -> generations/<generation_id>
fullchain.pem -> current/fullchain.pem
privkey.pem -> current/privkey.pem
generations/<generation_id>/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

The generations/ component is intentional rather than redundant: it unambiguously separates immutable historical artifacts from the stable compatibility projection and future deployment control files. Wildcard domains use a portable collision-free name: a request for *.example.com reports deployment_directory: "@wildcard@.example.com" and deploys under /certificates/@wildcard@.example.com/. This cannot collide with a separate request for the valid literal name wildcard.example.com. Clients consuming the shared certificate volume must always resolve artifact paths from the API's deployment_directory field, never derive them from the requested identifier.

Files are copied to temporary names. acme.api assigns configured group and mode through each open file descriptor, then fsyncs the content and access-control metadata before atomically renaming artifacts into place. It also sets the deployment root and target directory to the configured group with 0750 traversal mode. The same process runs for initial issuance and renewal, so consumers never need to repair ownership or permissions themselves.

Architecture

 REST API
|
+---------------+---------------+
| |
Certificate Lifecycle Renewal Scheduler
| |
+---------------+---------------+
|
ACME Backend
|
acme.sh

The public API is independent of the ACME backend. v1 supports DNS-01 through acme.sh; future backends can be added behind the same internal protocol.

Non-Goals For v1

  • HTTP-01 validation
  • TLS-ALPN-01 validation
  • per-request DNS credentials
  • web UI
  • high availability or clustering
  • implementing the ACME protocol directly

About

acme.api is a lightweight, self-hosted REST service for managing ACME certificates; in the first instance focusing on acme.sh as a backend.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

acme.api

Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to acme.sh.

Warning

This project is entirely experimental currently. Do not use it for production, important certificates, or anything you are not prepared to delete and rebuild.

acme.api is meant to be consumed only by applications that enforce strict access controls as part of the application stack — it is never intended for direct exposure on the internet or general internal systems. A system like this has severe security implications if misconfigured or misused, and the author is not responsible for any damage caused by its use. I considered a tool like this to be necessary for some specific systems I was working on, and I'd have had to essentially build it regardless as part of that other system, so I thought why not genericize it and share it.

Status

Prototype v1 implementation. The core API, SQLite state, API key auth, acme.sh backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, Docker packaging, mock-backed end-to-end integration tests, and GitHub Actions CI are implemented. Real staging/Pebble ACME tests remain optional and credential-gated.

Quick Start

Build and start the local container:

make build
make start
curl http://localhost:8080/health
curl http://localhost:8080/ready

The compose file uses named volumes for persistent runtime state:

VolumeContainer pathPurpose
acme-api-data/dataSQLite database
acme-api-certificates/certificatesAtomically deployed certificate files
acme-api-acmesh/acmeshacme.sh account and certificate state

The bundled compose config at docker/config.yaml is intentionally minimal so the service can boot for health checks. For real issuance, copy config.example.yaml, configure ACME accounts, DNS provider aliases, and credential file mounts, then set ACME_API_CONFIG to that file inside the container.

Local Development

make dev
make verify

Useful targets:

CommandDescription
make testRun the unit, integration, and ordinary end-to-end suites plus coverage gates
make test-unitRun deterministic unit tests
make test-integrationRun mock-backed integration tests
make test-e2eRun the Pebble-backed Docker Compose end-to-end test stack
make deps-checkVerify uv.lock and both hashed requirements exports
make deps-updateUpgrade dependencies and regenerate uv.lock and exports
make format-checkCheck Ruff formatting
make lintRun Ruff and Pylint
make type-checkRun strict type checking
make verifyRun the full local quality and test gate
make simulate-ciExecute the GitHub Actions workflow locally with act
make buildBuild the Docker image
make startStart the Docker compose service
make stopStop the Docker compose service
make logsFollow container logs

Development uses uv for locking and export verification. make dev follows Vulpine's hashed-install workflow: it bootstraps .venv and installs requirements-dev.txt with --require-hashes --no-deps.

Configuration

Configuration is YAML. By default the app loads ./config.yaml; set ACME_API_CONFIG=/path/to/config.yaml to override it. See config.example.yaml for a complete reference.

Certificate issuance requires an acme_accounts entry and an authenticated API client. Fresh acme.api installations intentionally create no API clients:

printf'%s'"$ADMIN_KEY"| acme-api admin initialize --key-stdin

This stdin-only command is the one-time local administrative trust boundary and can run only while the persisted API-client table is empty. It creates the initial admin client; afterward, authenticated admins create, rotate, revoke, and list admin, operator, and readonly clients at /v1/admin/clients. Configuration has no api_keys setting. If every admin credential is lost, stop the service, back up the database, and remove only the API-client records; preserve certificate, account, renewal, deployment, and audit rows. Standard DNS-01 issuance additionally requires a configured dns_providers alias and a provider credential file readable by the container. DNS Persist issuance does not require either: its one-time TXT record is generated from the selected account.

Deployment configuration

deployment.directory is the artifact root. Mount it read/write only in the acme.api container and read-only in certificate consumers. permissions_cert and permissions_key are decimal file modes; their defaults are 420 (0644) and 384 (0600) respectively.

Set deployment.artifact_group_id only when a separate unprivileged consumer must read private keys. It is a numeric GID, not a group name, and acme.api must run with that GID as a supplementary group. If it cannot assign the group to a deployment directory or artifact, issuance or renewal records a deployment failure rather than publishing an unexpected access policy. A typical shared-volume configuration uses permissions_key: 416 (0640) and grants

read-only consumers membership in the same GID. When configured, acme.api sets directories it owns—or whose group it changes—to that group with 0750 mode, ensuring consumers can traverse them even with a restrictive umask. Pre-provisioned directories owned by another user retain their ownership and mode only when they already belong to the configured GID, so a non-root service can use an administrator-managed volume without CAP_CHOWN.

Enable deployment.generation_aware to preserve every successful issuance or renewal as an immutable artifact set. acme.api publishes a complete generation directory, then atomically switches the current symlink. The established cert.pem, chain.pem, fullchain.pem, privkey.pem, and metadata.json paths become symlinks through that pointer, so existing consumers keep their predictable paths. generation_retention_count and generation_retention_days may be set independently; a generation is removed only when it exceeds every configured limit. The selected generation and any explicitly pinned generation are never removed.

Example certificate request:

{
"name": "wildcard-example",
"domains": ["*.example.com", "example.com"],
"acme_account_ref": "letsencrypt-production",
"dns_provider_ref": "production",
"key_algorithm": "ecdsa"
}

DNS Persist certificates

For a zone managed manually, create a request with "challenge_method": "dns-persist" and omit dns_provider_ref. The response remains pending_dns and contains an account-bound TXT instruction at _validation-persist.<primary-domain>. Publish that exact value and retain it for the certificate's lifetime, then call POST /v1/certificates/{id}/authorize. The service issues with the selected account only after that explicit authorization. DNS Persist SANs must be the primary domain or its subdomains. Multi-SAN and wildcard requests receive a policy=wildcard instruction, which deliberately authorizes that primary domain's subdomains; use separate requests for unrelated domains.

Creation with the same name, domains, and account resumes the stored request and instruction; it does not create another ACME order. A different account creates a distinct instruction and cannot replace an existing request's account. Once valid, DNS Persist certificates renew unattended through the normal scheduler without DNS provider credentials or another TXT update. The instruction is returned only from authenticated certificate endpoints.

Held DNS Persist workflow

Set "held": true when creating a DNS Persist request to persist its stable TXT instruction without allowing issuance. After publishing the record, call POST /v1/certificates/{id}/authorize; this advances the request to authorization_ready but still does not issue. To release the current prepared revision, call POST /v1/certificates/{id}/release with an Idempotency-Key header and a JSON body containing the response's current revision, for example:

{"revision": 1}

Release is accepted only once for that revision and queues asynchronous issuance. Retry the same request with the same idempotency key if the client does not receive the response; a retry also re-queues issuance if the stored request is still released. Delete a held, authorization-ready, released, or release-derived issuing request to cancel it.

REST API

OpenAPI is generated at /openapi.json; Swagger UI is available at /docs.

MethodPathAuthDescription
GET/healthnoneLiveness probe with uptime
GET/readynoneDB and acme.sh readiness
POST/v1/certificatesoperatorCreate a certificate request; DNS Persist returns its stored TXT instruction
GET/v1/certificatesreadonlyList certificates
GET/v1/certificates/{id}readonlyRead certificate detail and DNS Persist instruction
POST/v1/certificates/{id}/authorizeoperatorAuthorize or retry DNS Persist issuance after publishing TXT
POST/v1/certificates/{id}/releaseoperatorRelease a held DNS Persist revision; requires Idempotency-Key and { "revision": n }
POST/v1/certificates/{id}/renewoperatorQueue manual renewal
POST/v1/certificates/{id}/revokeoperatorRevoke the issued primary domain through acme.sh; requires Idempotency-Key
DELETE/v1/certificates/{id}operatorSoft-delete as revoked
GET/v1/accountsreadonlyList configured ACME accounts
GET/v1/providersreadonlyList configured DNS providers
GET/v1/eventsreadonlyQuery audit events
GET/v1/admin/clientsadminList safe API-client metadata
POST/v1/admin/clientsadminCreate an API client and return its credential once
POST/v1/admin/clients/{id}/rotateadminRotate a client credential and return its replacement once
POST/v1/admin/clients/{id}/revokeadminRevoke an API client

Authenticated requests use bearer API keys:

curl \
-H "Authorization: Bearer $ACME_API_KEY" \
http://localhost:8080/v1/certificates

Certificate revocation

DELETE /v1/certificates/{id} only changes the local request record; it does not contact a certificate authority. To revoke an issued certificate at its configured CA, call POST /v1/certificates/{id}/revoke with an Idempotency-Key header and, optionally, an RFC 5280 reason:

curl -X POST \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Idempotency-Key: revoke-example-20260724" \
-H "Content-Type: application/json" \
--data '{"reason": 1}' \
http://localhost:8080/v1/certificates/$CERTIFICATE_ID/revoke

The operation invokes acme.sh as --revoke --domain <primary-domain> and adds --revoke-reason when requested. It does not delete deployed artifacts, disable renewal, or otherwise modify the local certificate record. Reusing the same key returns the durable original result without another acme.sh command. Reasons 0 through 10 are accepted except 7, which RFC 5280 leaves unused.

acme.sh selects the certificate it revokes from its managed domain and key-type slot; it does not accept a certificate file, serial number, fingerprint, or deployment generation. Generation selection only repoints acme.api's deployed artifact view and does not modify acme.sh's managed certificate. Consequently, CA revocation through this endpoint cannot target a retained historical generation independently of the certificate currently managed by acme.sh.

Certificate Deployment

Successful issuance and renewal deploy artifacts under the deployment_directory reported by every authenticated certificate API response, relative to the configured deployment root. For ordinary certificates it is the first requested domain:

/certificates/example.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

With generation-aware deployment enabled, each immutable publication is stored below a dedicated namespace:

/certificates/example.com/
current -> generations/<generation_id>
fullchain.pem -> current/fullchain.pem
privkey.pem -> current/privkey.pem
generations/<generation_id>/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

The generations/ component is intentional rather than redundant: it unambiguously separates immutable historical artifacts from the stable compatibility projection and future deployment control files. Wildcard domains use a portable collision-free name: a request for *.example.com reports deployment_directory: "@wildcard@.example.com" and deploys under /certificates/@wildcard@.example.com/. This cannot collide with a separate request for the valid literal name wildcard.example.com. Clients consuming the shared certificate volume must always resolve artifact paths from the API's deployment_directory field, never derive them from the requested identifier.

Files are copied to temporary names. acme.api assigns configured group and mode through each open file descriptor, then fsyncs the content and access-control metadata before atomically renaming artifacts into place. It also sets the deployment root and target directory to the configured group with 0750 traversal mode. The same process runs for initial issuance and renewal, so consumers never need to repair ownership or permissions themselves.

Architecture

 REST API
|
+---------------+---------------+
| |
Certificate Lifecycle Renewal Scheduler
| |
+---------------+---------------+
|
ACME Backend
|
acme.sh

The public API is independent of the ACME backend. v1 supports DNS-01 through acme.sh; future backends can be added behind the same internal protocol.

Non-Goals For v1

  • HTTP-01 validation
  • TLS-ALPN-01 validation
  • per-request DNS credentials
  • web UI
  • high availability or clustering
  • implementing the ACME protocol directly

About

acme.api is a lightweight, self-hosted REST service for managing ACME certificates; in the first instance focusing on acme.sh as a backend.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

acme.api

Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to acme.sh.

Warning

This project is entirely experimental currently. Do not use it for production, important certificates, or anything you are not prepared to delete and rebuild.

acme.api is meant to be consumed only by applications that enforce strict access controls as part of the application stack — it is never intended for direct exposure on the internet or general internal systems. A system like this has severe security implications if misconfigured or misused, and the author is not responsible for any damage caused by its use. I considered a tool like this to be necessary for some specific systems I was working on, and I'd have had to essentially build it regardless as part of that other system, so I thought why not genericize it and share it.

Status

Prototype v1 implementation. The core API, SQLite state, API key auth, acme.sh backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, Docker packaging, mock-backed end-to-end integration tests, and GitHub Actions CI are implemented. Real staging/Pebble ACME tests remain optional and credential-gated.

Quick Start

Build and start the local container:

make build
make start
curl http://localhost:8080/health
curl http://localhost:8080/ready

The compose file uses named volumes for persistent runtime state:

VolumeContainer pathPurpose
acme-api-data/dataSQLite database
acme-api-certificates/certificatesAtomically deployed certificate files
acme-api-acmesh/acmeshacme.sh account and certificate state

The bundled compose config at docker/config.yaml is intentionally minimal so the service can boot for health checks. For real issuance, copy config.example.yaml, configure ACME accounts, DNS provider aliases, and credential file mounts, then set ACME_API_CONFIG to that file inside the container.

Local Development

make dev
make verify

Useful targets:

CommandDescription
make testRun the unit, integration, and ordinary end-to-end suites plus coverage gates
make test-unitRun deterministic unit tests
make test-integrationRun mock-backed integration tests
make test-e2eRun the Pebble-backed Docker Compose end-to-end test stack
make deps-checkVerify uv.lock and both hashed requirements exports
make deps-updateUpgrade dependencies and regenerate uv.lock and exports
make format-checkCheck Ruff formatting
make lintRun Ruff and Pylint
make type-checkRun strict type checking
make verifyRun the full local quality and test gate
make simulate-ciExecute the GitHub Actions workflow locally with act
make buildBuild the Docker image
make startStart the Docker compose service
make stopStop the Docker compose service
make logsFollow container logs

Development uses uv for locking and export verification. make dev follows Vulpine's hashed-install workflow: it bootstraps .venv and installs requirements-dev.txt with --require-hashes --no-deps.

Configuration

Configuration is YAML. By default the app loads ./config.yaml; set ACME_API_CONFIG=/path/to/config.yaml to override it. See config.example.yaml for a complete reference.

Certificate issuance requires an acme_accounts entry and an authenticated API client. Fresh acme.api installations intentionally create no API clients:

printf'%s'"$ADMIN_KEY"| acme-api admin initialize --key-stdin

This stdin-only command is the one-time local administrative trust boundary and can run only while the persisted API-client table is empty. It creates the initial admin client; afterward, authenticated admins create, rotate, revoke, and list admin, operator, and readonly clients at /v1/admin/clients. Configuration has no api_keys setting. If every admin credential is lost, stop the service, back up the database, and remove only the API-client records; preserve certificate, account, renewal, deployment, and audit rows. Standard DNS-01 issuance additionally requires a configured dns_providers alias and a provider credential file readable by the container. DNS Persist issuance does not require either: its one-time TXT record is generated from the selected account.

Deployment configuration

deployment.directory is the artifact root. Mount it read/write only in the acme.api container and read-only in certificate consumers. permissions_cert and permissions_key are decimal file modes; their defaults are 420 (0644) and 384 (0600) respectively.

Set deployment.artifact_group_id only when a separate unprivileged consumer must read private keys. It is a numeric GID, not a group name, and acme.api must run with that GID as a supplementary group. If it cannot assign the group to a deployment directory or artifact, issuance or renewal records a deployment failure rather than publishing an unexpected access policy. A typical shared-volume configuration uses permissions_key: 416 (0640) and grants

read-only consumers membership in the same GID. When configured, acme.api sets directories it owns—or whose group it changes—to that group with 0750 mode, ensuring consumers can traverse them even with a restrictive umask. Pre-provisioned directories owned by another user retain their ownership and mode only when they already belong to the configured GID, so a non-root service can use an administrator-managed volume without CAP_CHOWN.

Enable deployment.generation_aware to preserve every successful issuance or renewal as an immutable artifact set. acme.api publishes a complete generation directory, then atomically switches the current symlink. The established cert.pem, chain.pem, fullchain.pem, privkey.pem, and metadata.json paths become symlinks through that pointer, so existing consumers keep their predictable paths. generation_retention_count and generation_retention_days may be set independently; a generation is removed only when it exceeds every configured limit. The selected generation and any explicitly pinned generation are never removed.

Example certificate request:

{
"name": "wildcard-example",
"domains": ["*.example.com", "example.com"],
"acme_account_ref": "letsencrypt-production",
"dns_provider_ref": "production",
"key_algorithm": "ecdsa"
}

DNS Persist certificates

For a zone managed manually, create a request with "challenge_method": "dns-persist" and omit dns_provider_ref. The response remains pending_dns and contains an account-bound TXT instruction at _validation-persist.<primary-domain>. Publish that exact value and retain it for the certificate's lifetime, then call POST /v1/certificates/{id}/authorize. The service issues with the selected account only after that explicit authorization. DNS Persist SANs must be the primary domain or its subdomains. Multi-SAN and wildcard requests receive a policy=wildcard instruction, which deliberately authorizes that primary domain's subdomains; use separate requests for unrelated domains.

Creation with the same name, domains, and account resumes the stored request and instruction; it does not create another ACME order. A different account creates a distinct instruction and cannot replace an existing request's account. Once valid, DNS Persist certificates renew unattended through the normal scheduler without DNS provider credentials or another TXT update. The instruction is returned only from authenticated certificate endpoints.

Held DNS Persist workflow

Set "held": true when creating a DNS Persist request to persist its stable TXT instruction without allowing issuance. After publishing the record, call POST /v1/certificates/{id}/authorize; this advances the request to authorization_ready but still does not issue. To release the current prepared revision, call POST /v1/certificates/{id}/release with an Idempotency-Key header and a JSON body containing the response's current revision, for example:

{"revision": 1}

Release is accepted only once for that revision and queues asynchronous issuance. Retry the same request with the same idempotency key if the client does not receive the response; a retry also re-queues issuance if the stored request is still released. Delete a held, authorization-ready, released, or release-derived issuing request to cancel it.

REST API

OpenAPI is generated at /openapi.json; Swagger UI is available at /docs.

MethodPathAuthDescription
GET/healthnoneLiveness probe with uptime
GET/readynoneDB and acme.sh readiness
POST/v1/certificatesoperatorCreate a certificate request; DNS Persist returns its stored TXT instruction
GET/v1/certificatesreadonlyList certificates
GET/v1/certificates/{id}readonlyRead certificate detail and DNS Persist instruction
POST/v1/certificates/{id}/authorizeoperatorAuthorize or retry DNS Persist issuance after publishing TXT
POST/v1/certificates/{id}/releaseoperatorRelease a held DNS Persist revision; requires Idempotency-Key and { "revision": n }
POST/v1/certificates/{id}/renewoperatorQueue manual renewal
POST/v1/certificates/{id}/revokeoperatorRevoke the issued primary domain through acme.sh; requires Idempotency-Key
DELETE/v1/certificates/{id}operatorSoft-delete as revoked
GET/v1/accountsreadonlyList configured ACME accounts
GET/v1/providersreadonlyList configured DNS providers
GET/v1/eventsreadonlyQuery audit events
GET/v1/admin/clientsadminList safe API-client metadata
POST/v1/admin/clientsadminCreate an API client and return its credential once
POST/v1/admin/clients/{id}/rotateadminRotate a client credential and return its replacement once
POST/v1/admin/clients/{id}/revokeadminRevoke an API client

Authenticated requests use bearer API keys:

curl \
-H "Authorization: Bearer $ACME_API_KEY" \
http://localhost:8080/v1/certificates

Certificate revocation

DELETE /v1/certificates/{id} only changes the local request record; it does not contact a certificate authority. To revoke an issued certificate at its configured CA, call POST /v1/certificates/{id}/revoke with an Idempotency-Key header and, optionally, an RFC 5280 reason:

curl -X POST \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Idempotency-Key: revoke-example-20260724" \
-H "Content-Type: application/json" \
--data '{"reason": 1}' \
http://localhost:8080/v1/certificates/$CERTIFICATE_ID/revoke

The operation invokes acme.sh as --revoke --domain <primary-domain> and adds --revoke-reason when requested. It does not delete deployed artifacts, disable renewal, or otherwise modify the local certificate record. Reusing the same key returns the durable original result without another acme.sh command. Reasons 0 through 10 are accepted except 7, which RFC 5280 leaves unused.

acme.sh selects the certificate it revokes from its managed domain and key-type slot; it does not accept a certificate file, serial number, fingerprint, or deployment generation. Generation selection only repoints acme.api's deployed artifact view and does not modify acme.sh's managed certificate. Consequently, CA revocation through this endpoint cannot target a retained historical generation independently of the certificate currently managed by acme.sh.

Certificate Deployment

Successful issuance and renewal deploy artifacts under the deployment_directory reported by every authenticated certificate API response, relative to the configured deployment root. For ordinary certificates it is the first requested domain:

/certificates/example.com/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

With generation-aware deployment enabled, each immutable publication is stored below a dedicated namespace:

/certificates/example.com/
current -> generations/<generation_id>
fullchain.pem -> current/fullchain.pem
privkey.pem -> current/privkey.pem
generations/<generation_id>/
cert.pem
chain.pem
fullchain.pem
privkey.pem
metadata.json

The generations/ component is intentional rather than redundant: it unambiguously separates immutable historical artifacts from the stable compatibility projection and future deployment control files. Wildcard domains use a portable collision-free name: a request for *.example.com reports deployment_directory: "@wildcard@.example.com" and deploys under /certificates/@wildcard@.example.com/. This cannot collide with a separate request for the valid literal name wildcard.example.com. Clients consuming the shared certificate volume must always resolve artifact paths from the API's deployment_directory field, never derive them from the requested identifier.

Files are copied to temporary names. acme.api assigns configured group and mode through each open file descriptor, then fsyncs the content and access-control metadata before atomically renaming artifacts into place. It also sets the deployment root and target directory to the configured group with 0750 traversal mode. The same process runs for initial issuance and renewal, so consumers never need to repair ownership or permissions themselves.

Architecture

 REST API
|
+---------------+---------------+
| |
Certificate Lifecycle Renewal Scheduler
| |
+---------------+---------------+
|
ACME Backend
|
acme.sh

The public API is independent of the ACME backend. v1 supports DNS-01 through acme.sh; future backends can be added behind the same internal protocol.

Non-Goals For v1

  • HTTP-01 validation
  • TLS-ALPN-01 validation
  • per-request DNS credentials
  • web UI
  • high availability or clustering
  • implementing the ACME protocol directly

About

acme.api is a lightweight, self-hosted REST service for managing ACME certificates; in the first instance focusing on acme.sh as a backend.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages