How the build works
The pipeline is four steps:
- We clone your repo — public URL, or via GitHub App install for private.
- Cloud Build runs
docker build .at the repo root. Whatever your Dockerfile says is what gets built. - The resulting image is pushed to Artifact Registry, tagged with your deployment ID.
- Cloud Run pulls that image, injects your env vars at runtime, and starts the container.
So your repo needs a file named Dockerfile at the root. Pick a template below and adjust the entrypoint to your app.
Three rules your Dockerfile must follow
- Listen on
$PORT. Cloud Run injects the port number as an environment variable at container start. Your app has to bind to it, not to a hardcoded 3000 / 8000 / 80. The templates below default to8080, which Cloud Run honors out of the box. - Don’t put secrets in
ENV. Two things go wrong if you do. First,ENVlines get baked into the image layers — anyone who can pull the image can read them back withdocker history. Second, for any key we inject at deploy time —DATABASE_URL,UNINC_JWT_SECRET,UNINC_PROXY_URL, plus whatever you set in the Env tab — our runtime value silently overrides yours. So a baked secret leaks into the image and the app never sees it. UseENVonly for non-secret constants likeNODE_ENV=production. - Build-time env vars don’t come from us. Cloud Run injects env at container start, after the image is already built. Frameworks that bake env into bundles at build time —
NEXT_PUBLIC_*in Next.js,VITE_*in Vite,REACT_APP_*in CRA — won’t pick up values you set in our Env tab. If your client bundle needs a config value, commit it to your repo or pass it viaARG+ENVin your Dockerfile.
Templates
Four starting points for common stacks. Each follows the three rules above. Click copy, paste into a file named Dockerfile at your repo root, push.
Next.js
App Router or Pages — paste this and your repo deploys with no next.config changes. Uses next start from a full node_modules, the same command npm start runs locally.
# Next.js (App Router or Pages) — works with any next.config
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080
COPY --from=build /app .
EXPOSE 8080
CMD ["npx", "next", "start", "-p", "8080"]
Node (generic)
Works for Express, Fastify, Hono, NestJS, or any Node server. Adjust the CMD entrypoint to your build output.
# Generic Node / Express / Fastify / Hono / NestJS
FROM node:20-alpine AS build
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
COPY . .
# Compile if a build script is present (TypeScript, bundlers); skip for plain JS.
RUN npm run build --if-present
FROM node:20-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080
COPY --from=build /app .
EXPOSE 8080
# Adjust to your build output (e.g. "dist/index.js", "build/server.js", "src/server.js").
CMD ["node", "dist/index.js"]
Python (FastAPI / Flask)
Slim base image, uvicorn serving FastAPI. Swap the CMD for gunicorn + Flask or any other WSGI/ASGI runner.
# Python (FastAPI / Flask)
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PORT=8080
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
# `exec` so uvicorn becomes PID 1 — Cloud Run's SIGTERM reaches it directly
# and graceful shutdown actually works. Without it, sh swallows the signal
# and Cloud Run hard-kills the container after 10s.
# FastAPI via uvicorn — replace "app.main:app" with your entrypoint.
# For Flask: CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:$PORT app.main:app"]
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port $PORT"]
Go
Static binary on distroless — tiny image, no shell. Build output is your module's main package.
# Go (module build, static binary)
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Builds the root main package. For cmd/server-style layouts, change `.` → `./cmd/server`.
# -ldflags="-s -w" strips symbols + DWARF for a smaller binary.
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app .
FROM gcr.io/distroless/static-debian12
ENV PORT=8080
COPY --from=build /out/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
Not about the proxy
These templates are for your application— the image Cloud Run pulls and starts. The Unincorporated proxy, chain-engine, and observer live in their own containers on VMs we provision for you; you don’t write Dockerfiles for any of that. Self-hosting the proxy stack instead? See server/docker/ in the open-source repo.