| 1 | --- |
| 2 | title: Getting started |
| 3 | description: Issue a staging TLS certificate with Let's Encrypt and an existing Cloudflare DNS zone. |
| 4 | --- |
| 5 | |
| 6 | This guide issues a certificate for a domain you control. It uses Let's Encrypt |
| 7 | staging, so you can validate the deployment without consuming production issuance |
| 8 | limits. The resulting certificate is deliberately **not trusted by browsers**. |
| 9 | |
| 10 | ## Prepare the project |
| 11 | |
| 12 | Install Alchemy and the matching Effect version using [Cloudflare setup](/cloudflare/setup). |
| 13 | You need an active Cloudflare DNS zone, its zone ID, and credentials that can read, |
| 14 | create, and delete DNS records in that zone. The domain's authoritative nameservers |
| 15 | must already point to Cloudflare. |
| 16 | |
| 17 | Use a domain you own wherever this guide says `example.com`. Set |
| 18 | `CLOUDFLARE_ZONE_ID` in your project's `.env` to the zone ID from its Cloudflare |
| 19 | dashboard. This example references the existing zone by ID; it does not create, |
| 20 | adopt, modify the settings of, or delete the zone itself. |
| 21 | |
| 22 | ## Create the stack |
| 23 | |
| 24 | Create `alchemy.run.ts`: |
| 25 | |
| 26 | ```typescript |
| 27 | import * as Alchemy from "alchemy"; |
| 28 | import * as Effect from "effect/Effect"; |
| 29 | import * as Layer from "effect/Layer"; |
| 30 | |
| 31 | export default Alchemy.Stack( |
| 32 | "Certificates", |
| 33 | { providers: Layer.empty, state: Alchemy.localState() }, |
| 34 | Effect.gen(function* () { |
| 35 | return {}; |
| 36 | }), |
| 37 | ); |
| 38 | ``` |
| 39 | |
| 40 | The stack groups the certificate resources and persists their state locally. |
| 41 | Treat that state as secret material and keep it out of source control. For a |
| 42 | shared deployment runner, use a protected [remote state store](/state-store). |
| 43 | |
| 44 | ## Register the providers |
| 45 | |
| 46 | ```diff lang="typescript" |
| 47 | +import * as ACME from "alchemy/ACME"; |
| 48 | +import * as Cloudflare from "alchemy/Cloudflare"; |
| 49 | |
| 50 | export default Alchemy.Stack( |
| 51 | "Certificates", |
| 52 | - { providers: Layer.empty, state: Alchemy.localState() }, |
| 53 | + { |
| 54 | + providers: Layer.mergeAll(ACME.providers(), Cloudflare.providers()), |
| 55 | + state: Alchemy.localState(), |
| 56 | + }, |
| 57 | ``` |
| 58 | |
| 59 | `ACME.providers()` manages accounts and certificates. `Cloudflare.providers()` |
| 60 | registers the Cloudflare DNS solver used to prove control of the domain. |
| 61 | |
| 62 | ## Connect Cloudflare |
| 63 | |
| 64 | ```sh |
| 65 | alchemy profile edit --add Cloudflare |
| 66 | ``` |
| 67 | |
| 68 | Select the Cloudflare account containing your zone. You can use OAuth or a |
| 69 | suitably scoped API token; [Cloudflare setup](/cloudflare/setup) also documents |
| 70 | non-interactive environment credentials for CI. |
| 71 | |
| 72 | ## Create an ACME account |
| 73 | |
| 74 | Inside the stack's `Effect.gen`, add the account before `return`: |
| 75 | |
| 76 | ```diff lang="typescript" |
| 77 | Effect.gen(function* () { |
| 78 | + const account = yield* ACME.Account("Issuer", { |
| 79 | + ca: ACME.LetsEncryptStaging, |
| 80 | + contact: ["mailto:ops@example.com"], |
| 81 | + termsOfServiceAgreed: true, |
| 82 | + }); |
| 83 | return {}; |
| 84 | }), |
| 85 | ``` |
| 86 | |
| 87 | Replace the contact address with yours and review the CA's terms before agreeing. |
| 88 | The account gets its own signing key, which Alchemy persists in stack state and |
| 89 | reuses on subsequent deployments. |
| 90 | |
| 91 | ## Request the certificate |
| 92 | |
| 93 | ```diff lang="typescript" |
| 94 | +import * as Config from "effect/Config"; |
| 95 | |
| 96 | const account = yield* ACME.Account("Issuer", { |
| 97 | ca: ACME.LetsEncryptStaging, |
| 98 | contact: ["mailto:ops@example.com"], |
| 99 | termsOfServiceAgreed: true, |
| 100 | }); |
| 101 | + const certificate = yield* ACME.Certificate("Site", { |
| 102 | + account, |
| 103 | + identifiers: ["example.com"], |
| 104 | + solver: Cloudflare.DNS.AcmeSolver({ |
| 105 | + zoneId: Config.String("CLOUDFLARE_ZONE_ID"), |
| 106 | + }), |
| 107 | + }); |
| 108 | ``` |
| 109 | |
| 110 | The solver publishes a temporary `_acme-challenge.example.com` TXT record. |
| 111 | It waits for propagation before asking the CA to validate it, then removes that |
| 112 | challenge record. Existing unrelated TXT values are preserved. |
| 113 | |
| 114 | ## Return certificate metadata |
| 115 | |
| 116 | ```diff lang="typescript" |
| 117 | - return {}; |
| 118 | + return { |
| 119 | + serial: certificate.serial, |
| 120 | + expires: certificate.notAfter, |
| 121 | + }; |
| 122 | ``` |
| 123 | |
| 124 | Return metadata rather than private keys. The PEM chain and redacted key remain |
| 125 | available as resource outputs for a [TLS consumer](/acme/using-certificates). |
| 126 | |
| 127 | ## Deploy to a development stage |
| 128 | |
| 129 | ```sh |
| 130 | alchemy deploy --stage dev |
| 131 | ``` |
| 132 | |
| 133 | Allow time for DNS validation: Cloudflare's solver includes a 60-second wait to |
| 134 | let cached challenge values expire. A successful deployment returns the serial |
| 135 | number and expiry. It does not configure a website or an HTTPS listener. |
| 136 | |
| 137 | ## Review the complete file |
| 138 | |
| 139 | The steps above produce this `alchemy.run.ts`: |
| 140 | |
| 141 | ```typescript |
| 142 | import * as Alchemy from "alchemy"; |
| 143 | import * as ACME from "alchemy/ACME"; |
| 144 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 145 | import * as Config from "effect/Config"; |
| 146 | import * as Effect from "effect/Effect"; |
| 147 | import * as Layer from "effect/Layer"; |
| 148 | |
| 149 | export default Alchemy.Stack( |
| 150 | "Certificates", |
| 151 | { |
| 152 | providers: Layer.mergeAll(ACME.providers(), Cloudflare.providers()), |
| 153 | state: Alchemy.localState(), |
| 154 | }, |
| 155 | Effect.gen(function* () { |
| 156 | const account = yield* ACME.Account("Issuer", { |
| 157 | ca: ACME.LetsEncryptStaging, |
| 158 | contact: ["mailto:ops@example.com"], |
| 159 | termsOfServiceAgreed: true, |
| 160 | }); |
| 161 | const certificate = yield* ACME.Certificate("Site", { |
| 162 | account, |
| 163 | identifiers: ["example.com"], |
| 164 | solver: Cloudflare.DNS.AcmeSolver({ |
| 165 | zoneId: Config.String("CLOUDFLARE_ZONE_ID"), |
| 166 | }), |
| 167 | }); |
| 168 | return { |
| 169 | serial: certificate.serial, |
| 170 | expires: certificate.notAfter, |
| 171 | }; |
| 172 | }), |
| 173 | ); |
| 174 | ``` |
| 175 | |
| 176 | ## Clean up the development stack |
| 177 | |
| 178 | ```sh |
| 179 | alchemy destroy --stage dev |
| 180 | ``` |
| 181 | |
| 182 | Destruction deactivates the ACME account and removes the stack's certificate |
| 183 | state. By default it does not revoke an issued certificate. The existing DNS |
| 184 | zone is not a managed resource in this example and remains intact. Read |
| 185 | [Renewal & revocation](/acme/renewal) before choosing a production deletion policy. |
| 186 | |
| 187 | ## Next steps |
| 188 | |
| 189 | - [Certificate authorities](/acme/certificate-authorities) — switch to a trusted production CA. |
| 190 | - [DNS validation](/acme/dns-validation) — add a wildcard or customize propagation. |
| 191 | - [Using certificates](/acme/using-certificates) — attach the chain and key to a TLS consumer. |