Next.js Full-Stack Bootstrap
On-demand command for adding backend infrastructure to a Next.js project — Drizzle ORM, Postgres, Redis, BullMQ, and Auth.js. Supports three blueprint tiers for right-sized infrastructure.
This bootstrap adds backend pieces to an existing Next.js project. Run the appropriate frontend bootstrap first (
/nextjs-bootstrap, etc.) to create the base project, then run this to add the backend layer.
MANDATORY: This project runs entirely in Docker. All Docker service additions (db, redis, worker) MUST be added to the existing docker-compose.yml. The Makefile is the sole interface — never run
npmornpxdirectly on the host.
Before You Start
Ask the user for these values (provide defaults where shown):
| Placeholder | Description | Example |
|---|---|---|
{blueprint} |
Blueprint tier: minimal, standard, or full |
standard |
{app-name} |
Existing project directory name | my-app |
{compose-project} |
Docker Compose project name (kebab-case) | my-app |
{db_name} |
Postgres database name (snake_case) | my_app |
{host_port} |
Host port for Next.js (should match existing) | 3000 |
Blueprint Tiers
| Tier | Name | Includes | Use when |
|---|---|---|---|
| 1 | minimal | Drizzle + Postgres + Docker services. No Redis, no auth, no queues, no Sentry. | Simple full-stack apps, prototypes, no user accounts |
| 2 | standard | Minimal + Redis cache + Auth.js + Sentry. No BullMQ, S3, email. | Most production apps with auth |
| 3 | full | Standard + BullMQ workers + S3/R2 + email. | Background tasks, file uploads, notifications |
Only generate files and sections marked for your blueprint tier. Sections are tagged
[ALL],[STANDARD+], or[FULL]. Generate[ALL]always,[STANDARD+]for standard and full,[FULL]only for full.
Bootstrapping Steps
- Verify the base Next.js project exists with
src/app/structure - Install dependencies (add to
package.json) - Create backend files from templates below (respecting blueprint tier tags)
- Update
docker-compose.ymlwith database and Redis services - Update
Makefilewith database and queue commands - Update
.env.localwith new environment variables make build && make upmake db-generate && make db-migrate- Verify at
http://localhost:{host_port}
New Files to Create
Directory Structure (additions to existing project)
[MINIMAL]:
src/
├── lib/
│ └── db/
│ ├── index.ts # Drizzle client
│ ├── schema/
│ │ ├── index.ts # Re-exports all tables
│ │ └── posts.ts # Example table
│ └── migrations/ # Generated by drizzle-kit
├── server/
│ ├── actions/
│ │ └── posts.ts # Example server actions
│ └── services/
│ └── posts.ts # Example service
├── app/
│ └── api/
│ └── v1/
│ └── posts/
│ └── route.ts # Example route handler
drizzle.config.ts
[STANDARD] — adds auth, redis, middleware:
src/
├── lib/
│ ├── auth.ts # Auth.js configuration
│ ├── redis.ts # Redis client
│ └── db/
│ ├── index.ts
│ ├── schema/
│ │ ├── index.ts
│ │ ├── users.ts # Auth.js user/account/session tables
│ │ └── posts.ts
│ └── migrations/
├── server/
│ ├── actions/
│ │ └── posts.ts
│ └── services/
│ └── posts.ts
├── app/
│ ├── api/
│ │ ├── auth/
│ │ │ └── [...nextauth]/
│ │ │ └── route.ts # Auth.js route handler
│ │ └── v1/
│ │ └── posts/
│ │ └── route.ts
│ └── (auth)/
│ ├── login/
│ │ └── page.tsx
│ └── register/
│ └── page.tsx
├── types/
│ └── next-auth.d.ts # Session type extensions
middleware.ts
drizzle.config.ts
[FULL] — adds BullMQ, storage service:
src/
├── lib/
│ ├── auth.ts
│ ├── redis.ts
│ ├── db/
│ │ ├── index.ts
│ │ ├── schema/
│ │ │ ├── index.ts
│ │ │ ├── users.ts
│ │ │ └── posts.ts
│ │ └── migrations/
│ └── queue/
│ ├── client.ts # Shared BullMQ connection
│ ├── queues.ts # Queue definitions
│ └── workers.ts # Worker processors
├── server/
│ ├── actions/
│ │ └── posts.ts
│ └── services/
│ ├── posts.ts
│ ├── storage.ts # S3/R2 file uploads
│ └── email.ts # Email via Resend
├── app/
│ ├── api/
│ │ ├── auth/
│ │ │ └── [...nextauth]/
│ │ │ └── route.ts
│ │ └── v1/
│ │ └── posts/
│ │ └── route.ts
│ └── (auth)/
│ ├── login/
│ │ └── page.tsx
│ └── register/
│ └── page.tsx
├── types/
│ └── next-auth.d.ts
middleware.ts
drizzle.config.ts
File Templates
drizzle.config.ts [ALL]
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/lib/db/schema/index.ts",
out: "./src/lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
src/lib/db/index.ts [ALL]
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });
src/lib/db/schema/index.ts [ALL]
[MINIMAL]:
export * from "./posts";
[STANDARD+]:
export * from "./users";
export * from "./posts";
src/lib/db/schema/posts.ts [ALL]
import { pgTable, serial, text, timestamp, uuid, index } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
export const posts = pgTable(
"posts",
{
id: serial("id").primaryKey(),
uuid: uuid("uuid").defaultRandom().unique().notNull(),
// Owner — ties each post to the authenticated user (STANDARD+ auth tier).
// Minimal (no-auth) blueprint: drop this column and the auth()/user scoping
// shown in the service, actions, and route below; posts are then unowned.
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
content: text("content"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
},
(t) => [index("posts_user_id_idx").on(t.userId)]
);
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
src/lib/db/schema/users.ts [STANDARD+]
import {
pgTable,
serial,
text,
timestamp,
uuid,
integer,
primaryKey,
} from "drizzle-orm/pg-core";
import type { AdapterAccountType } from "next-auth/adapters";
export const users = pgTable("users", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name"),
email: text("email").unique().notNull(),
emailVerified: timestamp("email_verified", { mode: "date" }),
image: text("image"),
hashedPassword: text("hashed_password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
});
export const accounts = pgTable(
"accounts",
{
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccountType>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("provider_account_id").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => [
primaryKey({ columns: [account.provider, account.providerAccountId] }),
]
);
export const sessions = pgTable("sessions", {
sessionToken: text("session_token").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verification_tokens",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
src/lib/auth.ts [STANDARD+]
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/lib/db";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { users } from "@/lib/db/schema";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
// Credentials logins never create database session rows in Auth.js —
// the JWT strategy is required for them to work, even with an adapter
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await db.query.users.findFirst({
where: eq(users.email, credentials.email as string),
});
if (!user?.hashedPassword) return null;
const isValid = await bcrypt.compare(
credentials.password as string,
user.hashedPassword
);
if (!isValid) return null;
return { id: user.id, name: user.name, email: user.email };
},
}),
// Add OAuth providers here:
// Google({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }),
],
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id;
return token;
},
session({ session, token }) {
session.user.id = token.id as string;
return session;
},
},
});
src/app/api/auth/[...nextauth]/route.ts [STANDARD+]
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
src/middleware.ts [STANDARD+]
import { auth } from "@/lib/auth";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage =
req.nextUrl.pathname.startsWith("/login") ||
req.nextUrl.pathname.startsWith("/register");
const isApiAuth = req.nextUrl.pathname.startsWith("/api/auth");
const isPublic =
req.nextUrl.pathname === "/" || req.nextUrl.pathname.startsWith("/api/v1");
// Allow auth API routes and public pages
if (isApiAuth || isPublic) return;
// Redirect logged-in users away from auth pages
if (isAuthPage && isLoggedIn) {
return Response.redirect(new URL("/dashboard", req.nextUrl));
}
// Redirect unauthenticated users to login
if (!isAuthPage && !isLoggedIn) {
return Response.redirect(new URL("/login", req.nextUrl));
}
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$).*)"],
};
src/types/next-auth.d.ts [STANDARD+]
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}
src/lib/redis.ts [STANDARD+]
import Redis from "ioredis";
const globalForRedis = globalThis as unknown as { redis: Redis | undefined };
export const redis =
globalForRedis.redis ??
new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
if (process.env.NODE_ENV !== "production") globalForRedis.redis = redis;
src/lib/queue/client.ts [FULL]
import Redis from "ioredis";
// Shared connection for BullMQ — separate from the cache client
// BullMQ requires maxRetriesPerRequest: null
export const queueConnection = new Redis(
process.env.REDIS_URL ?? "redis://localhost:6379",
{ maxRetriesPerRequest: null }
);
src/lib/queue/queues.ts [FULL]
import { Queue } from "bullmq";
import { queueConnection } from "./client";
export const emailQueue = new Queue("email-notifications", {
connection: queueConnection,
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 1000 },
removeOnComplete: { count: 100 },
removeOnFail: { count: 500 },
},
});
// Add more queues as needed:
// export const dataProcessingQueue = new Queue("data-processing", { ... });
src/lib/queue/workers.ts [FULL]
import { Worker } from "bullmq";
import { queueConnection } from "./client";
// Email notification worker
const emailWorker = new Worker(
"email-notifications",
async (job) => {
const { to, subject, body } = job.data;
// Call the email service here
console.log(`Processing email job ${job.id}: ${subject} -> ${to}`);
},
{ connection: queueConnection, concurrency: 5 }
);
emailWorker.on("completed", (job) => {
console.log(`Job ${job.id} completed`);
});
emailWorker.on("failed", (job, err) => {
console.error(`Job ${job?.id} failed:`, err.message);
});
export { emailWorker };
src/server/services/posts.ts [ALL]
import { db } from "@/lib/db";
import { posts, type NewPost, type Post } from "@/lib/db/schema";
import { and, count, eq } from "drizzle-orm";
// Queries are scoped to the owning user (STANDARD+ auth tier). Minimal
// (no-auth) blueprint: drop the userId parameters and the userId filters.
export async function listPosts(
userId: string,
{ page, pageSize }: { page: number; pageSize: number }
): Promise<{ results: Post[]; count: number }> {
const offset = (page - 1) * pageSize;
const [results, totals] = await Promise.all([
db.query.posts.findMany({
where: eq(posts.userId, userId),
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit: pageSize,
offset,
}),
db.select({ total: count() }).from(posts).where(eq(posts.userId, userId)),
]);
return { results, count: totals[0].total };
}
export async function getPost(
uuid: string,
userId: string
): Promise<Post | undefined> {
return db.query.posts.findFirst({
where: and(eq(posts.uuid, uuid), eq(posts.userId, userId)),
});
}
export async function createPost(data: NewPost): Promise<Post> {
const [post] = await db.insert(posts).values(data).returning();
return post;
}
export async function updatePost(
uuid: string,
userId: string,
data: Partial<NewPost>
): Promise<Post | undefined> {
const [post] = await db
.update(posts)
.set(data)
.where(and(eq(posts.uuid, uuid), eq(posts.userId, userId)))
.returning();
return post;
}
export async function deletePost(uuid: string, userId: string): Promise<void> {
await db.delete(posts).where(and(eq(posts.uuid, uuid), eq(posts.userId, userId)));
}
src/server/actions/posts.ts [ALL]
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import * as postService from "@/server/services/posts";
import { z } from "zod";
type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string };
const createPostSchema = z.object({
title: z.string().trim().min(1, "Title is required"),
content: z.string().optional(),
});
// auth() gates every mutation (STANDARD+ auth tier). Minimal (no-auth)
// blueprint: drop the session checks and the userId argument.
export async function createPost(
formData: FormData
): Promise<ActionResult<{ uuid: string }>> {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: "Unauthorized" };
}
const parsed = createPostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content") ?? undefined,
});
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0].message };
}
const post = await postService.createPost({
...parsed.data,
userId: session.user.id,
});
revalidatePath("/posts");
return { success: true, data: { uuid: post.uuid } };
}
export async function deletePost(uuid: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: "Unauthorized" };
}
await postService.deletePost(uuid, session.user.id);
revalidatePath("/posts");
return { success: true, data: undefined };
}
src/app/api/v1/posts/route.ts [ALL]
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import * as postService from "@/server/services/posts";
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(1, "Title is required"),
content: z.string().optional(),
});
// middleware treats /api/v1/* as public, so each handler authenticates itself
// (STANDARD+ auth tier). Minimal (no-auth) blueprint: drop the auth() guards
// and the userId scoping.
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Canonical pagination contract: page/page_size params → {page, count, num_pages, results}
const { searchParams } = new URL(request.url);
const page = Math.max(1, Number(searchParams.get("page")) || 1);
const pageSize = Math.min(
100,
Math.max(1, Number(searchParams.get("page_size")) || 20)
);
const { results, count } = await postService.listPosts(session.user.id, {
page,
pageSize,
});
return NextResponse.json({
page,
count,
num_pages: Math.max(1, Math.ceil(count / pageSize)),
results,
});
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const result = createPostSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Validation failed", details: z.flattenError(result.error).fieldErrors },
{ status: 400 }
);
}
const post = await postService.createPost({
...result.data,
userId: session.user.id,
});
return NextResponse.json(post, { status: 201 });
}
src/server/services/storage.ts [FULL]
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
const bucket = process.env.R2_BUCKET_NAME!;
export async function uploadFile(
key: string,
body: Buffer | ReadableStream,
contentType: string
): Promise<string> {
await s3.send(
new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType })
);
if (process.env.R2_CUSTOM_DOMAIN) {
return `https://${process.env.R2_CUSTOM_DOMAIN}/${key}`;
}
return key;
}
export async function deleteFile(key: string): Promise<void> {
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
}
export async function getSignedDownloadUrl(
key: string,
expiresIn = 3600
): Promise<string> {
return getSignedUrl(
s3,
new GetObjectCommand({ Bucket: bucket, Key: key }),
{ expiresIn }
);
}
src/server/services/email.ts [FULL]
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
from?: string;
}
export async function sendEmail({
to,
subject,
html,
from = process.env.DEFAULT_FROM_EMAIL ?? "noreply@example.com",
}: SendEmailOptions) {
const { data, error } = await resend.emails.send({
from,
to: Array.isArray(to) ? to : [to],
subject,
html,
});
if (error) {
throw new Error(`Failed to send email: ${error.message}`);
}
return data;
}
Docker Services (add to existing docker-compose.yml)
[MINIMAL] — add db service
db:
image: postgres:16
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB={db_name}
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
ports:
- "5432:5432"
volumes:
postgres_data:
Also update the existing app service:
app:
depends_on:
- db
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
[STANDARD] — add db + redis
db:
image: postgres:16
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB={db_name}
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
ports:
- "5432:5432"
redis:
image: redis:7
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
Also update the existing app service:
app:
depends_on:
- db
- redis
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
- REDIS_URL=redis://redis:6379/0
[FULL] — add db + redis + worker
db:
image: postgres:16
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB={db_name}
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
ports:
- "5432:5432"
redis:
image: redis:7
volumes:
- redis_data:/data
worker:
build:
context: .
dockerfile: Dockerfile.dev
command: npx tsx --watch src/lib/queue/workers.ts
volumes:
- .:/app
- /app/node_modules
depends_on:
- redis
- db
env_file:
- .env.local
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
- REDIS_URL=redis://redis:6379/0
volumes:
postgres_data:
redis_data:
Also update the existing app service:
app:
depends_on:
- db
- redis
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
- REDIS_URL=redis://redis:6379/0
Makefile Additions (append to existing Makefile)
[ALL] — Database commands
# ============================================================================
# Database (Drizzle)
# ============================================================================
.PHONY: db-generate db-migrate db-push db-studio db-seed dbshell dbreset
db-generate:
$(DOCKER_COMPOSE) exec app npx drizzle-kit generate
db-migrate:
$(DOCKER_COMPOSE) exec app npx drizzle-kit migrate
db-push:
$(DOCKER_COMPOSE) exec app npx drizzle-kit push
db-studio:
$(DOCKER_COMPOSE) exec app npx drizzle-kit studio
dbshell:
$(DOCKER_COMPOSE) exec db psql -U postgres -d {db_name}
dbreset:
@echo "WARNING: This will delete the database!"
@read -p "Are you sure? [y/N] " confirm && [ "$$confirm" = "y" ]
$(DOCKER_COMPOSE) exec db psql -U postgres -c "DROP DATABASE IF EXISTS {db_name};"
$(DOCKER_COMPOSE) exec db psql -U postgres -c "CREATE DATABASE {db_name};"
$(DOCKER_COMPOSE) exec app npx drizzle-kit migrate
[FULL] — Queue commands
# ============================================================================
# Queue (BullMQ)
# ============================================================================
.PHONY: worker logs-worker
worker:
$(DOCKER_COMPOSE) exec worker npx tsx src/lib/queue/workers.ts
logs-worker:
$(DOCKER_COMPOSE) logs -f worker
Package Dependencies (add to existing package.json)
[MINIMAL]
{
"dependencies": {
"drizzle-orm": "^0.45",
"pg": "^8.13",
"zod": "^4.4"
},
"devDependencies": {
"drizzle-kit": "^0.31",
"@types/pg": "^8.11"
}
}
[STANDARD] (adds auth, redis, bcrypt)
{
"dependencies": {
"drizzle-orm": "^0.45",
"pg": "^8.13",
"zod": "^4.4",
"next-auth": "5.0.0-beta.31",
"@auth/drizzle-adapter": "^1.11",
"bcryptjs": "^3.0",
"ioredis": "^5.4"
},
"devDependencies": {
"drizzle-kit": "^0.31",
"@types/pg": "^8.11"
}
}
[FULL] (adds bullmq, s3, resend)
{
"dependencies": {
"drizzle-orm": "^0.45",
"pg": "^8.13",
"zod": "^4.4",
"next-auth": "5.0.0-beta.31",
"@auth/drizzle-adapter": "^1.11",
"bcryptjs": "^3.0",
"ioredis": "^5.4",
"bullmq": "^5.30",
"@aws-sdk/client-s3": "^3.700",
"@aws-sdk/s3-request-presigner": "^3.700",
"resend": "^4.1"
},
"devDependencies": {
"drizzle-kit": "^0.31",
"@types/pg": "^8.11",
"tsx": "^4.19"
}
}
Environment Variables (add to existing .env.local)
[MINIMAL]
# Database
DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
[STANDARD] (adds redis, auth)
# Database
DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
# Redis
REDIS_URL=redis://redis:6379/0
# Auth.js
AUTH_SECRET=generate-a-random-secret-here
AUTH_URL=http://localhost:{host_port}
# Error Tracking
SENTRY_DSN=
[FULL] (adds storage, email)
# Database
DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
# Redis
REDIS_URL=redis://redis:6379/0
# Auth.js
AUTH_SECRET=generate-a-random-secret-here
AUTH_URL=http://localhost:{host_port}
# Storage (Cloudflare R2)
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=
R2_CUSTOM_DOMAIN=
# Email (Resend)
RESEND_API_KEY=
DEFAULT_FROM_EMAIL=noreply@example.com
# Error Tracking
SENTRY_DSN=
Integration Summaries
After completing a backend feature, generate an integration summary:
- List all new/modified endpoints with method, path, auth requirements
- Include request/response shapes as JSON examples
- Note any pagination, filtering, or ordering parameters
- Document error response shapes
- Save to
docs/integration/[feature-name].md
This mirrors the Django convention so frontend consumers have a consistent reference.