How to buildOctober 12, 20268 min read
Vibe coding: from prototype to productStep 7 of 7

Releasing and handing over an AI-built MVP: what to prepare

In short

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.

How-toLevel: IntermediateVibe CodingPractice: ~1–2 h

After this you will be able to

  • write a README that starts from zero and verify it with a clean-clone rehearsal
  • make .env.example part of the checks: a test fails when the code reads a variable that isn't in the file
  • build npm run check and pin the Node.js version with engines and engine-strict
  • put together a table of external services and owners and a release and rollback checklist with tested pg_dump and pg_restore commands

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
  1. 01What goes into a handoff package
  2. 02README: start from zero
  3. 03Environment variables: .env.example and a startup check
  4. 04One check command and pinned versions
  5. 05External services and account owners
  6. 06Release, rollback and known limitations
  7. 07Handoff rehearsal: a clean clone, strictly by the README

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
A minimal handoff package and how each part is checked

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:

README.md (first version)
## Quick start

```bash
git clone <repository-url> leads-mvp
cd leads-mvp
npm install
npm run dev
```
terminal
$ 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.ts
No .env, then no generated Prisma client — and that's before the database

The 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.

README.md
# 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>`
The final README. Checked with markdownlint-cli2 0.23.3 and a clean-clone rehearsal
  • npm ci, not npm install: according to the npm docs, npm ci installs exactly what's in package-lock.json, fails if it doesn't match package.json and never rewrites the lock file.
  • docker compose up -d --wait db waits until the service is healthy, so the next command doesn't hit a database that isn't ready. That needs a healthcheck in compose.yaml.
  • Migrations are applied with prisma migrate deploy, the same command as on the server. The Prisma docs call migrate dev a development command that should never be used in production.
  • The last line checks the result: /health returns {"status":"ok"}. Without it there's no telling whether it worked.
compose.yaml
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:
The local database: the healthcheck makes --wait work, the port can be changed with DB_PORT

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.

.env.example
# 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:

src/env.ts
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;
}
terminal
$ 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 undefined
Starting without .env: a clear list instead of a stack trace

To 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:

tests/env-example.test.ts
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());
});
terminal
$ 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",
  ]
SENTRY_DSN is in the code but not in .env.example

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.

package.json
{
  "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"
  }
}
Excerpt: scripts and the required Node.js version
.npmrc
engine-strict=true

According 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:

terminal
$ 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.

terminal
$ 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 vulnerabilities
Excerpt from npm audit on 12 Oct 2026

External 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.

docs/HANDOFF.md
# 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.
A template: the product owner fills in the values in angle brackets

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.

docs/RELEASE.md
# 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.

terminal
$ 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: 1
pg_dump and pg_restore from the postgres:17-alpine image against the database from compose.yaml

Rolling 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.

tests/app.test.ts
  // 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);
  });
Excerpt: the test pins known behaviour — the lead is saved, the response is 500

Handoff rehearsal: a clean clone, strictly by the README

  1. A clean folder and environment

    Clone the repository into a new folder, without your .env, node_modules or generated files. Ideally on the machine or account of someone who didn't write the code.

  2. 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.

  3. Check the result

    npm run check is green, /health responds, a test lead is saved.

  4. 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:

terminal
$ 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 s
Rehearsal on the final README (excerpt from the log)

A 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:

src/server.ts
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}`);
});
terminal
$ 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.