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.
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.
Six deliberate choices that make this pipeline simple, safe, and reproducible:
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.
| Step | What happens | Why it's safe |
|---|---|---|
| Checkout | Pulls the repo at the pushed commit (actions/checkout@v4) | Standard, deterministic source fetch |
| Create .env | All dev-environment secrets & vars are written to a local .env, excluding GITHUB_TOKEN | File is chmod 600 (owner read/write only) |
| Build image | docker 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 old | docker stop … || true | || true means the first-ever deploy (no container yet) doesn't fail |
| Remove old | docker rm … || true | Frees the container name for reuse |
| Run new | docker run -d — detached, named, --restart unless-stopped, real .env injected | The only container in the pipeline; entrypoint runs migrate → seed → start before serving |
| Clean images | Removes every other SHA-tagged image of this name, then prunes dangling layers | Keeps runner disk bounded (SHA tags aren't "dangling," so prune alone misses them) |
| Remove .env | Deletes .env from the runner — if: always() | Safe: Docker already copied the values into the container's own env at run time |
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.
# ---- 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"]
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.
The image's ENTRYPOINT always runs on container start. It does three things in order: migrate, seed, start.
#!/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
It classifies common failure modes and either auto-recovers (dev only) or prints exact manual-recovery commands. The core loop:
#!/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
| Code | Meaning | Behavior |
|---|---|---|
P3009 | A previously failed migration exists; deploy is blocked until resolved | If PRISMA_AUTO_RESOLVE_APPLIED=true or APP_ENV=dev → auto resolve --applied & retry (≤25×). Otherwise prints manual steps. |
P3018 | A migration failed while actively applying this run | Always manual-recovery only — the failure just happened and its DB state is unverified |
1142 / REFERENCES | DB user lacks REFERENCES privilege; FK-creating migration fails | Prints a GRANT template for the DBA + suggests a separate privileged DB_MIGRATE_USER |
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.
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
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.
Seeding populates baseline data (here, a single admin user). It runs on every startup, so it must be idempotent — hence upsert, not create:
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 },
});
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.
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.
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.
| Variable | Role |
|---|---|
DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME | Prisma builds its connection string from these (via database.config.ts) — at both generate and migrate time |
PRISMA_AUTO_RESOLVE_APPLIED | true → auto-resolve P3009 failed migrations as applied and retry |
APP_ENV | dev/development/local also enables auto-resolve |
DB_MIGRATE_USER / DB_MIGRATE_PASSWORD | Optional — a more-privileged DB user for migrations (ALTER + REFERENCES) while the app keeps a restricted runtime DB_USER |
Run a one-off command against the built image to resolve a stuck migration:
# 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.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.