Desplegando NestJS en Bun y Prisma 7 en Cloud Run sin errores de compilación
Un pipeline funcional de GitHub Actions para desplegar una aplicación NestJS en Bun con Prisma 7 y Neon en Cloud Run, además de las correcciones para Docker y las conexiones que suelen causar problemas a los equipos.
Fuentes: Zero to Production: Deploying NestJS with Bun, Prisma 7, and Neon DB on Google Cloud Run
Ejecutar NestJS en Bun, respaldado por Prisma 7 y un Postgres sin servidor como Neon, y enviarlo a Google Cloud Run mediante GitHub Actions es rápido en todos los sentidos. El problema es que esta combinación es lo suficientemente nueva como para que sus fallos sean poco conocidos, y la mayoría de los equipos pierden un día debido a errores en la construcción con Docker o problemas de conexión con Prisma antes del primer despliegue exitoso. Esta guía le proporciona un pipeline funcional y explica los trucos detrás de él, para que pueda ahorrarse ese día.
Cómo se estructura el pipeline
El objetivo es un flujo automático: al hacer push a la rama de despliegue, la automatización ejecuta las migraciones de la base de datos, crea una imagen Docker ligera, la envía al Artifact Registry y la despliega en un servicio sin servidor. El orden de los pasos es importante, y algunos requieren un manejo especial debido a la forma en que Prisma y Bun funcionan dentro de un contenedor.
Satisfacer al generador de Prisma durante la construcción con Docker
La contenerización de Prisma presenta un problema persistente. Cuando se ejecuta prisma generate, este necesita una cadena de conexión a la base de datos para poder analizar el esquema y generar el cliente. Durante el proceso de construcción no hay razón para proporcionarle credenciales reales de producción, y es mejor evitar incluir secretos en las capas de la imagen, ya que cualquier dato contenido en una imagen distribuible puede ser extraído posteriormente.
La solución es utilizar una URL sintácticamente válida pero falsa que exista únicamente durante la construcción. El Dockerfile de múltiples etapas mostrado a continuación instala las dependencias a partir de un archivo lockfile fijo, establece una variable DB__URL temporal que apunta a una base de datos local inexistente, solo para satisfacer al generador, construye la aplicación y copia únicamente los resultados compilados a una imagen de ejecución ligera.
# 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"]
Dos decisiones son cruciales aquí. La construcción en fases múltiples utiliza la imagen completa de Bun y funciona con bun:1-slim, lo que mantiene la imagen final pequeña y reduce las posibilidades de ataque. Además, la cadena ficticia satisface al generador sin que ninguna credencial real ingrese a las capas, ya que el generador solo necesita la estructura de una URL de Postgres en el momento de la construcción, no un servidor accesible.
Seguridad de la cuenta de servicio
El flujo de trabajo se autentica en Google Cloud mediante una cuenta de servicio, y es tentador otorgarle acceso amplio para evitar errores. Hay que resistirse a eso. Siguiendo el principio de mínimo privilegio, solo se le deben asignar los roles necesarios para el despliegue, de modo que una clave filtrada no pueda usarse para tomar control del proyecto completo: solo subir imágenes al Artifact Registry y desplegarlas en Cloud Run, nada más.
El flujo de trabajo de GitHub Actions de principio a fin
Almacene los valores a los que hace referencia el flujo de trabajo, como la clave de la cuenta de servicio y la URL de la base de datos, como secretos encriptados del repositorio antes de la primera ejecución. La tarea descarga el código, configura Bun, se autentica en Google Cloud, ejecuta la migración de Prisma, autentica a Docker frente al Artifact Registry, construye e envía la imagen, la despliega en Cloud Run y elimina las imágenes antiguas. Las etiquetas de origen clasifican este fragmento como SQL; se trata de un flujo de trabajo YAML de GitHub Actions, y la indicación a continuación lo etiqueta correctamente.
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
Por qué los pasos complicados tienen su lugar
Ejecutar prisma migrate deploy como paso independiente, antes de que la nueva imagen entre en producción, es mucho más seguro que realizar la migración desde dentro del contenedor al iniciarlo. Si una migración falla en el proceso, la implementación se detiene y la versión actual sigue atendiendo las solicitudes; si en cambio se realiza la migración al arrancar el contenedor, una migración defectuosa podría interrumpir el servicio mientras ya se está iniciando el nuevo código.
El último paso de mantenimiento elimina los hashes antiguos de las imágenes, ya que en un entorno con mucho tráfico los artefactos almacenados se acumulan y el costo de almacenamiento aumenta. Realmente es opcional: si depende de conservar cada versión anterior para poder hacer un rollback, puede prescindir de este paso y asumir el costo del almacenamiento.
El último modo de fallo silencioso es el agotamiento de conexiones. Cloud Run escala las instancias rápidamente y cada una abre sus propias conexiones, por lo que indicar la dirección directa de una base de datos permite que un pico exceda su límite de conexiones. En Neon, utilice la cadena de conexión compartida en DB__URL para que muchas instancias de corta duración compartan un conjunto limitado de conexiones. Esto aplica a cualquier configuración de tipo serverless hacia Postgres.
Puntos clave
- Proporcione al generador de Prisma una URL de base de datos ficticia y bien formada durante la fase de construcción, para que ningún secreto real quede almacenado en la capa de imágenes.
- Construya con la imagen completa de Bun y ejecute la versión ligera para mantener las imágenes pequeñas.
- Ejecute las migraciones como un paso separado antes del despliegue, de modo que un fallo detenga el lanzamiento en lugar de dañar un servicio en funcionamiento.
- Utilice una cadena de conexión compartida para los destinos serverless y limite las permisos de la cuenta de servicio de despliegue al mínimo necesario.
Lecturas relacionadas
- Tratar el contenido como código: un pipeline de semillado de Git a Postgres — Muestra cómo reemplazar un CMS por JSON controlado por Git, validación con Zod y operaciones upsert de Prisma para sembrar contenido estructurado de forma segura en Postgres.
- Configurando Prisma 7 con PostgreSQL en un proyecto TypeScript Node.js — Soluciona los errores comunes al configurar Prisma 7 en TypeScript, desde URLs inválidas o undefined hasta problemas con rootDir, y conecta PostgreSQL mediante el adaptador del controlador pg.