Google Cloud Run¶
GCP Hono Validation Container
A container-first ReScript service built around Hono and ready to deploy to Google Cloud Run. Unlike the Hono (Node.js) starter, this template ships an opinionated multi-stage Dockerfile, a .dockerignore, an .env.example, and README sections that walk you through gcloud run deploy and a Cloud SQL recipe.
The runtime image is intentionally small: a builder stage installs every dependency and compiles ReScript, then the runtime stage reinstalls production-only dependencies, copies the generated .res.mjs files, drops to a non-root user, and exposes port 8080. The result is a deterministic image that runs the same way locally, inside a CI registry, and on Cloud Run.
What You Get¶
my-service/
├── rescript.json
├── package.json # ESM, "type":"module", engines.node = >=24
├── Dockerfile # multi-stage: builder → runtime
├── .dockerignore # excludes node_modules, lib/, coverage, .env
├── .env.example # PORT=8080 (Cloud Run sets it for you)
├── src/
│ ├── ServerMain.res # entry — calls Server.start()
│ ├── Server.res # Hono app: GET /, POST /echo, reads PORT
│ ├── Hono.res # bindings shared with other Hono templates
│ ├── HonoNodeServer.res # @hono/node-server bindings
│ ├── Validation.res # zod or sury — selected in the wizard
│ └── __tests__/Server.test.mjs # vitest smoke test that imports Server.res.mjs
├── README.md # script docs + API + Environment + Deploy + Cloud SQL
├── LICENSE # MIT, holder = project name
├── .nvmrc # Node 24
├── .gitignore # node_modules + dist/ + .env + ReScript artifacts
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vitest
Wizard Options¶
Option |
Effect |
|---|---|
Project name |
Becomes the npm |
Package manager |
npm / pnpm / yarn / bun. Drives the |
Validation library |
|
The Dockerfile branch is the meaningful difference from other Hono templates. Selecting Bun swaps the base image to oven/bun:1-slim, replaces every npm install / pnpm install line with bun install, and changes the runtime user from node to bun (both uid 1000). Pinning the Node major to nodeMajor (defaults to TemplateVersions.NODE_MAJOR) means gcloud run deploy and your local node --watch always see the same runtime — no package.json engines-vs-image drift.
Key Dependencies¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler |
|
|
Standard library |
|
|
Runtime stubs the compiled |
|
|
HTTP framework — small, fast, web-standard |
|
|
Adapter that mounts a Hono app on Node’s HTTP server |
|
|
Validation backend (chosen in the wizard) |
|
|
Smoke test runner |
|
|
Coverage provider for |
|
Key Files¶
src/Server.res¶
The Hono app. It reads PORT from process.env (Cloud Run sets it automatically) and falls back to 8080 for local development:
@val external processEnv: Dict.t<string> = "process.env"
let port =
processEnv
->Dict.get("PORT")
->Option.flatMap(Int.fromString(_))
->Option.getOr(8080)
let app = Hono.createApp()
app->Hono.get("/", ctx => ctx->Hono.text("Cloud Run + Hono + ReScript"))
app->Hono.post("/echo", async ctx => {
let raw = await ctx->Hono.req->Hono.jsonBody
switch Validation.parseEchoPayload(raw) {
| Error(msg) => ctx->Hono.status(400)->Hono.json({"error": msg})
| Ok(payload) =>
ctx->Hono.json({"echo": payload.message, "receivedAt": Date.now()->Float.toString})
}
})
let start = () => {
HonoNodeServer.serve({fetch: app->HonoNodeServer.honoFetch, port})
Console.log(`Server running on http://localhost:${port->Int.toString}`)
}
The CORS hook is left commented near the top — uncomment it when the service is called from a browser on another origin and adjust the allowlist before deploying.
src/ServerMain.res¶
A single line: Server.start(). The split exists so vitest can import("../Server.res.mjs") to verify the app compiles without binding to a port.
src/Validation.res¶
parseEchoPayload: JSON.t => result<echoPayload, string> — the runtime contract for the /echo route’s body. The signature is identical between the zod and sury variants so callers do not branch on which library shipped.
Dockerfile¶
A two-stage image generated from the wizard inputs. The exact contents depend on the selected package manager and nodeMajor, but the shape is:
# syntax=docker/dockerfile:1.7
# --- Builder stage: install all deps + compile ReScript ---
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN <pm-install> # e.g. corepack enable && pnpm install --frozen-lockfile=false
COPY . .
RUN <pm-exec> rescript # e.g. pnpm exec rescript / npx rescript / bunx rescript
# --- Runtime stage: prod deps + compiled output only ---
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN <pm-install-prod> # e.g. pnpm install --prod --frozen-lockfile=false
COPY --from=builder /app/src ./src
USER node
EXPOSE 8080
CMD ["node", "src/ServerMain.res.mjs"]
Three properties are intentional and load-bearing:
node_modulesis never copied across stages — it is reinstalled with--prodflags so the runtime layer is reproducible from the lockfile.The runtime stage runs as a non-root user (
nodefor Node images,bunforoven/bun). Cloud Run’s hardened policies expect this.The base image’s Node major matches
package.json’sengines.nodeand.nvmrc. BumpingnodeMajorupdates all three in lockstep.
.dockerignore¶
Keeps the build context lean by excluding VCS metadata, IDE state, package-manager debug logs, the lib/ ReScript build artifact, coverage/, and any .env* files. The !.env.example line preserves the sample env file so the README’s deploy instructions still find it.
npm Scripts¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
For local development, run res:dev and dev in two terminals (or chain them through your own concurrently invocation if you prefer a single-window workflow).
Deploying to Cloud Run¶
The README ships a copy-paste deploy block that uses Cloud Build to push the image and gcloud run deploy to roll it out:
gcloud builds submit --tag gcr.io/PROJECT-ID/<projectName>
gcloud run deploy <projectName> \
--image gcr.io/PROJECT-ID/<projectName> \
--port 8080 \
--allow-unauthenticated
Cloud Run injects PORT at runtime (the value is not always 8080), which is why Server.res reads it from process.env. The --port 8080 flag tells Cloud Run which port the container listens on, not which port to publish — they are independent settings.
The README also covers two operational follow-ups: pinning a deployed revision’s image digest for immutable rollouts and tailing logs with gcloud run services logs tail.
Cloud SQL recipe¶
The bundled README includes a short recipe for connecting to a Cloud SQL Postgres instance via @google-cloud/cloud-sql-connector (or the Cloud SQL Auth Proxy with a vanilla pg driver). The pattern in summary:
pnpm add pg @google-cloud/cloud-sql-connector
@module("pg") @new external makePool: 'opts => 'pool = "Pool"
@send external queryAsync: ('pool, string, array<'a>) => promise<'rows> = "query"
Pass DATABASE_URL (or the Cloud SQL connection name plus credentials) as an environment variable via --set-env-vars on gcloud run deploy. For Drizzle ORM on top of Postgres, see the Set Up Drizzle recipe — the same shape applies once you swap the libsql client for pg.
Day-Two Recipes¶
Add a Hono Endpoint — add a typed POST/GET route alongside
/echoSet Up Drizzle — add a Drizzle ORM schema (translate the libsql examples to
pgfor Cloud SQL)Add OpenAPI Docs — generate Scalar API docs from
@hono/zod-openapischemas
For ReScript-side editor workflows once the project is open, see the Feature Overview.
Notes¶
PORTis not configurable in the manifest. Cloud Run sets it on every invocation;Server.reshonours it. Locally,PORT=3000 pnpm devworks because the same env-reading logic falls back to user input before defaulting to 8080.The Dockerfile re-runs
bun install --productioneven when there is no lockfile. Bun createsbun.lockon first install. CI environments without a committed lockfile will still produce a working image, but production deployments should always commit the lockfile to guarantee reproducibility.COPY --from=builder /app/src ./srcis the only artifact carried across stages. If you generate additional output (dist/,public/), add explicitCOPY --from=builderlines for each.The
nodeengine is pinned to>=24and.nvmrcsays24. The Dockerfile’s base image followsnodeMajor(defaultTemplateVersions.NODE_MAJOR); changing one in the wizard updates all of them.The vitest suite is intentionally tiny — it asserts that
import("../Server.res.mjs")resolves. Add aapp.request("/")style integration test (see the Monorepo and Full-Stack templates for examples) before relying on tests for regression coverage.The
.gitignoreexcludes.envbut keeps.env.example. Cloud Run secrets should travel through--set-env-varsor Secret Manager, never through a committed.env.CORS is off by default. Uncomment the
Hono.cors(...)block at the top ofServer.resand pin an origin allowlist before exposing the service to a browser-side client on another origin.