Step 8
E2E manifest + deploy
35 min
E2E manifest + deploy
As page count grows, the common mistake becomes "added a page, forgot the spec". Derive route manifests from the filesystem and remove the cost.
1. Page manifest
import fg from 'fast-glob';
import { writeFileSync } from 'node:fs';
const files = await fg(['src/app/**/page.tsx', '!src/app/api/**']);
const routes = files.map((f) =>
'/' + f
.replace(/^src\/app\//, '')
.replace(/\/page\.tsx$/, '')
.replace(/\/\([^)]+\)/g, '')
.replace(/\/\[(\.{3})?([^\]]+)\]/g, '/:$2')
);
writeFileSync('e2e/pages/manifest.json',
JSON.stringify({ routes: routes.sort() }, null, 2));
2. API manifest
const files = await fg('src/app/api/**/route.ts');
const endpoints = [];
for (const file of files) {
const src = readFileSync(file, 'utf-8');
const methods = ['GET','POST','PUT','PATCH','DELETE']
.filter((m) => new RegExp(`export\\s+async\\s+function\\s+${m}\\b`).test(src));
if (!methods.length) continue;
endpoints.push({ path: '/' + file.replace(/^src\/app\//, '').replace(/\/route\.ts$/, ''), methods });
}
3. Single spec iterating
for (const route of manifest.routes) {
test(`page ${route} smoke`, async ({ page }) => {
const url = route
.replace(/:id/g, '00000000-0000-0000-0000-000000000000')
.replace(/:slug/g, 'test-slug');
const resp = await page.goto(url);
expect(resp?.status() ?? 0).toBeLessThan(500);
});
}
4. Protect writes
export function skipWriteOnProd() {
if (process.env.E2E_ENV === 'PROD') test.skip();
}
Apply plus grepInvert: /@write/ in the PROD Playwright config.
5. CI drift detection
- name: regenerate manifests
run: |
pnpm tsx e2e/pages/generate-manifest.ts
pnpm tsx e2e/equivalence/generate-manifest.ts
git diff --exit-code e2e/**/manifest.json
6. Deploy — Docker standalone
export default { output: 'standalone' } satisfies NextConfig;
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache postgresql-client
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
postgresql-client is needed for pg_dump.
7. docker-compose (dev)
services:
admin:
build: .
env_file: .env.dev
ports: ["127.0.0.1:3000:3000"]
volumes:
- ./backups:/app/backups
depends_on:
- postgres-blog
- postgres-market
postgres-blog:
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: blog
ports: ["127.0.0.1:5435:5432"]
volumes: [blog-data:/var/lib/postgresql/data]
127.0.0.1: loopback binding keeps PG off the public internet.
8. Caddy reverse proxy (prod)
admin.example.com {
reverse_proxy admin:3000
}
VPN / IP allow-list is better, but HTTPS via Caddy is the minimum.
9. Checklist
- CI regenerates manifests and diffs clean
- Every write test tagged
@writeand usesskipWriteOnProd -
DISABLE_CRON=1for local dev - PG containers bind
127.0.0.1: - Quarterly restore rehearsal
Closing
Once a single hub serves several domains, "why is this page slow?" and "where did the audit miss?" are answered in one place. The five foundations (separate pools, shared table, audit log, backups, manifest-driven E2E) keep compounding.
Next
- architecture-patterns (successor course)
🎉 You finished Central admin platform — many domains behind one hub
What's next? Pick another course below.