1---
2title: Serve an API on Cloud Run
3description: A link shortener on one Cloud Run service — Firestore for state, Secret Manager for the API key, and IAM granted by the bindings themselves.
4---
5
6The shape almost every GCP web service lands on is the same three
7pieces:
8
91. **A container on Cloud Run** that serves HTTP and scales to zero.
102. **A managed datastore** — Firestore, because there is no instance to
11 size and no VPC to attach.
123. **A secret** the container reads at runtime instead of an
13 environment variable someone pasted in.
14
15This guide walks through `examples/gcp-cloud-run-api`, a link
16shortener that does exactly that. Every snippet is a piece of
17[`src/Api.ts`](https://github.com/alchemy-run/alchemy/blob/main/examples/gcp-cloud-run-api/src/Api.ts)
18or
19[`src/resources.ts`](https://github.com/alchemy-run/alchemy/blob/main/examples/gcp-cloud-run-api/src/resources.ts);
20the whole service is at the end.
21
22## The state the service needs
23
24Two resources, declared once and imported by both the stack and the
25service:
26
27```typescript
28// src/resources.ts
29import * as GCP from "alchemy/GCP";
30
31export const Links = GCP.Firestore.Database("Links", {
32 location: "us-central1",
33 type: "FIRESTORE_NATIVE",
34});
35
36export const ApiKey = GCP.SecretManager.Secret("ApiKey", {});
37```
38
39`Secret` creates the container, not the value. Adding the *version*
40that holds the key is an operator step, which is what you want — the
41key never passes through the stack, `alchemy deploy` output, or your
42shell history in CI:
43
44```sh
45printf 'my-key' | gcloud secrets versions add "$SECRET_ID" --data-file=-
46```
47
48## One class is the service
49
50`GCP.Function` is an alias of `GCP.Run.Service`. `main` points at this
51file; Alchemy bundles it, builds a container image, pushes it to
52Artifact Registry, and deploys the revision.
53
54```typescript
55// src/Api.ts
56export default class Api extends GCP.Function<Api>()(
57 "Api",
58 {
59 main: import.meta.url,
60 location: "us-central1",
61 invokerIamDisabled: true,
62 },
63 Effect.gen(function* () {
64 const links = yield* Links;
65 const apiKey = yield* ApiKey;
66 // ...
67```
68
69:::caution[`invokerIamDisabled` makes the URL public]
70A link shortener has to be reachable by anyone. Drop this flag and
71Cloud Run requires a signed Google identity token on every request,
72which is the right default for an internal service.
73:::
74
75## Bindings are the IAM
76
77Each `yield*` of a binding does three things: grants the matching role
78on the service's runtime service account, injects whatever the calls
79need into the revision, and hands back a typed client.
80
81```typescript
82 const db = yield* GCP.Firestore.ReadWriteDatabase(links);
83 const key = yield* GCP.SecretManager.ReadSecret(apiKey);
84```
85
86Those two lines are the entire IAM configuration of this service.
87`ReadWriteDatabase` grants `roles/datastore.user`. Firestore databases
88have no IAM policy of their own, so the grant is a project binding
89under an IAM Condition naming this one database — the service cannot
90touch any other database in the project. `ReadSecret` grants
91`roles/secretmanager.secretAccessor` on the API key secret itself.
92Delete a binding and the role is revoked on the next deploy. See
93[How bindings grant IAM](/gcp/guides/bindings) for the mechanism.
94
95Each resource has a `Read`, `Write`, and `ReadWrite` client. The
96service reads and writes links but only ever reads the key, so that is
97exactly what it asks for.
98
99## Reading documents
100
101The client speaks plain JavaScript: `get` answers `undefined` for a
102missing document, and fields come back decoded (integers as `number`,
103timestamps as `Date`).
104
105```typescript
106 const readLink = (code: string) =>
107 db.get(`links/${code}`).pipe(
108 Effect.map((document): Link | undefined => {
109 // `get` answers `undefined` for a missing document, and fields
110 // come back as plain JavaScript values.
111 const { url, clicks, createdAt } = document?.fields ?? {};
112 if (typeof url !== "string") return undefined;
113 return {
114 url,
115 clicks: typeof clicks === "number" ? clicks : 0,
116 createdAt: createdAt instanceof Date ? createdAt.toISOString() : "",
117 };
118 }),
119 Effect.orDie,
120 );
121```
122
123## Reading the key on demand
124
125`access()` reads the `latest` version of the bound secret as a UTF-8
126string, or `undefined` when there is no version yet. The container
127fetches it per request rather than caching it in the revision, so
128rotating the secret takes effect without a redeploy. `sameKey`
129compares in constant time so the response timing does not leak the
130key.
131
132```typescript
133 const authorize = (request: HttpServerRequest) =>
134 key.access().pipe(
135 Effect.map((expected) => {
136 if (expected === undefined) return "unconfigured" as const;
137 return sameKey(expected, request.headers["x-api-key"])
138 ? ("ok" as const)
139 : ("denied" as const);
140 }),
141 Effect.orDie,
142 );
143```
144
145Until someone adds a version there is nothing to compare against. The
146service says so with a `503` instead of failing open.
147
148## The routes
149
150`GET /` is a health route. Cloud Run reports a revision healthy as
151soon as the container listens, so having something cheap to poll makes
152deploy scripts and tests straightforward.
153
154### `POST /links` — mint a code
155
156```typescript
157 if (request.method === "POST" && url.pathname === "/links") {
158 const auth = yield* authorize(request);
159 if (auth !== "ok") {
160 return yield* HttpServerResponse.json(
161 {
162 error:
163 auth === "unconfigured"
164 ? "no api key version has been added to the secret"
165 : "invalid api key",
166 },
167 { status: auth === "unconfigured" ? 503 : 401 },
168 );
169 }
170
171 const body = (yield* request.json) as { url?: string };
172 if (!body.url) {
173 return yield* HttpServerResponse.json(
174 { error: "url is required" },
175 { status: 400 },
176 );
177 }
178
179 // `create` fails with DocumentAlreadyExists instead of
180 // overwriting, so a collision just mints another code.
181 const code = yield* Effect.suspend(() => {
182 const code = newCode();
183 return db
184 .create(`links/${code}`, {
185 url: body.url,
186 clicks: 0,
187 createdAt: new Date(),
188 })
189 .pipe(Effect.as(code));
190 }).pipe(
191 Effect.retry({
192 while: (error) =>
193 error._tag === "GCP.Firestore.DocumentAlreadyExists",
194 times: 3,
195 }),
196 Effect.orDie,
197 );
198
199 return yield* HttpServerResponse.json(
200 { code, shortUrl: `${publicOrigin(request)}/l/${code}` },
201 { status: 201 },
202 );
203 }
204```
205
206`create` fails with `DocumentAlreadyExists` instead of overwriting, so
207a code collision retries with a fresh code rather than clobbering
208someone else's link.
209
210### `GET /l/:code` — redirect and count
211
212```typescript
213 if (request.method === "GET" && segments[0] === "l" && segments[1]) {
214 const link = yield* readLink(segments[1]);
215 if (link === undefined) {
216 return yield* HttpServerResponse.json(
217 { error: "unknown code" },
218 { status: 404 },
219 );
220 }
221
222 // `update` writes only the keys it is given.
223 yield* db
224 .update(`links/${segments[1]}`, { clicks: link.clicks + 1 })
225 .pipe(Effect.orDie);
226
227 return HttpServerResponse.empty({
228 status: 302,
229 headers: { location: link.url },
230 });
231 }
232```
233
234`update` sends an update mask of the keys it is given, so bumping
235`clicks` leaves `url` and `createdAt` alone. The counter is still a
236read-modify-write; a count that has to be exact wants a Firestore
237transaction.
238
239`GET /links/:code` returns the same record as JSON, and
240`DELETE /links/:code` retires a code behind the same API key.
241
242## Provide the implementations
243
244The service Effect closes by providing one layer per client:
245
246```typescript
247 }).pipe(
248 Effect.provide([
249 GCP.Firestore.ReadWriteDatabaseHttp,
250 GCP.SecretManager.ReadSecretHttp,
251 ]),
252 ),
253) {}
254```
255
256## The stack
257
258```typescript
259// alchemy.run.ts
260export default Alchemy.Stack(
261 "GcpCloudRunApiExample",
262 { providers: GCP.providers(), state: Alchemy.localState() },
263 Effect.gen(function* () {
264 const links = yield* Links;
265 const apiKey = yield* ApiKey;
266 const api = yield* Api;
267
268 return {
269 url: api.uri,
270 databaseId: links.databaseId,
271 databaseName: links.name,
272 secretId: apiKey.secretId,
273 secretName: apiKey.name,
274 };
275 }),
276);
277```
278
279## Try it
280
281Deploying builds a container locally, so Docker has to be running.
282
283```sh
284cd examples/gcp-cloud-run-api
285bun alchemy deploy
286```
287
288Seed the key with the `secretId` the deploy printed, then mint a link:
289
290```sh
291printf 'my-key' | gcloud secrets versions add "$SECRET_ID" --data-file=-
292
293curl -X POST "$URL/links" \
294 -H 'content-type: application/json' \
295 -H 'x-api-key: my-key' \
296 -d '{"url":"https://alchemy.run"}'
297# → { "code": "aB3xY7z", "shortUrl": "https://…/l/aB3xY7z" }
298
299curl -i "$URL/l/aB3xY7z" # 302 → https://alchemy.run
300curl "$URL/links/aB3xY7z" # → { …, "clicks": 1 }
301```
302
303`test/integ.test.ts` runs that loop against a real deploy: it adds the
304secret version out of band, mints a code, checks the Firestore
305document exists, follows the redirect, asserts the click count moved,
306and deletes.
307
308```sh
309bun test
310```
311
312Tear it down with `bun alchemy destroy`, which also deletes the
313service's runtime service account and revokes its roles.
314
315## Full file
316
317<details>
318<summary><code>examples/gcp-cloud-run-api/src/Api.ts</code></summary>
319
320```typescript
321import * as GCP from "alchemy/GCP";
322import * as Effect from "effect/Effect";
323import { HttpServerRequest } from "effect/http/HttpServerRequest";
324import * as HttpServerResponse from "effect/http/HttpServerResponse";
325import { ApiKey, Links } from "./resources.ts";
326
327/** Base62 so codes stay short and URL-safe. */
328const ALPHABET =
329 "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
330
331const newCode = () =>
332 Array.from(
333 crypto.getRandomValues(new Uint8Array(7)),
334 (byte) => ALPHABET[byte % ALPHABET.length],
335 ).join("");
336
337/**
338 * Cloud Run terminates TLS at its front end and forwards plain HTTP, so
339 * the public origin comes from the forwarded headers.
340 */
341const publicOrigin = (request: HttpServerRequest) =>
342 `${request.headers["x-forwarded-proto"] ?? "https"}://${request.headers.host}`;
343
344/** Constant-time comparison so response timing does not leak the key. */
345const sameKey = (expected: string, given: string | undefined) => {
346 if (given === undefined || given.length !== expected.length) return false;
347 let diff = 0;
348 for (let index = 0; index < expected.length; index++) {
349 diff |= expected.charCodeAt(index) ^ given.charCodeAt(index);
350 }
351 return diff === 0;
352};
353
354interface Link {
355 url: string;
356 clicks: number;
357 createdAt: string;
358}
359
360/**
361 * A link shortener on Cloud Run.
362 *
363 * This is the shape of most GCP web services: one Cloud Run container,
364 * Firestore for state, Secret Manager for the credential the container
365 * needs at runtime. Nothing is wired by hand — each `yield*` of a binding
366 * grants the matching IAM role on the service's runtime service account
367 * and injects whatever the call needs into the revision.
368 *
369 * - `POST /links` — mint a code for a URL (requires `x-api-key`).
370 * - `GET /l/:code` — redirect and count the click.
371 * - `GET /links/:code` — read the link back.
372 * - `DELETE /links/:code` — retire a code (requires `x-api-key`).
373 *
374 * `invokerIamDisabled: true` makes the service publicly reachable, which
375 * a link shortener has to be. Drop it and Cloud Run requires a signed
376 * Google identity token on every request.
377 */
378export default class Api extends GCP.Function<Api>()(
379 "Api",
380 {
381 main: import.meta.url,
382 location: "us-central1",
383 invokerIamDisabled: true,
384 },
385 Effect.gen(function* () {
386 const links = yield* Links;
387 const apiKey = yield* ApiKey;
388
389 // Each binding grants the runtime service account one role:
390 // datastore.user on the project, under an IAM Condition naming only
391 // this database, and secretmanager.secretAccessor on the API key
392 // secret only.
393 const db = yield* GCP.Firestore.ReadWriteDatabase(links);
394 const key = yield* GCP.SecretManager.ReadSecret(apiKey);
395
396 const readLink = (code: string) =>
397 db.get(`links/${code}`).pipe(
398 Effect.map((document): Link | undefined => {
399 // `get` answers `undefined` for a missing document, and fields
400 // come back as plain JavaScript values.
401 const { url, clicks, createdAt } = document?.fields ?? {};
402 if (typeof url !== "string") return undefined;
403 return {
404 url,
405 clicks: typeof clicks === "number" ? clicks : 0,
406 createdAt: createdAt instanceof Date ? createdAt.toISOString() : "",
407 };
408 }),
409 Effect.orDie,
410 );
411
412 /**
413 * Secret Manager holds the key; the container reads the `latest`
414 * version on demand. Until someone adds a version the API cannot
415 * authenticate anyone, so say so instead of failing open.
416 */
417 const authorize = (request: HttpServerRequest) =>
418 key.access().pipe(
419 Effect.map((expected) => {
420 if (expected === undefined) return "unconfigured" as const;
421 return sameKey(expected, request.headers["x-api-key"])
422 ? ("ok" as const)
423 : ("denied" as const);
424 }),
425 Effect.orDie,
426 );
427
428 return {
429 fetch: Effect.gen(function* () {
430 const request = yield* HttpServerRequest;
431 const url = new URL(request.originalUrl);
432 const segments = url.pathname.split("/").filter(Boolean);
433
434 if (request.method === "GET" && segments.length === 0) {
435 return HttpServerResponse.text("ok");
436 }
437
438 // Mint a code. 62^7 keeps collisions rare, and `create` makes
439 // them harmless.
440 if (request.method === "POST" && url.pathname === "/links") {
441 const auth = yield* authorize(request);
442 if (auth !== "ok") {
443 return yield* HttpServerResponse.json(
444 {
445 error:
446 auth === "unconfigured"
447 ? "no api key version has been added to the secret"
448 : "invalid api key",
449 },
450 { status: auth === "unconfigured" ? 503 : 401 },
451 );
452 }
453
454 const body = (yield* request.json) as { url?: string };
455 if (!body.url) {
456 return yield* HttpServerResponse.json(
457 { error: "url is required" },
458 { status: 400 },
459 );
460 }
461
462 // `create` fails with DocumentAlreadyExists instead of
463 // overwriting, so a collision just mints another code.
464 const code = yield* Effect.suspend(() => {
465 const code = newCode();
466 return db
467 .create(`links/${code}`, {
468 url: body.url,
469 clicks: 0,
470 createdAt: new Date(),
471 })
472 .pipe(Effect.as(code));
473 }).pipe(
474 Effect.retry({
475 while: (error) =>
476 error._tag === "GCP.Firestore.DocumentAlreadyExists",
477 times: 3,
478 }),
479 Effect.orDie,
480 );
481
482 return yield* HttpServerResponse.json(
483 { code, shortUrl: `${publicOrigin(request)}/l/${code}` },
484 { status: 201 },
485 );
486 }
487
488 // Follow a link. The click counter is a read-modify-write, which
489 // is fine for a counter nobody bills on; a Firestore transaction
490 // is the answer when the count has to be exact.
491 if (request.method === "GET" && segments[0] === "l" && segments[1]) {
492 const link = yield* readLink(segments[1]);
493 if (link === undefined) {
494 return yield* HttpServerResponse.json(
495 { error: "unknown code" },
496 { status: 404 },
497 );
498 }
499
500 // `update` writes only the keys it is given.
501 yield* db
502 .update(`links/${segments[1]}`, { clicks: link.clicks + 1 })
503 .pipe(Effect.orDie);
504
505 return HttpServerResponse.empty({
506 status: 302,
507 headers: { location: link.url },
508 });
509 }
510
511 if (
512 request.method === "GET" &&
513 segments[0] === "links" &&
514 segments[1]
515 ) {
516 const link = yield* readLink(segments[1]);
517 if (link === undefined) {
518 return yield* HttpServerResponse.json(
519 { error: "unknown code" },
520 { status: 404 },
521 );
522 }
523 return yield* HttpServerResponse.json({ code: segments[1], ...link });
524 }
525
526 if (
527 request.method === "DELETE" &&
528 segments[0] === "links" &&
529 segments[1]
530 ) {
531 const auth = yield* authorize(request);
532 if (auth !== "ok") {
533 return yield* HttpServerResponse.json(
534 { error: "invalid api key" },
535 { status: 401 },
536 );
537 }
538 // Deleting a missing document succeeds.
539 yield* db.delete(`links/${segments[1]}`).pipe(Effect.orDie);
540 return HttpServerResponse.empty({ status: 204 });
541 }
542
543 return yield* HttpServerResponse.json(
544 { error: "not found" },
545 { status: 404 },
546 );
547 }),
548 };
549 }).pipe(
550 Effect.provide([
551 GCP.Firestore.ReadWriteDatabaseHttp,
552 GCP.SecretManager.ReadSecretHttp,
553 ]),
554 ),
555) {}
556```
557
558</details>
559
560## Next
561
562- [Ingest events into BigQuery](/gcp/guides/event-pipeline) — Pub/Sub
563 and a Cloud Run Job.
564- [How bindings grant IAM](/gcp/guides/bindings) — what a `yield*` of a
565 binding actually does.
566- [Run.Service](/providers/gcp/run/service),
567 [Firestore.Database](/providers/gcp/firestore/database),
568 [SecretManager.Secret](/providers/gcp/secretmanager/secret)
569 reference.