| 1 | --- |
| 2 | title: Runtime issuance |
| 3 | description: Deploy an authenticated Worker that issues and revokes certificates using a bound ACME account. |
| 4 | --- |
| 5 | |
| 6 | `ACME.IssueCertificate` lets an application issue certificates without deploying |
| 7 | a new `ACME.Certificate` resource. The account is still managed by your stack, |
| 8 | but the application owns the issued certificates' storage, installation, renewal, |
| 9 | and revocation. |
| 10 | |
| 11 | This example deploys an authenticated administrative Worker. `POST /issue` |
| 12 | returns a certificate for one fixed hostname; `POST /revoke` accepts a leaf PEM |
| 13 | and revokes it using the bound account. It is a starting point for an internal |
| 14 | issuer, not a public self-service certificate API. |
| 15 | |
| 16 | ## Prepare credentials |
| 17 | |
| 18 | Complete [Cloudflare setup](/cloudflare/setup) and obtain [ZeroSSL EAB credentials](/acme/certificate-authorities#configure-zerossl-external-account-binding). |
| 19 | Set `ZEROSSL_EAB_KID`, `ZEROSSL_EAB_HMAC_KEY`, and a long, randomly generated |
| 20 | `ISSUER_ADMIN_TOKEN` in your deployment's secret configuration. Replace |
| 21 | `example.com` and `api.example.com` below with your zone and hostname. |
| 22 | |
| 23 | The deployment identity also needs permission to create the zone-scoped API |
| 24 | token used by `WriteDnsHttp`. It is not enough to supply a token that can only |
| 25 | edit DNS records. See the [DNS write binding](/providers/cloudflare/dns/writedns). |
| 26 | |
| 27 | :::note[CA reachability] |
| 28 | The deployed-Worker integration test uses ZeroSSL. Let's Encrypt returned TLS |
| 29 | connection errors from deployed Cloudflare Workers during testing, although it |
| 30 | worked for deploy-time and local-workerd issuance. Verify the CA from the actual |
| 31 | hosting environment. ZeroSSL issuance uses its production CA and consumes its |
| 32 | issuance allowance. |
| 33 | ::: |
| 34 | |
| 35 | ## Define the account |
| 36 | |
| 37 | Create `resources.ts`: |
| 38 | |
| 39 | ```typescript |
| 40 | import * as ACME from "alchemy/ACME"; |
| 41 | import * as Config from "effect/Config"; |
| 42 | |
| 43 | export const Issuer = ACME.Account("Issuer", { |
| 44 | ca: ACME.ZeroSSL, |
| 45 | eab: { |
| 46 | keyId: Config.String("ZEROSSL_EAB_KID"), |
| 47 | hmacKey: Config.Redacted("ZEROSSL_EAB_HMAC_KEY"), |
| 48 | }, |
| 49 | termsOfServiceAgreed: true, |
| 50 | }); |
| 51 | ``` |
| 52 | |
| 53 | The Worker will bind this account's directory URL, account URL, and redacted |
| 54 | private key. Treat access to the Worker and its configuration as access to the |
| 55 | account. |
| 56 | |
| 57 | ## Reference the runtime DNS zone |
| 58 | |
| 59 | Add a zone resource to `resources.ts`: |
| 60 | |
| 61 | ```typescript |
| 62 | import { adopt } from "alchemy/AdoptPolicy"; |
| 63 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 64 | import { retain } from "alchemy/RemovalPolicy"; |
| 65 | |
| 66 | export const Zone = Cloudflare.Zone.Zone("DnsZone", { |
| 67 | name: "example.com", |
| 68 | }).pipe(adopt(), retain()); |
| 69 | ``` |
| 70 | |
| 71 | Unlike the ID-only deploy-time solver, `WriteDns` binds a zone resource. This |
| 72 | example adopts your existing zone and retains it when the stack is destroyed. |
| 73 | Adoption allows Alchemy to manage the zone's settings, so inspect the plan and |
| 74 | match any non-default settings you need to preserve. If your application already |
| 75 | owns a zone resource, import it instead of declaring another one. |
| 76 | |
| 77 | ## Bind the issuer |
| 78 | |
| 79 | In the Worker's initialization Effect, bind the account: |
| 80 | |
| 81 | ```typescript |
| 82 | const issuer = yield* ACME.IssueCertificate(Issuer); |
| 83 | ``` |
| 84 | |
| 85 | Provide `ACME.IssueCertificateHttp` on that host Effect. The client methods run |
| 86 | inside a request and require Alchemy's runtime context; they are not deploy-time |
| 87 | operations. |
| 88 | |
| 89 | ## Bind DNS access |
| 90 | |
| 91 | ```typescript |
| 92 | const dns = yield* Cloudflare.DNS.WriteDns(Zone); |
| 93 | ``` |
| 94 | |
| 95 | Provide `Cloudflare.DNS.WriteDnsHttp` on the same host Effect. It creates a DNS |
| 96 | write token scoped to `Zone` at deployment. The complete Worker below includes |
| 97 | both implementation layers. |
| 98 | |
| 99 | ## Authorize the request |
| 100 | |
| 101 | ```typescript |
| 102 | if (request.headers.authorization !== `Bearer ${Redacted.value(adminToken)}`) { |
| 103 | return HttpServerResponse.text("Unauthorized", { status: 401 }); |
| 104 | } |
| 105 | ``` |
| 106 | |
| 107 | Read `adminToken` from `Config.Redacted("ISSUER_ADMIN_TOKEN")` during initialization. |
| 108 | Authentication runs before issuance or revocation. A production multi-tenant |
| 109 | issuer also needs per-tenant domain authorization, quotas, and durable job |
| 110 | serialization; possession of a DNS token alone is not tenant authorization. |
| 111 | |
| 112 | ## Issue one fixed hostname |
| 113 | |
| 114 | ```typescript |
| 115 | const issued = yield* issuer.issue({ |
| 116 | identifiers: ["api.example.com"], |
| 117 | solver: Cloudflare.DNS.acmeDnsSolver(dns), |
| 118 | }); |
| 119 | ``` |
| 120 | |
| 121 | Keep the hostname fixed until you have a domain-authorization model. Constructing |
| 122 | a solver per call isolates its cleanup bookkeeping. The result includes `chain`, |
| 123 | `certificate`, `privateKey`, `notAfter`, and order metadata. |
| 124 | |
| 125 | ## Revoke a leaf certificate |
| 126 | |
| 127 | ```typescript |
| 128 | yield* issuer.revoke({ certificate: yield* request.text }); |
| 129 | ``` |
| 130 | |
| 131 | The account signs the revocation request. Alternatively, pass the certificate's |
| 132 | own `privateKey` to sign with that key. An already-revoked response counts as |
| 133 | success. Revocation does not remove a certificate installed on Fly or another |
| 134 | TLS consumer. |
| 135 | |
| 136 | ## Assemble the Worker |
| 137 | |
| 138 | Create `issuer-worker.ts`. This complete file combines the bindings, authorization, |
| 139 | and two operations above: |
| 140 | |
| 141 | ```typescript |
| 142 | import * as ACME from "alchemy/ACME"; |
| 143 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 144 | import * as Config from "effect/Config"; |
| 145 | import * as Duration from "effect/Duration"; |
| 146 | import * as Effect from "effect/Effect"; |
| 147 | import * as Redacted from "effect/Redacted"; |
| 148 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 149 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 150 | import { Issuer, Zone } from "./resources.ts"; |
| 151 | |
| 152 | export default class IssuerWorker extends Cloudflare.Worker<IssuerWorker>()( |
| 153 | "IssuerWorker", |
| 154 | { main: import.meta.url }, |
| 155 | Effect.gen(function* () { |
| 156 | const issuer = yield* ACME.IssueCertificate(Issuer); |
| 157 | const dns = yield* Cloudflare.DNS.WriteDns(Zone); |
| 158 | const adminToken = yield* Config.Redacted("ISSUER_ADMIN_TOKEN"); |
| 159 | |
| 160 | return { |
| 161 | fetch: Effect.gen(function* () { |
| 162 | const request = yield* HttpServerRequest; |
| 163 | if (request.headers.authorization !== `Bearer ${Redacted.value(adminToken)}`) { |
| 164 | return HttpServerResponse.text("Unauthorized", { status: 401 }); |
| 165 | } |
| 166 | if (request.method !== "POST") { |
| 167 | return HttpServerResponse.text("Use POST", { |
| 168 | status: 405, |
| 169 | headers: { allow: "POST" }, |
| 170 | }); |
| 171 | } |
| 172 | const pathname = new URL(request.url, "https://issuer.invalid").pathname; |
| 173 | if (pathname === "/issue") { |
| 174 | const issued = yield* issuer.issue({ |
| 175 | identifiers: ["api.example.com"], |
| 176 | solver: Cloudflare.DNS.acmeDnsSolver(dns), |
| 177 | }); |
| 178 | return yield* HttpServerResponse.json( |
| 179 | { |
| 180 | certificate: issued.certificate, |
| 181 | chain: issued.chain, |
| 182 | privateKey: Redacted.value(issued.privateKey), |
| 183 | notAfter: issued.notAfter, |
| 184 | serial: issued.serial, |
| 185 | }, |
| 186 | { headers: { "cache-control": "no-store" } }, |
| 187 | ); |
| 188 | } |
| 189 | if (pathname === "/revoke") { |
| 190 | const certificate = yield* request.text; |
| 191 | if (!certificate.startsWith("-----BEGIN CERTIFICATE-----")) { |
| 192 | return HttpServerResponse.text("Expected a leaf PEM", { status: 400 }); |
| 193 | } |
| 194 | yield* issuer.revoke({ certificate }); |
| 195 | return HttpServerResponse.text("Revoked"); |
| 196 | } |
| 197 | return HttpServerResponse.text("Not found", { status: 404 }); |
| 198 | }).pipe( |
| 199 | Effect.catchTag("AcmeRateLimited", (error) => |
| 200 | HttpServerResponse.json( |
| 201 | { error: "Certificate authority rate limit" }, |
| 202 | { |
| 203 | status: 429, |
| 204 | headers: error.retryAfter === undefined |
| 205 | ? {} |
| 206 | : { |
| 207 | "retry-after": String(Math.ceil( |
| 208 | Duration.toMillis(error.retryAfter) / 1000, |
| 209 | )), |
| 210 | }, |
| 211 | }, |
| 212 | ), |
| 213 | ), |
| 214 | Effect.catchCause(() => |
| 215 | Effect.succeed( |
| 216 | HttpServerResponse.text("Certificate operation failed", { status: 502 }), |
| 217 | ), |
| 218 | ), |
| 219 | ), |
| 220 | }; |
| 221 | }).pipe( |
| 222 | Effect.provide(ACME.IssueCertificateHttp), |
| 223 | Effect.provide(Cloudflare.DNS.WriteDnsHttp), |
| 224 | ), |
| 225 | ) {} |
| 226 | ``` |
| 227 | |
| 228 | The response deliberately unwraps the **certificate** private key for the |
| 229 | administrative caller, over HTTPS, with `Cache-Control: no-store`. It never |
| 230 | returns the **account** key. Do not log response bodies or publish this endpoint |
| 231 | without authentication. A production issuer should generally write directly to |
| 232 | its protected certificate store or TLS consumer instead of exporting keys over |
| 233 | HTTP. Replace the generic failure response with sanitized internal diagnostics |
| 234 | when integrating it into your application. |
| 235 | |
| 236 | ## Deploy the Worker |
| 237 | |
| 238 | Create `alchemy.run.ts`: |
| 239 | |
| 240 | ```typescript |
| 241 | import * as Alchemy from "alchemy"; |
| 242 | import * as ACME from "alchemy/ACME"; |
| 243 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 244 | import * as Effect from "effect/Effect"; |
| 245 | import * as Layer from "effect/Layer"; |
| 246 | import IssuerWorker from "./issuer-worker.ts"; |
| 247 | |
| 248 | export default Alchemy.Stack( |
| 249 | "RuntimeCertificates", |
| 250 | { |
| 251 | providers: Layer.mergeAll(ACME.providers(), Cloudflare.providers()), |
| 252 | state: Alchemy.localState(), |
| 253 | }, |
| 254 | Effect.gen(function* () { |
| 255 | const worker = yield* IssuerWorker; |
| 256 | return { url: worker.url }; |
| 257 | }), |
| 258 | ); |
| 259 | ``` |
| 260 | |
| 261 | Run `alchemy deploy --stage dev` and use the returned HTTPS URL. Protect the |
| 262 | local state, or switch to your protected remote state backend before sharing |
| 263 | this deployment with a CI runner. |
| 264 | |
| 265 | ## Call the issuer |
| 266 | |
| 267 | Set `ISSUER_URL` to the deployment output and `ISSUER_ADMIN_TOKEN` in your shell |
| 268 | without committing or sharing its value: |
| 269 | |
| 270 | ```sh |
| 271 | umask 077 |
| 272 | curl --fail-with-body --max-time 180 \ |
| 273 | -X POST "$ISSUER_URL/issue" \ |
| 274 | -H "Authorization: Bearer $ISSUER_ADMIN_TOKEN" \ |
| 275 | -o issued.json |
| 276 | ``` |
| 277 | |
| 278 | `issued.json` contains a private key; the restrictive umask keeps newly created |
| 279 | files private to your user. Store the result securely rather than printing it. |
| 280 | Every successful call issues again: this endpoint has no cache or idempotency |
| 281 | key. A client timeout can leave issuance completed without a delivered result, |
| 282 | so avoid blind retries. |
| 283 | |
| 284 | ## Revoke the test certificate |
| 285 | |
| 286 | ```sh |
| 287 | jq -r '.certificate' issued.json > leaf.pem |
| 288 | curl --fail-with-body --max-time 60 \ |
| 289 | -X POST "$ISSUER_URL/revoke" \ |
| 290 | -H "Authorization: Bearer $ISSUER_ADMIN_TOKEN" \ |
| 291 | -H "Content-Type: application/pem-certificate-chain" \ |
| 292 | --data-binary @leaf.pem |
| 293 | ``` |
| 294 | |
| 295 | Revoke only after removing or replacing the certificate at any consumer using |
| 296 | it. Dispose of local key material according to your secret-handling policy. |
| 297 | Destroying the Worker stack does not revoke this runtime-issued certificate. |
| 298 | |
| 299 | ## Add storage and renewal before production |
| 300 | |
| 301 | Persist the issued material and expiry in an access-controlled store, and serialize |
| 302 | issuance for each identifier set. Schedule a new issuance before `notAfter`, install |
| 303 | the replacement, and verify the live TLS endpoint before retiring the old one. |
| 304 | Neither the Worker binding nor an in-memory variable provides those guarantees. |
| 305 | |
| 306 | DNS propagation alone waits 60 seconds by default. Ensure that your hosting plan, |
| 307 | client timeout, and request lifetime accommodate the full flow. For long-running |
| 308 | production work, use a durable job runner whose lifetime is independent of the |
| 309 | HTTP client, rather than starting untracked work after returning a response. |
| 310 | |
| 311 | The SDK retries individual protocol requests. Catch `AcmeRateLimited` to preserve |
| 312 | the CA's `retryAfter` hint, as above, and reschedule durably rather than repeatedly |
| 313 | retrying the entire issuance flow. See [Troubleshooting](/acme/troubleshooting). |