CI/CD · Containerized Backend
GitHub Actions · Docker · Prisma · Self-hosted Runner

NestJS Backend
Deployment Pipeline

How a NestJS backend is containerized and shipped on every push — a multi-stage Docker build, a self-hosted GitHub Actions runner, and a single container that migrates, seeds, then serves, with resilient Prisma migration recovery built in.

GitHub Actions Multi-stage Docker Prisma Migrate Self-hosted Runner Zero-downtime swap
Contents
The Premise

The app deploys as a single Docker container on a self-hosted GitHub Actions runner. On every push to feat/local: Actions checks out the code, builds a multi-stage image, writes the dev environment's secrets to a temporary .env, swaps out the old container, and starts a new one. Inside that one container, on startup, it runs migrate → seed → start, in order, before the app accepts a single request. No separate migration container — everything happens in one place, every time.

Why This Design

Six deliberate choices that make this pipeline simple, safe, and reproducible:

One Container, In Order
migrate → seed → start all run inside the single app container via entrypoint.sh — no fragile multi-container choreography.
Multi-stage Build
Stage 1 compiles TypeScript; stage 2 ships a slim runtime image — smaller, cleaner, no build tooling in production.
Fail-Safe Migrations
set -e means a failed migration halts before the app ever starts — a broken schema is never served to traffic.
SHA-tagged Images
Every build is tagged with its git commit SHA — the previous image stays available to roll back to until the next deploy.
Build-time Secret Isolation
Real credentials never enter the build context — .dockerignore keeps .env out and only harmless placeholders touch build-time RUN steps.
Idempotent Seeding
Seeding uses upsert, so it runs safely on every restart without duplicate-key crashes.
01 Live Deployment Flow

A push to feat/local triggers the whole chain. Watch a commit flow through the CI steps on the runner, then drop into the container where migrate → seed → start runs before traffic is served.

git push branch: feat/local checkout + .env secrets → .env (jq) docker build multi-stage · :sha stop + remove old free the name self-hosted GitHub Actions runner ▸ docker run -d · medical-backend:<sha> ENTRYPOINT ./entrypoint.sh · -p 127.0.0.1:8100:2001 · --restart unless-stopped migrate prisma deploy seed upsert (idempotent) start app node dist/src/main serving :8100 (loopback) after deploy: prune old SHA images · rm .env from runner (always)
CI/CD steps on the runner Docker build In-container: migrate → seed → start
Figure 1 — Live deployment flow. The CI pipeline builds and swaps the container; inside it, migrations and seeding finish before the NestJS app serves a single request.
02 The Pipeline, Step by Step
StepWhat happensWhy it's safe
CheckoutPulls the repo at the pushed commit (actions/checkout@v4)Standard, deterministic source fetch
Create .envAll dev-environment secrets & vars are written to a local .env, excluding GITHUB_TOKENFile is chmod 600 (owner read/write only)
Build imagedocker build --pull --no-cache builds the 2-stage image, tagged medical-backend:<sha>--no-cache = no stale layers; real secrets aren't in the build context
Stop olddocker stop … || true|| true means the first-ever deploy (no container yet) doesn't fail
Remove olddocker rm … || trueFrees the container name for reuse
Run newdocker run -d — detached, named, --restart unless-stopped, real .env injectedThe only container in the pipeline; entrypoint runs migrate → seed → start before serving
Clean imagesRemoves every other SHA-tagged image of this name, then prunes dangling layersKeeps runner disk bounded (SHA tags aren't "dangling," so prune alone misses them)
Remove .envDeletes .env from the runner — if: always()Safe: Docker already copied the values into the container's own env at run time
03 The Multi-stage Dockerfile

Stage 1 compiles TypeScript to dist/. Stage 2 is the slim runtime image that actually ships — it reinstalls deps, regenerates the Prisma client, and copies in the compiled output plus the two runtime shell scripts.

Dockerfile
# ---- Build Stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm i
# prisma.config.ts imports app config (e.g. src/config/database.config.ts),
# so the full source tree must exist before `prisma generate` runs.
COPY . .
# prisma.config.ts validates DB_* eagerly on load, even though `generate`
# never opens a real connection. These placeholders exist only for this
# RUN instruction's shell, never as persistent image ENV.
RUN DB_HOST=placeholder DB_PORT=3306 DB_USER=placeholder DB_PASSWORD=placeholder DB_NAME=placeholder \
    npm run build

# ---- Production Stage ----
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
# `prisma` is a production dependency so migrate deploy works with NODE_ENV=production.
COPY package.json ./
RUN npm i
COPY . .
RUN DB_HOST=placeholder DB_PORT=3306 DB_USER=placeholder DB_PASSWORD=placeholder DB_NAME=placeholder \
    npx prisma generate
COPY --from=builder /app/dist ./dist
COPY entrypoint.sh ./entrypoint.sh
COPY scripts/migrate-deploy.sh ./scripts/migrate-deploy.sh
RUN chmod +x ./entrypoint.sh ./scripts/migrate-deploy.sh
EXPOSE 2001
ENTRYPOINT ["./entrypoint.sh"]
Two things people trip on
  • Placeholder DB_* at build: prisma.config.ts reads from database.config.ts, which throws if DB vars are missing — even though prisma generate never opens a connection. Harmless placeholders satisfy it, scoped to that one RUN only.
  • COPY . . not just dist/: both prisma generate (build) and migrate deploy (runtime) load prisma.config.ts → it imports from src/, so source must be in the final image.
.dockerignore — keeping secrets out of the build context
.dockerignore
node_modules
npm-debug.log
dist
.git
.gitignore
.env
.env.*
*.md
.vscode
.idea
Dockerfile
.dockerignore
.github
test
coverage

Because .env and .env.* are excluded, real secrets can never end up baked into an image layer — regardless of what COPY . . picks up.

04 Entrypoint & Resilient Migrations

The image's ENTRYPOINT always runs on container start. It does three things in order: migrate, seed, start.

entrypoint.sh
#!/bin/sh
set -e
# prisma.config.ts builds its own connection string from DB_HOST/DB_PORT/
# DB_USER/DB_PASSWORD/DB_NAME — present via --env-file at docker run, so no
# separate DATABASE_URL is needed here.
echo "Running database migrations..."
./scripts/migrate-deploy.sh
npm run prisma:seed
echo "Starting application..."
exec node dist/src/main
  • set -e — if migrations exit non-zero, seed and the app never run. A broken schema is never served.
  • exec node … — replaces the shell with Node, so Node becomes PID 1 and receives signals (e.g. docker stop) directly.
migrate-deploy.sh — a resilient wrapper around prisma migrate deploy

It classifies common failure modes and either auto-recovers (dev only) or prints exact manual-recovery commands. The core loop:

scripts/migrate-deploy.sh (core)
#!/bin/sh
set -e
MAX_ATTEMPTS=25
attempt=0

should_auto_resolve() {
  case "${PRISMA_AUTO_RESOLVE_APPLIED:-}" in
    true|1|yes|TRUE) return 0 ;;
    false|0|no|FALSE) return 1 ;;
  esac
  case "${APP_ENV:-}" in
    dev|development|local|DEV) return 0 ;;
  esac
  return 1
}

while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do
  if npx prisma migrate deploy 2>/tmp/prisma-deploy.err; then
    exit 0                       # success
  fi
  cat /tmp/prisma-deploy.err >&2

  # 1142 / REFERENCES denied → print GRANT help, stop (manual fix)
  if grep -q '1142' /tmp/prisma-deploy.err; then
    print_references_grant_help; exit 1
  fi
  # P3018 — failed while applying this run → manual recovery only
  if grep -q 'P3018' /tmp/prisma-deploy.err; then
    print_references_grant_help; exit 1
  fi
  # Only P3009 (a previously-failed migration) is auto-recoverable
  if ! grep -q 'P3009' /tmp/prisma-deploy.err; then exit 1; fi

  FAILED=$(extract_failed_migration_p3009 /tmp/prisma-deploy.err)
  if should_auto_resolve; then
    echo "Auto-resolving: $FAILED (attempt $((attempt+1))/$MAX_ATTEMPTS)"
    npx prisma migrate resolve --applied "$FAILED"
    attempt=$((attempt+1)); continue
  fi
  print_manual_recovery_p3009 "$FAILED"; exit 1
done
echo "Too many failed migration recovery attempts."; exit 1
Error codes it handles
CodeMeaningBehavior
P3009A previously failed migration exists; deploy is blocked until resolvedIf PRISMA_AUTO_RESOLVE_APPLIED=true or APP_ENV=dev → auto resolve --applied & retry (≤25×). Otherwise prints manual steps.
P3018A migration failed while actively applying this runAlways manual-recovery only — the failure just happened and its DB state is unverified
1142 / REFERENCESDB user lacks REFERENCES privilege; FK-creating migration failsPrints a GRANT template for the DBA + suggests a separate privileged DB_MIGRATE_USER
⚠ Auto-resolve caveat: because the workflow sets APP_ENV=dev, P3009 failures are auto-marked --applied without verifying the schema actually matches. Great for unblocking dev fast — but for staging/production, disable it (PRISMA_AUTO_RESOLVE_APPLIED=false, don't set APP_ENV=dev) so a partial failure can't slip through.
05 The GitHub Actions Workflow

The full pipeline definition. Note the secret-to-.env trick with jq, and that no separate migration step exists — the container's entrypoint owns that ordering.

.github/workflows/deploy.yml
name: Deploy Backend
on:
  push:
    branches:
      - feat/local

env:
  APP_NAME: medical-backend
  IMAGE_NAME: medical-backend
  IMAGE_TAG: ${{ github.sha }}

jobs:
  deploy:
    runs-on: medical-frontend     # self-hosted runner
    environment: dev
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Create .env from Secrets and Vars
        shell: bash
        run: |
          touch .env
          chmod 600 .env
          echo '${{ toJSON(secrets) }}' | jq -r 'to_entries[] | select(.key != "GITHUB_TOKEN" and .key != "github_token") | "\(.key)=\(.value)"' >> .env
          echo '${{ toJSON(vars) }}'    | jq -r 'to_entries[] | "\(.key)=\(.value)"' >> .env

      - name: Build Docker image
        run: docker build --pull --no-cache -t ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} .

      - name: Stop existing container
        run: docker stop ${{ env.APP_NAME }} || true

      - name: Remove existing container
        run: docker rm ${{ env.APP_NAME }} || true

      # entrypoint.sh runs migrate -> seed -> start inside this one container.
      # set -e stops before the app starts if migrations fail.
      - name: Run new container
        run: |
          docker run -d \
            --name ${{ env.APP_NAME }} \
            --restart unless-stopped \
            -p 127.0.0.1:8100:2001 \
            --env-file .env \
            -e PRISMA_AUTO_RESOLVE_APPLIED=true \
            -e APP_ENV=dev \
            ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}

      - name: Clean up old images
        run: |
          docker images "${{ env.IMAGE_NAME }}" --format '{{.Tag}}' \
            | grep -v "^${{ env.IMAGE_TAG }}$" \
            | xargs -r -I {} docker rmi "${{ env.IMAGE_NAME }}:{}" || true
          docker image prune -f

      - name: Remove .env file
        if: always()
        run: rm -f .env
The secrets → .env one-liner
toJSON(secrets) dumps all environment secrets as JSON; jq converts each entry to KEY=value lines, skipping GITHUB_TOKEN. The same is done for vars. The app then reads them via --env-file at docker run.
06 Prisma: Migrate & Seed
Migrations

prisma migrate deploy applies any pending SQL migrations in order and records each as applied in the Prisma-managed _prisma_migrations table. It's non-interactive — the CI/CD-safe counterpart to the interactive migrate dev.

  • Why at startup, not build time: the image is built once and should deploy anywhere. Which migrations are "pending" is only known once the container starts with real DB_* credentials.
  • Failed-state safety: if a migration starts but the process is killed mid-way, Prisma marks it failed in _prisma_migrations and refuses further migrations until a human (or recovery command) resolves it — it won't guess whether the SQL partially ran.
Seeding

Seeding populates baseline data (here, a single admin user). It runs on every startup, so it must be idempotent — hence upsert, not create:

seed.ts (excerpt)
await prisma.user.upsert({
  where:  { email: ADMIN_EMAIL },
  update: { name: ADMIN_NAME, passwordHash, status: UserStatus.ACTIVE, type: UserType.ADMIN },
  create: { name: ADMIN_NAME, email: ADMIN_EMAIL, passwordHash, status: UserStatus.ACTIVE, type: UserType.ADMIN },
});
Migrations vs. seeding
  • Migrations change structure — tables, columns, indexes, FKs. Versioned, ordered, tracked individually.
  • Seeding inserts data into that structure. Not versioned — just a script that creates or updates known records.
  • That's why migrate must always run before seed — you can't insert a row into a table that doesn't exist yet. Exactly the order entrypoint.sh enforces.
07 Key Design Decisions
ENTRYPOINT vs CMD — why it mattered

The Dockerfile sets ENTRYPOINT ["./entrypoint.sh"]. In exec form, anything after the image name in docker run <image> <cmd> is appended as an argument to the entrypoint — it doesn't replace it. An earlier draft tried to run a "migration-only" container by passing the migrate script as that trailing command; the entrypoint ignored the argument and ran its full migrate → seed → start-app routine anyway — booting a second live app that never exited. The only correct override is the --entrypoint flag. This pipeline sidesteps it entirely by not needing a separate migration container.

Single container, no pre-flight migration container

An earlier version ran migrations in a throwaway --rm container before starting the real one — but since the workflow stops & removes the old container first, that pre-flight check protected nothing (the old app was already gone). It was simplified away: migrations run only inside the real container, and set -e still guarantees the app never starts if they fail.

Image tagging by commit SHA

Every build is medical-backend:<git-sha> rather than overwriting latest. The previous image stays available to roll back to manually — right up until the next successful deploy's cleanup removes it.

Worth revisiting — npm i vs npm ci: the build uses npm i, which can update the lockfile if deps drifted. npm ci is generally preferred in CI/Docker for reproducibility (clean, deterministic install from package-lock.json). Confirm whether the npm i choice is intentional.
08 Environment & Manual Operations
Environment Variables
VariableRole
DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAMEPrisma builds its connection string from these (via database.config.ts) — at both generate and migrate time
PRISMA_AUTO_RESOLVE_APPLIEDtrue → auto-resolve P3009 failed migrations as applied and retry
APP_ENVdev/development/local also enables auto-resolve
DB_MIGRATE_USER / DB_MIGRATE_PASSWORDOptional — a more-privileged DB user for migrations (ALTER + REFERENCES) while the app keeps a restricted runtime DB_USER
Manual Recovery (if you ever need it)

Run a one-off command against the built image to resolve a stuck migration:

bash
# Mark a migration as already applied (its SQL is correctly in the DB):
docker run --rm --env-file .env --entrypoint sh medical-backend:<tag> \
  -c "npx prisma migrate resolve --applied <migration_name>"

# Or, if the migration genuinely never applied (after reverting partial changes):
docker run --rm --env-file .env --entrypoint sh medical-backend:<tag> \
  -c "npx prisma migrate resolve --rolled-back <migration_name>"
  • --applied — "this migration's SQL is already in the DB, just mark it done."
  • --rolled-back — "this never happened, forget it." Use only after manually reverting any partial changes.
The Result

A push-to-deploy pipeline that's simple to reason about and hard to break: one image, one container, one ordered startup routine. Migrations can't half-apply silently into production, secrets never touch an image layer, and any build can be rolled back by its commit SHA. Backend ships in minutes, every push.

Related Documentation
NestJS Backend Deployment  ·  GitHub Actions · Docker · Prisma · Self-hosted Runner