Deploying NestJS on Bun and Prisma 7 to Cloud Run Without the Build Errors
A working GitHub Actions pipeline for shipping a NestJS app on Bun with Prisma 7 and Neon to Cloud Run, plus the Docker and connection fixes that trip teams up.
This article is published in English.
Sources: Zero to Production: Deploying NestJS with Bun, Prisma 7, and Neon DB on Google Cloud Run
Running NestJS on Bun, backed by Prisma 7 and a serverless Postgres like Neon, and shipping it to Google Cloud Run through GitHub Actions is fast in every sense. The catch is that the combination is new enough that its failures are unfamiliar, and most teams lose a day to Docker build errors or Prisma connection problems before the first green deploy. This walkthrough gives you a pipeline that works and explains the tricks behind it, so you can skip that day.
How the pipeline is shaped
The goal is a hands-off flow: push to your deployment branch and the automation runs your database migrations, builds a lean Docker image, pushes it to Artifact Registry, and rolls it out to a serverless service. The ordering matters, and a couple of steps need special handling because of how Prisma and Bun behave inside a container.
Satisfying Prisma's generator during the Docker build
Containerizing Prisma has one persistent snag. When prisma generate runs it wants a database connection string so it can parse the schema and produce the client. During a build there is no reason to hand it a real production credential, and baking a secret into an image layer is what you want to avoid, since anything in a distributable image can be extracted later.
The fix is a syntactically valid but fake URL that exists only for the build. The multi-stage Dockerfile below installs dependencies against a frozen lockfile, sets a throwaway DB__URL pointing at a nonexistent local database purely so the generator is happy, builds the app, and copies only the compiled output into a slim runtime image.
# Stage 1: Builder
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
COPY prisma ./prisma
COPY prisma.config.ts ./
RUN bun install --frozen-lockfile
# The "Prisma Satisfaction" Trick:
# Prisma 7 needs to see a URL format during the build phase to generate the client.
ENV DB__URL="postgresql://dummy:dummy@localhost:5432/dummy"
RUN bunx prisma generate
COPY . .
RUN bun run build
# Stage 2: Runner (Slim)
FROM oven/bun:1-slim AS runner
WORKDIR /app
# Only copy what is strictly necessary to keep the image small
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./
ENV NODE_ENV=production
ENV PORT=8080
CMD ["bun", "run", "dist/main.js"]
Two decisions there matter. The multi-stage split builds with the full Bun image and runs on bun:1-slim, which keeps the final image small and cuts the surface an attacker could poke at. And the dummy string satisfies the generator without a genuine credential ever entering a layer, because the generator only needs the shape of a Postgres URL at build time, not a reachable server.
Locking down the service account
The pipeline authenticates to Google Cloud with a service account, and it is tempting to grant it broad access to make errors disappear. Resist that. Following least privilege, give it only the roles the deploy needs, so a leaked key cannot be turned into control of the whole project: pushing images to Artifact Registry and deploying to Cloud Run, nothing more.
The GitHub Actions workflow end to end
Store the values the workflow references, such as the service account key and the database URL, as encrypted repository secrets before the first run. The job checks out the code, sets up Bun, authenticates to Google Cloud, runs the Prisma migration, authenticates Docker against Artifact Registry, builds and pushes the image, deploys to Cloud Run, and prunes old images. The source tags this snippet as SQL; it is a GitHub Actions YAML workflow, and the hint below labels it correctly.
name: Deploy Server to Cloud Run
on:
push:
branches: ['main']
env:
PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
REGION: asia-south1
REPO_NAME: cloud-run-source-deploy
SERVICE_NAME: server
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Google Auth
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
# STEP: Database Migration
# Run migrations BEFORE deployment to ensure the DB is ready for new code.
- name: Run Prisma Migrations
run: |
bun install --frozen-lockfile
bunx prisma generate
bunx prisma migrate deploy
env:
DB__URL: ${{ secrets.DB__URL }}
- name: Docker Auth
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev
- name: Build and Push
run: |
IMAGE_URI=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPO_NAME }}/${{ env.SERVICE_NAME }}:latest
docker build -t $IMAGE_URI .
docker push $IMAGE_URI
echo "IMAGE_URI=$IMAGE_URI" >> $GITHUB_ENV
- name: Deploy to Cloud Run
uses: google-github-actions/deploy-cloudrun@v2
with:
service: ${{ env.SERVICE_NAME }}
region: ${{ env.REGION }}
image: ${{ env.IMAGE_URI }}
flags: '--port=8080 --allow-unauthenticated'
env_vars: |
DB__URL=${{ secrets.DB__URL }}
# Add placeholders for other secrets below:
# SMTP__HOST=${{ secrets.SMTP__HOST }}
# AUTH__TOKEN_SECRET=${{ secrets.AUTH__TOKEN_SECRET }}
# STEP: Housekeeping (Optional)
# Deletes old image digests to save on storage costs.
- name: Delete Old Images
shell: bash
run: |
IMAGE_PATH="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPO_NAME }}/${{ env.SERVICE_NAME }}"
gcloud artifacts docker images list "$IMAGE_PATH" --sort-by=~create_time > all_images.txt
awk 'NR>2 {print $2}' all_images.txt > digests_to_delete.txt
if [ -s digests_to_delete.txt ]; then
while read digest; do
if [[ "$digest" == sha256:* ]]; then
echo "Deleting old image: $digest"
gcloud artifacts docker images delete "$IMAGE_PATH@$digest" --delete-tags --quiet || true
fi
done < digests_to_delete.txt
fi
Why the tricky steps earn their place
Running prisma migrate deploy as its own step, before the new image goes live, is far safer than migrating from inside the container at startup. If a migration fails in the pipeline the deploy stops and the current version keeps serving traffic; migrate at container boot instead and a bad migration can take the service down while the new code is already starting.
The final housekeeping step deletes old image digests, because in a busy environment stored artifacts pile up and the storage bill climbs. It is genuinely optional: if you rely on keeping every historical build to roll back to, drop it and pay for the storage.
The last quiet failure mode is connection exhaustion. Cloud Run scales instances up quickly and each one opens its own connections, so pointing at a database's direct address lets a spike blow past its connection limit. On Neon, use the pooled connection string in DB__URL so many short-lived instances share a bounded pool. This holds for any serverless-to-Postgres setup.
Key takeaways
- Give Prisma's generator a dummy, well-formed database URL at build time so no real secret lands in an image layer.
- Build with the full Bun image and run on the slim variant to keep images small.
- Run migrations as a distinct step before deploy, so a failure halts the release instead of breaking a live service.
- Use a pooled connection string for serverless targets and scope the deploy service account to least privilege.
- Bun in production and Prisma 7 are still bleeding-edge at the time of writing; re-verify tool behavior against current docs.