Deployment
AI Operations Dashboard runs in three modes. Pick one with the AI_OPS_DASHBOARD_MODE env var. The dashboard config and panel code are identical across all three โ only runtime characteristics change.
| Mode | Set with | What it does | Host on | |---|---|---|---| | Vercel / SSR | AI_OPS_DASHBOARD_MODE=vercel (default) | Server-renders every request. Loaders run live. | Vercel, any Next.js host | | Static | AI_OPS_DASHBOARD_MODE=static | Exports plain HTML/CSS/JS. Loaders run once at build. | S3, GitHub Pages, Netlify, file://, nginx | | Node | AI_OPS_DASHBOARD_MODE=node | Long-running Node server. Optional SSE refresh daemon. | Docker, EC2, Fly, Render |
The mode flag is read by next.config.mjs:
output: process.env.AI_OPS_DASHBOARD_MODE === "static" ? "export" : undefined,
trailingSlash: process.env.AI_OPS_DASHBOARD_MODE === "static",Deploy to Vercel
The fastest path. SSR is the default.
- Push your repo to GitHub.
- Go to vercel.com/new and import the repo.
- Framework preset: Next.js (auto-detected).
- Click Deploy.
That's it. The build command (npm run build) runs npm run data:build first, which freezes git history and aggregates AI usage. See the shallow-clone gotcha below โ it matters.
Optional env vars:
AI_OPS_DASHBOARD_MODE=vercel
AI_OPS_DASHBOARD_FREEZE_SINCE="180 days ago"
AI_OPS_DASHBOARD_FREEZE_LIMIT=500Static export
Works anywhere you can serve static files.
AI_OPS_DASHBOARD_MODE=static npm run buildThe output lands in out/. Loaders run at build time, so the dashboard reflects a snapshot of your data files when the build ran. Schedule a daily build to keep it fresh.
S3 + CloudFront
AI_OPS_DASHBOARD_MODE=static npm run build
aws s3 sync out/ s3://my-dashboard --delete
aws cloudfront create-invalidation --distribution-id <ID> --paths "/*"Set the bucket policy to allow s3:GetObject from CloudFront and configure CloudFront with out/index.html as the default root object.
GitHub Pages
# .github/workflows/deploy.yml
on:
push: { branches: [main] }
schedule: [{ cron: "0 12 * * *" }] # daily refresh
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history for freeze-commits
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: AI_OPS_DASHBOARD_MODE=static npm run build
- uses: actions/upload-pages-artifact@v3
with: { path: out }
deploy:
needs: build
permissions: { pages: write, id-token: write }
runs-on: ubuntu-latest
steps:
- uses: actions/deploy-pages@v4Netlify
Build settings:
- Build command:
AI_OPS_DASHBOARD_MODE=static npm run build - Publish directory:
out
Local file://
AI_OPS_DASHBOARD_MODE=static npm run build
open out/index.htmltrailingSlash: true is auto-set in static mode so paths resolve correctly when opened directly from the filesystem.
Internal nginx
server {
listen 80;
server_name dashboard.internal;
root /var/www/ai-ops-dashboard/out;
index index.html;
location / { try_files $uri $uri/ $uri.html =404; }
}Node mode
For SSE / long-running connections / custom middleware.
AI_OPS_DASHBOARD_MODE=node npm run build
AI_OPS_DASHBOARD_MODE=node npm startUse this when you want to wire a real-time push mechanism into a panel (Server-Sent Events, WebSockets) instead of the default <meta http-equiv="refresh"> polling.
Docker
Sample Dockerfile:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV AI_OPS_DASHBOARD_MODE=node
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/data ./data
COPY --from=builder /app/docs ./docs
COPY --from=builder /app/help ./help
COPY --from=builder /app/plans ./plans
COPY --from=builder /app/knowledge ./knowledge
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["npm", "start"]Compose:
services:
dashboard:
build: .
ports: ["3000:3000"]
environment:
AI_OPS_DASHBOARD_MODE: node
volumes:
- ./data:/app/data:ro
- ./docs:/app/docs:ro
- ./help:/app/help:ro
- ./plans:/app/plans:ro
- ./knowledge:/app/knowledge:ro
restart: unless-stoppedMount data folders read-only so the running container reflects whatever lives on the host.
The shallow-clone gotcha
Vercel and most CI hosts clone with fetch-depth: 1 (just the latest commit). Running git log on a shallow clone returns one entry, which would silently truncate your Activity panel.
The fix: scripts/freeze-commits.mjs captures git log into data/recent-commits.json, which gets committed to the repo. The Activity panel reads the JSON snapshot first and only falls back to live git log in dev.
The freeze script is wired into npm run build via data:build. It also includes a safety check: it refuses to overwrite a richer existing snapshot with a smaller one, so a shallow clone can't silently truncate your committed history.
npm run data:freeze-commits # manual refreshPre-push hook
To make sure pushed commits always include an up-to-date freeze, install a pre-push hook. Use simple-git-hooks (already in the recommended dev deps):
// package.json
{
"simple-git-hooks": {
"pre-push": "node scripts/freeze-commits.mjs && git add data/recent-commits.json && git diff --cached --quiet || git commit -m '[freeze] data/recent-commits.json'"
}
}Then run npx simple-git-hooks once on each checkout. Bypass for emergencies with SKIP_SIMPLE_GIT_HOOKS=1 git push.
Authentication
The dashboard is read-only and contains no auth layer. Put it behind something.
Vercel preview protection (paid plans)
Toggle on in the Vercel project settings. Free for Pro/Enterprise. One-click. Best option if you're already on Vercel.
Cloudflare Access
Put the dashboard behind Cloudflare Tunnel, then add a Cloudflare Access policy (email allowlist, Google SSO, GitHub SSO โ all free up to 50 users). Zero changes to AI Operations Dashboard.
Basic auth via Next.js middleware
For static or node mode hosted on your own infra:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server"
export function middleware(req: NextRequest) {
const auth = req.headers.get("authorization")
if (!auth) return new NextResponse("Auth required", { status: 401, headers: { "WWW-Authenticate": "Basic" } })
const [scheme, b64] = auth.split(" ")
if (scheme !== "Basic") return new NextResponse("Forbidden", { status: 403 })
const [user, pass] = atob(b64).split(":")
if (user !== process.env.DASH_USER || pass !== process.env.DASH_PASS) {
return new NextResponse("Forbidden", { status: 403 })
}
return NextResponse.next()
}
export const config = { matcher: ["/((?!_next|favicon).*)"] }Set DASH_USER and DASH_PASS as env vars. Middleware doesn't run on static-mode exports โ for that path, use Cloudflare Access or your host's auth layer.
Next
- upgrading.md โ Pull new versions without breaking your config.
- faq.md โ Common deployment questions.