Serve an API on Cloud Run
The shape almost every GCP web service lands on is the same three pieces:
- A container on Cloud Run that serves HTTP and scales to zero.
- A managed datastore — Firestore, because there is no instance to size and no VPC to attach.
- A secret the container reads at runtime instead of an environment variable someone pasted in.
This guide walks through examples/gcp-cloud-run-api, a link
shortener that does exactly that. Every snippet is a piece of
src/Api.ts
or
src/resources.ts;
the whole service is at the end.
The state the service needs
Section titled “The state the service needs”Two resources, declared once and imported by both the stack and the service:
import * as GCP from "alchemy/GCP";
export const Links = GCP.Firestore.Database("Links", { location: "us-central1", type: "FIRESTORE_NATIVE",});
export const ApiKey = GCP.SecretManager.Secret("ApiKey", {});Secret creates the container, not the value. Adding the version
that holds the key is an operator step, which is what you want — the
key never passes through the stack, alchemy deploy output, or your
shell history in CI:
printf 'my-key' | gcloud secrets versions add "$SECRET_ID" --data-file=-One class is the service
Section titled “One class is the service”GCP.Function is an alias of GCP.Run.Service. main points at this
file; Alchemy bundles it, builds a container image, pushes it to
Artifact Registry, and deploys the revision.
export default class Api extends GCP.Function<Api>()( "Api", { main: import.meta.url, location: "us-central1", invokerIamDisabled: true, }, Effect.gen(function* () { const links = yield* Links; const apiKey = yield* ApiKey; // ...Bindings are the IAM
Section titled “Bindings are the IAM”Each yield* of a binding does three things: grants the matching role
on the service’s runtime service account, injects whatever the calls
need into the revision, and hands back a typed client.
const db = yield* GCP.Firestore.ReadWriteDatabase(links); const key = yield* GCP.SecretManager.ReadSecret(apiKey);Those two lines are the entire IAM configuration of this service.
ReadWriteDatabase grants roles/datastore.user. Firestore databases
have no IAM policy of their own, so the grant is a project binding
under an IAM Condition naming this one database — the service cannot
touch any other database in the project. ReadSecret grants
roles/secretmanager.secretAccessor on the API key secret itself.
Delete a binding and the role is revoked on the next deploy. See
How bindings grant IAM for the mechanism.
Each resource has a Read, Write, and ReadWrite client. The
service reads and writes links but only ever reads the key, so that is
exactly what it asks for.
Reading documents
Section titled “Reading documents”The client speaks plain JavaScript: get answers undefined for a
missing document, and fields come back decoded (integers as number,
timestamps as Date).
const readLink = (code: string) => db.get(`links/${code}`).pipe( Effect.map((document): Link | undefined => { // `get` answers `undefined` for a missing document, and fields // come back as plain JavaScript values. const { url, clicks, createdAt } = document?.fields ?? {}; if (typeof url !== "string") return undefined; return { url, clicks: typeof clicks === "number" ? clicks : 0, createdAt: createdAt instanceof Date ? createdAt.toISOString() : "", }; }), Effect.orDie, );Reading the key on demand
Section titled “Reading the key on demand”access() reads the latest version of the bound secret as a UTF-8
string, or undefined when there is no version yet. The container
fetches it per request rather than caching it in the revision, so
rotating the secret takes effect without a redeploy. sameKey
compares in constant time so the response timing does not leak the
key.
const authorize = (request: HttpServerRequest) => key.access().pipe( Effect.map((expected) => { if (expected === undefined) return "unconfigured" as const; return sameKey(expected, request.headers["x-api-key"]) ? ("ok" as const) : ("denied" as const); }), Effect.orDie, );Until someone adds a version there is nothing to compare against. The
service says so with a 503 instead of failing open.
The routes
Section titled “The routes”GET / is a health route. Cloud Run reports a revision healthy as
soon as the container listens, so having something cheap to poll makes
deploy scripts and tests straightforward.
POST /links — mint a code
Section titled “POST /links — mint a code” if (request.method === "POST" && url.pathname === "/links") { const auth = yield* authorize(request); if (auth !== "ok") { return yield* HttpServerResponse.json( { error: auth === "unconfigured" ? "no api key version has been added to the secret" : "invalid api key", }, { status: auth === "unconfigured" ? 503 : 401 }, ); }
const body = (yield* request.json) as { url?: string }; if (!body.url) { return yield* HttpServerResponse.json( { error: "url is required" }, { status: 400 }, ); }
// `create` fails with DocumentAlreadyExists instead of // overwriting, so a collision just mints another code. const code = yield* Effect.suspend(() => { const code = newCode(); return db .create(`links/${code}`, { url: body.url, clicks: 0, createdAt: new Date(), }) .pipe(Effect.as(code)); }).pipe( Effect.retry({ while: (error) => error._tag === "GCP.Firestore.DocumentAlreadyExists", times: 3, }), Effect.orDie, );
return yield* HttpServerResponse.json( { code, shortUrl: `${publicOrigin(request)}/l/${code}` }, { status: 201 }, ); }create fails with DocumentAlreadyExists instead of overwriting, so
a code collision retries with a fresh code rather than clobbering
someone else’s link.
GET /l/:code — redirect and count
Section titled “GET /l/:code — redirect and count” if (request.method === "GET" && segments[0] === "l" && segments[1]) { const link = yield* readLink(segments[1]); if (link === undefined) { return yield* HttpServerResponse.json( { error: "unknown code" }, { status: 404 }, ); }
// `update` writes only the keys it is given. yield* db .update(`links/${segments[1]}`, { clicks: link.clicks + 1 }) .pipe(Effect.orDie);
return HttpServerResponse.empty({ status: 302, headers: { location: link.url }, }); }update sends an update mask of the keys it is given, so bumping
clicks leaves url and createdAt alone. The counter is still a
read-modify-write; a count that has to be exact wants a Firestore
transaction.
GET /links/:code returns the same record as JSON, and
DELETE /links/:code retires a code behind the same API key.
Provide the implementations
Section titled “Provide the implementations”The service Effect closes by providing one layer per client:
}).pipe( Effect.provide([ GCP.Firestore.ReadWriteDatabaseHttp, GCP.SecretManager.ReadSecretHttp, ]), ),) {}The stack
Section titled “The stack”export default Alchemy.Stack( "GcpCloudRunApiExample", { providers: GCP.providers(), state: Alchemy.localState() }, Effect.gen(function* () { const links = yield* Links; const apiKey = yield* ApiKey; const api = yield* Api;
return { url: api.uri, databaseId: links.databaseId, databaseName: links.name, secretId: apiKey.secretId, secretName: apiKey.name, }; }),);Try it
Section titled “Try it”Deploying builds a container locally, so Docker has to be running.
cd examples/gcp-cloud-run-apibun alchemy deploySeed the key with the secretId the deploy printed, then mint a link:
printf 'my-key' | gcloud secrets versions add "$SECRET_ID" --data-file=-
curl -X POST "$URL/links" \ -H 'content-type: application/json' \ -H 'x-api-key: my-key' \ -d '{"url":"https://alchemy.run"}'# → { "code": "aB3xY7z", "shortUrl": "https://…/l/aB3xY7z" }
curl -i "$URL/l/aB3xY7z" # 302 → https://alchemy.runcurl "$URL/links/aB3xY7z" # → { …, "clicks": 1 }test/integ.test.ts runs that loop against a real deploy: it adds the
secret version out of band, mints a code, checks the Firestore
document exists, follows the redirect, asserts the click count moved,
and deletes.
bun testTear it down with bun alchemy destroy, which also deletes the
service’s runtime service account and revokes its roles.
Full file
Section titled “Full file”examples/gcp-cloud-run-api/src/Api.ts
import * as GCP from "alchemy/GCP";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as HttpServerResponse from "effect/http/HttpServerResponse";import { ApiKey, Links } from "./resources.ts";
/** Base62 so codes stay short and URL-safe. */const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const newCode = () => Array.from( crypto.getRandomValues(new Uint8Array(7)), (byte) => ALPHABET[byte % ALPHABET.length], ).join("");
/** * Cloud Run terminates TLS at its front end and forwards plain HTTP, so * the public origin comes from the forwarded headers. */const publicOrigin = (request: HttpServerRequest) => `${request.headers["x-forwarded-proto"] ?? "https"}://${request.headers.host}`;
/** Constant-time comparison so response timing does not leak the key. */const sameKey = (expected: string, given: string | undefined) => { if (given === undefined || given.length !== expected.length) return false; let diff = 0; for (let index = 0; index < expected.length; index++) { diff |= expected.charCodeAt(index) ^ given.charCodeAt(index); } return diff === 0;};
interface Link { url: string; clicks: number; createdAt: string;}
/** * A link shortener on Cloud Run. * * This is the shape of most GCP web services: one Cloud Run container, * Firestore for state, Secret Manager for the credential the container * needs at runtime. Nothing is wired by hand — each `yield*` of a binding * grants the matching IAM role on the service's runtime service account * and injects whatever the call needs into the revision. * * - `POST /links` — mint a code for a URL (requires `x-api-key`). * - `GET /l/:code` — redirect and count the click. * - `GET /links/:code` — read the link back. * - `DELETE /links/:code` — retire a code (requires `x-api-key`). * * `invokerIamDisabled: true` makes the service publicly reachable, which * a link shortener has to be. Drop it and Cloud Run requires a signed * Google identity token on every request. */export default class Api extends GCP.Function<Api>()( "Api", { main: import.meta.url, location: "us-central1", invokerIamDisabled: true, }, Effect.gen(function* () { const links = yield* Links; const apiKey = yield* ApiKey;
// Each binding grants the runtime service account one role: // datastore.user on the project, under an IAM Condition naming only // this database, and secretmanager.secretAccessor on the API key // secret only. const db = yield* GCP.Firestore.ReadWriteDatabase(links); const key = yield* GCP.SecretManager.ReadSecret(apiKey);
const readLink = (code: string) => db.get(`links/${code}`).pipe( Effect.map((document): Link | undefined => { // `get` answers `undefined` for a missing document, and fields // come back as plain JavaScript values. const { url, clicks, createdAt } = document?.fields ?? {}; if (typeof url !== "string") return undefined; return { url, clicks: typeof clicks === "number" ? clicks : 0, createdAt: createdAt instanceof Date ? createdAt.toISOString() : "", }; }), Effect.orDie, );
/** * Secret Manager holds the key; the container reads the `latest` * version on demand. Until someone adds a version the API cannot * authenticate anyone, so say so instead of failing open. */ const authorize = (request: HttpServerRequest) => key.access().pipe( Effect.map((expected) => { if (expected === undefined) return "unconfigured" as const; return sameKey(expected, request.headers["x-api-key"]) ? ("ok" as const) : ("denied" as const); }), Effect.orDie, );
return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const url = new URL(request.originalUrl); const segments = url.pathname.split("/").filter(Boolean);
if (request.method === "GET" && segments.length === 0) { return HttpServerResponse.text("ok"); }
// Mint a code. 62^7 keeps collisions rare, and `create` makes // them harmless. if (request.method === "POST" && url.pathname === "/links") { const auth = yield* authorize(request); if (auth !== "ok") { return yield* HttpServerResponse.json( { error: auth === "unconfigured" ? "no api key version has been added to the secret" : "invalid api key", }, { status: auth === "unconfigured" ? 503 : 401 }, ); }
const body = (yield* request.json) as { url?: string }; if (!body.url) { return yield* HttpServerResponse.json( { error: "url is required" }, { status: 400 }, ); }
// `create` fails with DocumentAlreadyExists instead of // overwriting, so a collision just mints another code. const code = yield* Effect.suspend(() => { const code = newCode(); return db .create(`links/${code}`, { url: body.url, clicks: 0, createdAt: new Date(), }) .pipe(Effect.as(code)); }).pipe( Effect.retry({ while: (error) => error._tag === "GCP.Firestore.DocumentAlreadyExists", times: 3, }), Effect.orDie, );
return yield* HttpServerResponse.json( { code, shortUrl: `${publicOrigin(request)}/l/${code}` }, { status: 201 }, ); }
// Follow a link. The click counter is a read-modify-write, which // is fine for a counter nobody bills on; a Firestore transaction // is the answer when the count has to be exact. if (request.method === "GET" && segments[0] === "l" && segments[1]) { const link = yield* readLink(segments[1]); if (link === undefined) { return yield* HttpServerResponse.json( { error: "unknown code" }, { status: 404 }, ); }
// `update` writes only the keys it is given. yield* db .update(`links/${segments[1]}`, { clicks: link.clicks + 1 }) .pipe(Effect.orDie);
return HttpServerResponse.empty({ status: 302, headers: { location: link.url }, }); }
if ( request.method === "GET" && segments[0] === "links" && segments[1] ) { const link = yield* readLink(segments[1]); if (link === undefined) { return yield* HttpServerResponse.json( { error: "unknown code" }, { status: 404 }, ); } return yield* HttpServerResponse.json({ code: segments[1], ...link }); }
if ( request.method === "DELETE" && segments[0] === "links" && segments[1] ) { const auth = yield* authorize(request); if (auth !== "ok") { return yield* HttpServerResponse.json( { error: "invalid api key" }, { status: 401 }, ); } // Deleting a missing document succeeds. yield* db.delete(`links/${segments[1]}`).pipe(Effect.orDie); return HttpServerResponse.empty({ status: 204 }); }
return yield* HttpServerResponse.json( { error: "not found" }, { status: 404 }, ); }), }; }).pipe( Effect.provide([ GCP.Firestore.ReadWriteDatabaseHttp, GCP.SecretManager.ReadSecretHttp, ]), ),) {}- Ingest events into BigQuery — Pub/Sub and a Cloud Run Job.
- How bindings grant IAM — what a
yield*of a binding actually does. - Run.Service, Firestore.Database, SecretManager.Secret reference.