Handing over a project means giving the team everything it needs to run, check and release it without the author: a README that starts from zero, a .env.example with every variable, one check command, a list of external services with account owners, a release and rollback checklist, and an honest list of known limitations. You verify the package with a rehearsal: someone who didn't write the code clones the repository into a clean folder and follows the README step by step.
After this you will be able to
- write a README that starts from zero and verify it with a clean-clone rehearsal
- make
.env.examplepart of the checks: a test fails when the code reads a variable that isn't in the file - build
npm run checkand pin the Node.js version withenginesandengine-strict - put together a table of external services and owners and a release and rollback checklist with tested
pg_dumpandpg_restorecommands
Know beforehand
Verified with
Node.js 24 LTS (24.18), npm 11.16Express 5.3, zod 4.6Prisma ORM 7.10.0PostgreSQL 17 (postgres:17-alpine), Docker Compose v2Vitest 5.0.4, supertest 7.3, TypeScript 6.0.3
Checked against docs: October 12, 2026
Contents
What goes into a handoff package
An MVP built with an AI assistant often runs on one laptop only: that's where the .env lives, the database client was generated and the right container is running, while the order of commands lives in the author's head and the chat history with the agent. A new team, a contractor or an investor's technical due diligence gets the repository without any of that. A handoff package puts the context back into the repository as files that can be checked.
README: start from zero
- Question it answers
- What to install and which commands to run on a clean machine
- How to check it
- Rehearsal: clean clone, commands exactly as written
.env.example and a startup check
- Question it answers
- Which variables are needed and where to get values
- How to check it
- A test compares the file with the code; the app fails with a clear list
npm run check
- Question it answers
- How to tell nothing is broken
- How to check it
- The same command in CI on every pull request
External services and owners
- Question it answers
- Where the domain, hosting, database and bots live and who owns the accounts
- How to check it
- Every account opens under a company login
Release and rollback checklist
- Question it answers
- How to ship a version and how to go back
- How to check it
- Backup and restore commands have been run at least once
Known limitations
- Question it answers
- What's missing or simplified
- How to check it
- Tests that pin this behaviour
The practice project is a website leads API: Express 5, Prisma 7.10, PostgreSQL 17 in Docker Compose and optional Telegram notifications. Every file below comes from it, and every command was run.
README: start from zero
The first README an assistant writes often looks convincing and doesn't work. Here's what happened when we followed one word for word in a clean clone:
## Quick start
```bash
git clone <repository-url> leads-mvp
cd leads-mvp
npm install
npm run dev
```$ npm install
4 high severity vulnerabilities
$ npm run dev
> tsx watch --env-file=.env src/server.ts
node: .env: not found
$ cp .env.example .env && npx tsx --env-file=.env src/server.ts # next guess
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…\src\generated\prisma\client.js' imported from …\src\db.tsThe author already had all of this on their machine, so the steps went missing unnoticed. According to the GitHub docs, a README covers what the project does, why it's useful, how to get started, where to get help and who maintains it. For a handoff the key one is getting started: requirements with versions, every command in order, and a way to check the result.
# Leads MVP
API for the website contact form: stores leads in PostgreSQL and can notify
a Telegram chat about each new lead.
## Requirements
- Node.js 24 and npm 11 (`node -v`, `npm -v`)
- Docker with Compose v2 (`docker compose version`) for the local database
- Free ports: 3080 for the API and 55439 for PostgreSQL. To use other ports,
change `PORT`, `DB_PORT` and the port in `DATABASE_URL` in `.env`
## Quick start from zero
Commands are for bash: macOS, Linux, WSL or Git Bash on Windows.
```bash
git clone <repository-url> leads-mvp
cd leads-mvp
cp .env.example .env
docker compose up -d --wait db
npm ci
npx prisma generate
npm run db:migrate
npm run db:seed
npm run check
npm run dev
```
In another terminal: `curl http://localhost:3080/health` returns
`{"status":"ok"}`.
## Commands
| Command | What it does |
| --- | --- |
| `npm run dev` | API with reload on file changes |
| `npm run check` | Type check and tests. Must pass before every merge |
| `npm run db:migrate` | Applies pending migrations (`prisma migrate deploy`) |
| `npm run db:seed` | Adds a demo lead. Local database only |
| `docker compose down` | Stops the database; add `-v` to delete its data |
## Project structure
- `src/app.ts` — HTTP routes and input validation
- `src/env.ts` — environment variables, checked at startup
- `src/db.ts` — database access (Prisma)
- `prisma/` — schema, migrations, seed
- `tests/` — API tests without a real database
- `docs/HANDOFF.md` — external services and account owners
- `docs/RELEASE.md` — release and rollback checklist
## Configuration
Every variable is listed with a comment in `.env.example`; a test fails
if the code reads a variable that is not there. Production values are set
in the hosting provider's settings and are never committed.
## Known limitations
- `POST /api/leads` has no rate limiting or captcha: spam is possible.
- Admin access is one shared `ADMIN_TOKEN`; there are no users or roles.
- If Telegram is unreachable, the request returns 500 after the lead
is already saved, and a retry by the user creates a duplicate.
- `GET /api/leads` returns the latest 100 leads, without pagination.
- Tests use an in-memory store; the database is checked only by the
quick start above.
- On 12 Oct 2026 `npm audit` reports 4 high-severity advisories in the
dependencies of the `prisma` CLI (`mysql2`, `deepmerge-ts`). The suggested
fix downgrades Prisma to 6.x, so it is not applied. Re-check after
Prisma updates.
## Contacts
- Product owner: `<name, email>`
- Technical contact: `<name, email>`npm ci, notnpm install: according to the npm docs,npm ciinstalls exactly what's inpackage-lock.json, fails if it doesn't matchpackage.jsonand never rewrites the lock file.docker compose up -d --wait dbwaits until the service is healthy, so the next command doesn't hit a database that isn't ready. That needs ahealthcheckincompose.yaml.- Migrations are applied with
prisma migrate deploy, the same command as on the server. The Prisma docs callmigrate deva development command that should never be used in production. - The last line checks the result:
/healthreturns{"status":"ok"}. Without it there's no telling whether it worked.
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: leads
POSTGRES_PASSWORD: leads_local_only
POSTGRES_DB: leads
ports:
- "${DB_PORT:-55439}:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U leads -d leads"]
interval: 2s
timeout: 3s
retries: 15
volumes:
db-data:Environment variables: .env.example and a startup check
.env.example is the one place where a new team learns which variables are needed. It holds no secrets: only names, safe local values, comments on where to get real ones, and which variables are optional. How to store the secrets themselves and what to do after a leak is covered in the secrets article.
# Copy to .env for local development: cp .env.example .env
# Production values live in the hosting provider's settings, never in git.
# PostgreSQL. The local value matches compose.yaml.
DATABASE_URL="postgresql://leads:leads_local_only@localhost:55439/leads"
# HTTP port of the API.
PORT=3080
# Token for GET /api/leads (Authorization: Bearer ...), at least 32 characters.
# Generate one: node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
ADMIN_TOKEN="replace-with-a-random-string-of-32-plus-chars"
# Optional: Telegram notifications about new leads. Leave empty to disable.
TELEGRAM_BOT_TOKEN=""
TELEGRAM_CHAT_ID=""The app checks variables at startup and lists what's missing right away, instead of crashing half an hour later on the first database query:
import { z } from "zod";
const Env = z.object({
DATABASE_URL: z.url(),
PORT: z.coerce.number().int().positive().default(3080),
ADMIN_TOKEN: z.string().min(32, "must be at least 32 characters"),
TELEGRAM_BOT_TOKEN: z.string().optional(),
TELEGRAM_CHAT_ID: z.string().optional(),
});
export type Env = z.infer<typeof Env>;
export const ENV_KEYS = Object.keys(Env.shape);
// Fails fast at startup with a list of what is missing, instead of a crash later.
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
const parsed = Env.safeParse(source);
if (!parsed.success) {
const problems = parsed.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`);
console.error(`Invalid environment. Compare .env with .env.example:\n${problems.join("\n")}`);
process.exit(1);
}
return parsed.data;
}$ npx tsx src/server.ts # started without --env-file, .env not loaded
Invalid environment. Compare .env with .env.example:
- DATABASE_URL: Invalid input: expected string, received undefined
- ADMIN_TOKEN: Invalid input: expected string, received undefinedTo keep .env.example in step with the code, a test compares them. The agent added a variable to src/env.ts but forgot the example, and the check fails before the merge:
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { ENV_KEYS } from "../src/env.js";
// .env.example is part of the handoff: it must list every variable the code reads.
it(".env.example documents every environment variable", () => {
const documented = readFileSync(".env.example", "utf8")
.split("\n")
.map((line) => line.match(/^([A-Z0-9_]+)=/)?.[1])
.filter(Boolean);
expect(documented.sort()).toEqual([...ENV_KEYS].sort());
});$ npx vitest run tests/env-example.test.ts # SENTRY_DSN added to src/env.ts only
FAIL tests/env-example.test.ts > .env.example documents every environment variable
- Expected
+ Received
[
"ADMIN_TOKEN",
"DATABASE_URL",
"PORT",
- "SENTRY_DSN",
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_CHAT_ID",
]One check command and pinned versions
The team taking over needs one command that tells them whether the project is intact. npm run check combines the type check and the tests; people, the agent and CI all run it. Making it mandatory for pull requests is shown in the lesson on tests for generated code and in the GitHub Actions lesson.
{
"scripts": {
"dev": "tsx watch --env-file=.env src/server.ts",
"start": "tsx --env-file=.env src/server.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"check": "npm run typecheck && npm test",
"db:migrate": "prisma migrate deploy",
"db:seed": "tsx --env-file=.env prisma/seed.ts"
},
"engines": {
"node": ">=24 <25"
}
}engine-strict=trueAccording to the npm docs, engines is advisory unless engine-strict is set. With it, npm ci on the wrong Node.js version stops immediately instead of failing later with an obscure build error. We checked it on a copy of the project with the requirement raised to Node.js 26:
$ node -v
v24.18.0
$ npm ci # test copy: engines.node ">=26", .npmrc engine-strict=true
npm error code EBADENGINE
npm error notsup Not compatible with your version of node/npm: leads-mvp@1.0.0
npm error notsup Required: {"node":">=26"}
npm error notsup Actual: {"node":"v24.18.0","npm":"11.16.0"}npm ci in a clean clone surfaced one more thing worth handing over rather than hiding: dependency audit warnings. Here they come from the Prisma CLI, and the suggested fix downgrades Prisma to 6.x. That's a decision for the team, so it's recorded in the README under Known limitations with the date it was checked.
$ npm audit
deepmerge-ts <8.0.0
Severity: high
mysql2 <=3.23.0
Severity: high
fix available via `npm audit fix --force`
Will install prisma@6.19.3, which is a breaking change
4 high severity vulnerabilitiesExternal services and account owners
Code is only part of the product. The domain, hosting, database, Telegram bot, payment dashboard, AI assistant accounts with API keys are often registered to the personal email of whoever built the MVP. At handoff this is the most painful item: without access to DNS or the database the new team can't ship even a typo fix.
# Handoff
## External services and account owners
| Service | Used for | Variables | Account owner | Others with access |
| --- | --- | --- | --- | --- |
| Git hosting | Code, CI | — | `<company account>` | `<names, roles>` |
| App hosting | Runs the API | `DATABASE_URL`, `ADMIN_TOKEN`, `TELEGRAM_*` | `<company account>` | `<names, roles>` |
| Managed PostgreSQL | Production data, backups | `DATABASE_URL` | `<company account>` | `<names, roles>` |
| Domain and DNS | `<api domain>` | — | `<company account>` | `<names, roles>` |
| Telegram bot | Lead notifications | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | `<company account>` | `<names, roles>` |
| AI assistant accounts and API keys | Development only | — | `<who pays>` | `<names>` |
## Access transfer
- [ ] Every account above is registered to a company email, not a personal one.
- [ ] The receiving team has admin access to each service.
- [ ] Access of people who leave the project is revoked or reduced.
- [ ] Secrets are rotated after the transfer: `ADMIN_TOKEN`, the bot token,
the database password.
- [ ] Two-factor recovery codes are in the company password manager.
- [ ] Personal API keys used by AI assistants during development are revoked.
## Context
- Commands and rules for people and AI assistants: `AGENTS.md`.
- Limitations that are known and accepted: README, section Known limitations.Release, rollback and known limitations
A release checklist isn't bureaucracy: it settles questions that otherwise get decided in a hurry. Before the release: green CI, reviewed migrations and a backup; after it: a /health check; for trouble: a rollback agreed in advance.
# Release and rollback
## Before the release
- [ ] `npm run check` is green in CI on the release commit.
- [ ] New migrations are reviewed. A release only adds columns and tables;
removing or renaming happens in a later release, when no running version
uses the old structure.
- [ ] A database backup is made and its file name is written in the release notes:
```bash
pg_dump --format=custom --file="leads-$(date +%Y%m%d-%H%M).dump" "$DATABASE_URL"
```
- [ ] The release commit is tagged: `git tag v1.4.0 && git push origin v1.4.0`.
## Release
1. CI/CD deploys the tag and runs `npm run db:migrate` once.
2. `GET /health` returns `{"status":"ok"}`.
3. A test lead from the staging form reaches the database and Telegram.
## Rollback
1. Deploy the previous tag. This is safe when the release only added
columns and tables: the old version ignores them.
2. If a migration must be undone, write a new migration that reverses it
and release it as usual. Applied migrations are never edited.
3. Restore the backup only as a last resort: data written after the backup
is lost.
```bash
pg_restore --clean --if-exists --no-owner --dbname="$DATABASE_URL" leads-YYYYMMDD-HHMM.dump
```The backup and restore commands in the checklist were run against the practice database: we made a dump, added a lead, restored — and the extra lead was gone. That's why it's the last rollback step: a restore returns the database to the moment of the backup, together with all data written after it.
$ pg_dump --format=custom --file="leads-$(date +%Y%m%d-%H%M).dump" "$DATABASE_URL"
leads-20261012-1410.dump
leads after an extra insert: 2
$ pg_restore --clean --if-exists --no-owner --dbname="$DATABASE_URL" leads-20261012-1410.dump
exit code: 0
leads after restore: 1Rolling back the code to the previous tag is safe only if the release just added columns and tables: the old version simply doesn't use them. That's why removing and renaming happen in a later release — the expand-then-contract approach described in the deployment article. How to review migrations an agent writes is covered in the article on architecture boundaries and migrations.
A Known limitations section in the README is a sign of maturity, not weakness. It saves the new team days of discovering what the author already knew. It helps to pin a limitation with a test: if someone fixes it, the test fails and reminds them to update the README.
// Documents a known limitation from README: the lead is saved, the response is 500.
it("known limitation: a failed notification returns 500 after saving", async () => {
const { app, notify, saved } = setup();
notify.mockRejectedValueOnce(new Error("telegram unreachable"));
const res = await request(app).post("/api/leads").send({ name: "Aigerim", phone: "+7 701 123 45 67" });
expect(res.status).toBe(500);
expect(saved).toHaveLength(1);
});Handoff rehearsal: a clean clone, strictly by the README
A clean folder and environment
Clone the repository into a new folder, without your
.env,node_modulesor generated files. Ideally on the machine or account of someone who didn't write the code.Only the README's commands
Run them in order, word for word. Any command from memory is a missing step that needs to be written down.
Check the result
npm run checkis green,/healthresponds, a test lead is saved.A log of discrepancies
Write down every hiccup, fix it in the repository, then rehearse again from the start.
We automated the rehearsal with a script: it takes the command block from the Quick start section and runs it in a new folder. The final README went from git clone to a saved lead:
$ git clone …/project leads-mvp
$ cd leads-mvp
$ cp .env.example .env
$ docker compose up -d --wait db
$ npm ci
4 high severity vulnerabilities
$ npx prisma generate
✔ Generated Prisma Client (7.10.0) to .\src\generated\prisma in 25ms
$ npm run db:migrate
Applying migration `20261012140759_init`
All migrations have been successfully applied.
$ npm run db:seed
seed: 1 lead(s) in the database
$ npm run check
Test Files 2 passed (2)
Tests 6 passed (6)
$ npm run dev
[dev] leads-mvp listening on http://localhost:3080
$ curl http://localhost:3080/health
{"status":"ok"}
POST /api/leads → 201 {"id":2}
rehearsal finished in 27 sA rehearsal finds bugs in code, too. Another app was already using port 3000 on our machine, and the first version of the server printed "listening" and quietly exited with code 0. In Express 5, according to the migration guide, a startup error is passed to the app.listen callback rather than thrown. The fixed version fails with a clear error:
import { createApp } from "./app.js";
import { createDb, createLeadStore } from "./db.js";
import { loadEnv } from "./env.js";
import { createNotify } from "./notify.js";
const env = loadEnv();
const db = createDb(env.DATABASE_URL);
const app = createApp({ leads: createLeadStore(db), notify: createNotify(env), adminToken: env.ADMIN_TOKEN });
// Express 5 passes a listen error (for example, a busy port) to this callback.
app.listen(env.PORT, (error) => {
if (error) throw error;
console.info(`leads-mvp listening on http://localhost:${env.PORT}`);
});$ PORT=3000 npx tsx --env-file=.env server.before.ts # first version, port 3000 is busy
leads-mvp listening on http://localhost:3000
exit code: 0
$ PORT=3000 npx tsx --env-file=.env src/server.ts # fixed version
Error: listen EADDRINUSE: address already in use :::3000
exit code: 1- A rehearsal on one machine doesn't replace checking on the server: deployment, the domain and access are checked separately with the release checklist.
- The README goes stale with every change. Rehearse again before each handoff and after any change to setup.
- A handoff package doesn't make the code good. It makes the project checkable: the new team will see faster what to keep and what to redo.
How this was verified9 · passed1 · not runchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.