Akter

Cloudflare

On Cloudflare, the server runs in a Container and the store is an R2 bucket. A small Worker sits in front and forwards every request, WebSockets included, to the one container. Alchemy describes all of it in TypeScript and deploys it with one command.

You need:

  • The src/server.ts, Dockerfile and .dockerignore from Deploying.
  • Docker running locally. Alchemy builds the image on your machine and pushes it to Cloudflare.
  • A Cloudflare account on the Workers Paid plan, which Containers require.

Install⁠

Terminal
bun add -d alchemy @alchemy.run/cloudflare @cloudflare/containers @cloudflare/workers-types

The Worker runs on Cloudflare's runtime, not Bun. Add its types to tsconfig.json, so one config checks both:

JSON
{
  "compilerOptions": {
    "types": ["bun", "@cloudflare/workers-types"]
  }
}

Add .alchemy to .dockerignore too.

Describe the stack⁠

Create alchemy.run.ts in the project root:

TypeScript
import * as Alchemy from "alchemy"
import * as Cloudflare from "@alchemy.run/cloudflare"
import { Effect } from "effect"
import type { Server } from "./src/worker.ts"

const Store = Cloudflare.R2.Bucket("Store")

export const Worker = Cloudflare.Worker("Worker", {
  main: "./src/worker.ts",
  crons: ["* * * * *"],
  env: {
    SERVER: Cloudflare.Container<Server>("Server", {
      context: ".",
      instanceType: "basic",
      maxInstances: 1,
    }),
    STORE: Cloudflare.R2.S3Credentials(Store, { access: "read-write" }),
    AUTH_ISSUER: "https://login.example.com/",
    AUTH_AUDIENCE: "my-app",
    AUTH_JWKS_URL: "https://login.example.com/.well-known/jwks.json",
  },
})

export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>

export default Alchemy.Stack(
  "my-app",
  { providers: Cloudflare.providers(), state: Cloudflare.state() },
  Effect.gen(function* () {
    const worker = yield* Worker
    return { url: worker.url }
  }),
)
  • Store is the R2 bucket.
  • S3Credentials creates an API token that can only use that bucket, and hands its S3 keys to the Worker as a secret.
  • Server is the container. Alchemy builds it from your Dockerfile. maxInstances: 1 keeps it to one copy. basic gives it a quarter of a vCPU and 1 GiB of memory; pick a larger instance type as you grow.
  • crons runs the Worker every minute. The next section uses that to restart the container if it ever stops.
  • Cloudflare.state() keeps Alchemy's record of what it deployed in your Cloudflare account, so you can deploy from any machine.

Route requests to the container⁠

Create src/worker.ts:

TypeScript
import { Container, getContainer } from "@cloudflare/containers"
import type * as Cloudflare from "@alchemy.run/cloudflare"
import type { WorkerEnv } from "../alchemy.run.ts"

const serverEnv = (env: WorkerEnv) => {
  const store: Cloudflare.R2.S3CredentialsValue = JSON.parse(env.STORE)
  return {
    AKTER_STORAGE_BUCKET: store.bucketName,
    AKTER_STORAGE_ENDPOINT: store.endpoint,
    AKTER_STORAGE_REGION: store.region,
    AWS_ACCESS_KEY_ID: store.accessKeyId,
    AWS_SECRET_ACCESS_KEY: store.secretAccessKey,
    AUTH_ISSUER: env.AUTH_ISSUER,
    AUTH_AUDIENCE: env.AUTH_AUDIENCE,
    AUTH_JWKS_URL: env.AUTH_JWKS_URL,
  }
}

export class Server extends Container<WorkerEnv> {
  override defaultPort = 8080
  override envVars = serverEnv(this.env)

  override async onActivityExpired() {}
}

export default {
  fetch(request: Request, env: WorkerEnv) {
    return getContainer(env.SERVER).fetch(request)
  },
  async scheduled(_controller: unknown, env: WorkerEnv) {
    await getContainer(env.SERVER).startAndWaitForPorts()
  },
}
  • getContainer(env.SERVER) always names the same instance, so every request reaches the one server.
  • envVars passes the bucket's keys and your auth settings to the server as environment variables.
  • onActivityExpired normally stops a container that hasn't had a request for 10 minutes. Akter has timers and jobs to run between requests, so this one does nothing and the container keeps running.
  • scheduled runs every minute and starts the container if it isn't running, for example after Cloudflare moved it to another machine.

Deploy⁠

Terminal
bun alchemy deploy

The first time, Alchemy has you connect your Cloudflare account, and asks before it sets up its state store there. Then it shows the plan, builds and pushes the image, and prints the Worker's URL. The first deploy takes a few minutes. Check the server:

Terminal
curl https://<worker url>/ready

The container starts on the first request, or within a minute from the cron trigger, and opens the store. /ready then answers {"ready":true}. Point your client at the same URL, with /rpc on the end.

To check the bucket, copy the keys from the R2 dashboard into the variables in Check it and run bunx akter storage check.

Releases⁠

Run bun alchemy deploy again. Cloudflare sends SIGTERM to the running container, gives it up to 15 minutes to exit, and only then starts the new image, which is the order Akter needs. Requests that arrive in between wait or are retried by the client.

bun alchemy destroy tears the stack down.