1---
2title: Runtime issuance
3description: 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
7a new `ACME.Certificate` resource. The account is still managed by your stack,
8but the application owns the issued certificates' storage, installation, renewal,
9and revocation.
10
11This example deploys an authenticated administrative Worker. `POST /issue`
12returns a certificate for one fixed hostname; `POST /revoke` accepts a leaf PEM
13and revokes it using the bound account. It is a starting point for an internal
14issuer, not a public self-service certificate API.
15
16## Prepare credentials
17
18Complete [Cloudflare setup](/cloudflare/setup) and obtain [ZeroSSL EAB credentials](/acme/certificate-authorities#configure-zerossl-external-account-binding).
19Set `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
23The deployment identity also needs permission to create the zone-scoped API
24token used by `WriteDnsHttp`. It is not enough to supply a token that can only
25edit DNS records. See the [DNS write binding](/providers/cloudflare/dns/writedns).
26
27:::note[CA reachability]
28The deployed-Worker integration test uses ZeroSSL. Let's Encrypt returned TLS
29connection errors from deployed Cloudflare Workers during testing, although it
30worked for deploy-time and local-workerd issuance. Verify the CA from the actual
31hosting environment. ZeroSSL issuance uses its production CA and consumes its
32issuance allowance.
33:::
34
35## Define the account
36
37Create `resources.ts`:
38
39```typescript
40import * as ACME from "alchemy/ACME";
41import * as Config from "effect/Config";
42
43export 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
53The Worker will bind this account's directory URL, account URL, and redacted
54private key. Treat access to the Worker and its configuration as access to the
55account.
56
57## Reference the runtime DNS zone
58
59Add a zone resource to `resources.ts`:
60
61```typescript
62import { adopt } from "alchemy/AdoptPolicy";
63import * as Cloudflare from "alchemy/Cloudflare";
64import { retain } from "alchemy/RemovalPolicy";
65
66export const Zone = Cloudflare.Zone.Zone("DnsZone", {
67 name: "example.com",
68}).pipe(adopt(), retain());
69```
70
71Unlike the ID-only deploy-time solver, `WriteDns` binds a zone resource. This
72example adopts your existing zone and retains it when the stack is destroyed.
73Adoption allows Alchemy to manage the zone's settings, so inspect the plan and
74match any non-default settings you need to preserve. If your application already
75owns a zone resource, import it instead of declaring another one.
76
77## Bind the issuer
78
79In the Worker's initialization Effect, bind the account:
80
81```typescript
82const issuer = yield* ACME.IssueCertificate(Issuer);
83```
84
85Provide `ACME.IssueCertificateHttp` on that host Effect. The client methods run
86inside a request and require Alchemy's runtime context; they are not deploy-time
87operations.
88
89## Bind DNS access
90
91```typescript
92const dns = yield* Cloudflare.DNS.WriteDns(Zone);
93```
94
95Provide `Cloudflare.DNS.WriteDnsHttp` on the same host Effect. It creates a DNS
96write token scoped to `Zone` at deployment. The complete Worker below includes
97both implementation layers.
98
99## Authorize the request
100
101```typescript
102if (request.headers.authorization !== `Bearer ${Redacted.value(adminToken)}`) {
103 return HttpServerResponse.text("Unauthorized", { status: 401 });
104}
105```
106
107Read `adminToken` from `Config.Redacted("ISSUER_ADMIN_TOKEN")` during initialization.
108Authentication runs before issuance or revocation. A production multi-tenant
109issuer also needs per-tenant domain authorization, quotas, and durable job
110serialization; possession of a DNS token alone is not tenant authorization.
111
112## Issue one fixed hostname
113
114```typescript
115const issued = yield* issuer.issue({
116 identifiers: ["api.example.com"],
117 solver: Cloudflare.DNS.acmeDnsSolver(dns),
118});
119```
120
121Keep the hostname fixed until you have a domain-authorization model. Constructing
122a 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
128yield* issuer.revoke({ certificate: yield* request.text });
129```
130
131The account signs the revocation request. Alternatively, pass the certificate's
132own `privateKey` to sign with that key. An already-revoked response counts as
133success. Revocation does not remove a certificate installed on Fly or another
134TLS consumer.
135
136## Assemble the Worker
137
138Create `issuer-worker.ts`. This complete file combines the bindings, authorization,
139and two operations above:
140
141```typescript
142import * as ACME from "alchemy/ACME";
143import * as Cloudflare from "alchemy/Cloudflare";
144import * as Config from "effect/Config";
145import * as Duration from "effect/Duration";
146import * as Effect from "effect/Effect";
147import * as Redacted from "effect/Redacted";
148import { HttpServerRequest } from "effect/http/HttpServerRequest";
149import * as HttpServerResponse from "effect/http/HttpServerResponse";
150import { Issuer, Zone } from "./resources.ts";
151
152export 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
228The response deliberately unwraps the **certificate** private key for the
229administrative caller, over HTTPS, with `Cache-Control: no-store`. It never
230returns the **account** key. Do not log response bodies or publish this endpoint
231without authentication. A production issuer should generally write directly to
232its protected certificate store or TLS consumer instead of exporting keys over
233HTTP. Replace the generic failure response with sanitized internal diagnostics
234when integrating it into your application.
235
236## Deploy the Worker
237
238Create `alchemy.run.ts`:
239
240```typescript
241import * as Alchemy from "alchemy";
242import * as ACME from "alchemy/ACME";
243import * as Cloudflare from "alchemy/Cloudflare";
244import * as Effect from "effect/Effect";
245import * as Layer from "effect/Layer";
246import IssuerWorker from "./issuer-worker.ts";
247
248export 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
261Run `alchemy deploy --stage dev` and use the returned HTTPS URL. Protect the
262local state, or switch to your protected remote state backend before sharing
263this deployment with a CI runner.
264
265## Call the issuer
266
267Set `ISSUER_URL` to the deployment output and `ISSUER_ADMIN_TOKEN` in your shell
268without committing or sharing its value:
269
270```sh
271umask 077
272curl --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
279files private to your user. Store the result securely rather than printing it.
280Every successful call issues again: this endpoint has no cache or idempotency
281key. A client timeout can leave issuance completed without a delivered result,
282so avoid blind retries.
283
284## Revoke the test certificate
285
286```sh
287jq -r '.certificate' issued.json > leaf.pem
288curl --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
295Revoke only after removing or replacing the certificate at any consumer using
296it. Dispose of local key material according to your secret-handling policy.
297Destroying the Worker stack does not revoke this runtime-issued certificate.
298
299## Add storage and renewal before production
300
301Persist the issued material and expiry in an access-controlled store, and serialize
302issuance for each identifier set. Schedule a new issuance before `notAfter`, install
303the replacement, and verify the live TLS endpoint before retiring the old one.
304Neither the Worker binding nor an in-memory variable provides those guarantees.
305
306DNS propagation alone waits 60 seconds by default. Ensure that your hosting plan,
307client timeout, and request lifetime accommodate the full flow. For long-running
308production work, use a durable job runner whose lifetime is independent of the
309HTTP client, rather than starting untracked work after returning a response.
310
311The SDK retries individual protocol requests. Catch `AcmeRateLimited` to preserve
312the CA's `retryAfter` hint, as above, and reschedule durably rather than repeatedly
313retrying the entire issuance flow. See [Troubleshooting](/acme/troubleshooting).