DevOps Basics for AI Apps: Git, Docker, CI/CD, and Deployment
Ship an AI web app safely with Git workflows, a multi-stage Docker image, a CI pipeline that runs tests and evals, environment validation, health checks, and rollbacks.
On this page· The pipeline at a glance
Why this matters
AI features change behavior in ways unit tests do not catch: a one-word prompt edit can make answers worse while every test stays green. A good delivery pipeline makes changes small, reviewed, reproducible, and reversible, and it adds an AI-specific gate: evals that measure answer quality before a change reaches users. This is the boring infrastructure that lets you move fast without breaking production.
The pipeline at a glance#
Hover or tap a component to see what it does.
Show text descriptionHide text description
The pipeline runs left to right. The extra step compared with a classic web app is the eval suite: prompt and model changes can break behavior without breaking any unit test.
- git push: Every change starts as a commit on a branch, with a pull request for review.
- CI checks: Fast, deterministic checks on every push. A red build blocks the merge.
- Evals: Runs the eval dataset against the changed prompts or models and fails if quality drops below the baseline.
- Preview deploy: A full copy of the app at its own URL, so reviewers can try the change.
- Production: Merging to main deploys. Keep the previous version ready for a one-click rollback.
Each stage answers one question. CI: does the code still work? Evals: does the AI still behave well? Preview: does it look and feel right to a reviewer? Production: can we ship it, and undo it in one click if not?
Git: small, reviewed, reversible#
- Short-lived branches and pull requests. One change per pull request, merged within a day or two. Small diffs get real reviews.
- Prompts are code. Keep system prompts, tool descriptions, and eval datasets in the repository, not in a dashboard, so every change is diffable, reviewed, and revertible.
- Never commit secrets. Add
.env*to.gitignorebefore the first commit, and enable your Git host's secret scanning. If a key is ever pushed, revoke it immediately; deleting the commit is not enough. - Write commit messages for your future self: what changed and why, especially for prompt changes ("Ask for citations in the system prompt; eval faithfulness 0.81 → 0.90").
Validate configuration at startup#
A missing environment variable should fail the deploy, not the first user request at 3 a.m. Parse process.env with a schema in one module and import env everywhere else.
import { z } from "zod";
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
DATABASE_URL: z.url(),
REDIS_URL: z.url().default("redis://localhost:6379"),
ANTHROPIC_API_KEY: z.string().min(1, "Set ANTHROPIC_API_KEY on the server"),
SESSION_SECRET: z.string().min(32),
});
const parsed = Env.safeParse(process.env);
if (!parsed.success) {
// Print every problem at once, then stop: a half-configured server is worse than none.
console.error("Invalid environment:", parsed.error.issues.map((i) => `${i.path.map(String).join(".")}: ${i.message}`));
throw new Error("Invalid environment variables");
}
export const env = parsed.data;A production Docker image#
A multi-stage Dockerfile keeps the final image small: dependencies and the build happen in throwaway stages, and only the standalone server output is copied into the image that runs. Enable it in Next.js with output: "standalone" in next.config.ts.
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER app
EXPOSE 3000
CMD ["node", "server.js"]The same image can run more than one process. A background worker that indexes documents uses the image with a different command, next to Postgres and Redis:
Hover or tap a component to see what it does.
Show text descriptionHide text description
Containers make the environment part of the code: the versions of Node.js, Postgres, and Redis are pinned in files you commit.
- Dockerfile: Recipe: base image, install dependencies, copy code, build, set the start command.
- Image: The built artifact. The same image runs on your laptop, in CI, and in production.
- web container: Serves the app. Scale horizontally by running more copies.
- worker container: Same image, different command: processes background jobs.
- postgres: Official image with the pgvector extension, data on a named volume.
- redis: Queue and cache.
services:
web:
build: .
env_file: .env.production
ports: ["3000:3000"]
depends_on: [postgres, redis]
worker:
build: .
command: ["node", "worker.js"]
env_file: .env.production
depends_on: [postgres, redis]
postgres:
image: pgvector/pgvector:pg17
environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: app, POSTGRES_DB: app }
volumes: [pgdata:/var/lib/postgresql/data]
redis:
image: redis:7-alpine
volumes:
pgdata:Continuous integration with an eval gate#
This GitHub Actions workflow runs fast checks on every push and the eval suite on pull requests. Evals call a real model, so they need an API key from your repository secrets and they cost money: keep the dataset focused and run it where it matters.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
evals:
needs: checks
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run evals
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}Repository secrets are not passed to workflows triggered by pull requests from forks, so external contributions will skip or fail the eval job until a maintainer runs it. The evals article shows what npm run evals contains.
Deploying#
Pick a managed platform until you have a reason not to: most of them build from Git, give every pull request a preview URL, manage TLS, and support instant rollbacks. Whatever you choose, check these boxes:
- Secrets in the platform's encrypted settings, separate per environment (preview environments should use a separate, budget-capped API key).
- Streaming support. Confirm the platform does not buffer responses and allows long enough request durations for model calls.
- A health check the platform polls to decide whether a new version is ready to receive traffic.
- One-click rollback to the previous build, and the habit of using it before debugging in production.
- Database migrations as a separate, explicit step, written to be backward-compatible so the old version keeps working during rollout and after a rollback.
// Liveness check for the platform's health probe. Keep it cheap: no model calls here.
export function GET() {
return Response.json({ status: "ok", version: process.env.GIT_SHA ?? "dev", uptime: Math.round(process.uptime()) });
}Common mistakes#
- Prompts edited in production dashboards. Unreviewed, untested, and impossible to roll back. Prompts belong in Git.
- Sharing one API key across local, preview, and production. A bug in a preview branch can drain the production budget, and you cannot tell traffic apart. Use separate keys with separate limits.
- Building on the server at deploy time. Build once in CI, then deploy the same artifact everywhere, so what you tested is what runs.
- Migrations that break the running version. Renaming a column the old code still reads takes the site down during rollout. Add the new column, deploy, backfill, then remove the old one later.
- Health checks that call the model. A slow provider then marks every instance unhealthy at once. Check your own process, and monitor upstreams separately.
Try it
Wire up the pipeline
- Add
typecheck(tsc --noEmit) andtestscripts to a project'spackage.jsonand push the CI workflow. - Open a pull request that breaks a type on purpose and confirm the check fails and blocks the merge.
- Build the Docker image locally and run it with
docker run -p 3000:3000 --env-file .env.local <image>. Call/api/health. - Remove one required variable from the env file and confirm the container exits with a clear message.
Check your understanding
Key takeaways
- Keep prompts, tool definitions, and eval datasets in Git; review them like code.
- Validate environment variables at startup and fail fast.
- Build once into a small, non-root Docker image and deploy that artifact everywhere.
- CI runs lint, types, tests, and build on every push, plus evals on pull requests.
- Deploy with previews, health checks, separate keys per environment, backward-compatible migrations, and one-click rollbacks.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Databases for AI Apps: PostgreSQL, Drizzle, Redis, and pgvectorDesign an AI app's data layer with PostgreSQL, Drizzle, pgvector for embeddings, and Redis for caching and rate limits, with a full schema and runnable queries.
- LLM Evals in TypeScript: Datasets, Graders, LLM-as-Judge, and CIBuild an eval suite for an LLM feature: real-case datasets, code graders, a calibrated LLM judge, repeated trials, baseline comparison, and a CI gate in TypeScript.