Deploy a Complete AI Incident Backend on One Neon Branch

A preview environment is only isolated if all of its state follows the preview. Giving a pull request its own application deployment and database branch does not help much when its writes still reach production identity, file, or model services.
This tutorial deploys Incident Atlas, a small incident-response backend whose Lakebase Postgres data, Auth state, stored reports, Function, and AI Gateway host all belong to one disposable branch on Neon. You will create the branch, exercise every service against a real postmortem, inspect the isolation boundaries, and delete the environment without touching the parent branch.
The React interface runs locally. The complete backend, not the frontend site, is what lives on Neon.
TL;DR
After the one-time setup, the workflow is four commands:
npm run demo:up
npm run demo:test
npm run demo:open
npm run demo:down
They exercise five Neon backend primitives:
- Lakebase Postgres stores incident metadata and the search index.
- Neon Auth, Neon's Managed Better Auth service, signs the operator in.
- Object Storage keeps the original report in a private bucket.
- A Neon Function named Incident Atlas exposes the backend API.
- Neon AI Gateway gives the Function branch-scoped access to the configured model.
The important feature is their shared lifecycle. After a branch is created, changes to its Postgres data, Auth records, stored objects, Function, and AI Gateway host stay on that child. Deleting the child removes that environment; the parent project and its default branch remain.
A branch on Neon is not necessarily empty. A normal child exposes the parent's database rows and schema immediately through copy-on-write storage, and existing Auth and Object Storage state branches with it. Later child writes do not change the parent. This tutorial assumes that the default branch belongs to a new, demo-safe project; use the schema-only option described in Neon's database branching workflow primer when a preview must not inherit sensitive rows.
Object Storage, Functions, Neon Auth, and AI Gateway are generally available. The Neon backend GA announcement describes the current product set and Free Plan allowances.
Prerequisites
- Node.js 24 LTS
- A project on Neon in a region that supports the complete Neon backend
- A project-scoped Neon API key
- AI Gateway credits or an applicable account allowance
- The Incident Atlas companion repository
This walkthrough was tested in AWS US East (Ohio), whose Neon region ID is aws-us-east-2. At publication time, Neon also supports the complete backend in AWS Europe (Frankfurt). Using Ohio reproduces the environment behind the commands in this article.
“Hosted in AWS” does not mean that you install Neon in your AWS account. You choose the provider and region when creating the project in the Neon Console, and Neon operates the infrastructure. You do not supply credentials for your AWS account. Neon later generates AWS_* values for its S3-compatible Object Storage service; those values belong to the disposable branch on Neon.
From the companion repository, install dependencies and create the ignored environment file:
cd neon-incident-atlas
npm install
cp .env.example .env.local
Add the project ID and API key:
NEON_API_KEY=your_project_scoped_key
NEON_PROJECT_ID=your_project_id
Those are the only two values you put into .env.local. Deployment writes the temporary branch's connection strings and service credentials into that same ignored file.
The demo consumes metered resources and AI Gateway credits. Charges depend on your plan and remaining allowances. The child branch expires after six hours, but npm run demo:down is the primary cleanup mechanism.
Understand the request path first
Neon Functions is the product name. This project deploys a single Function whose display name is Incident Atlas. “One Function” is not a Neon product or service name.
Neon Auth does not invoke the Function. The browser first creates an Auth session, then requests a JWT and carries that token to the protected backend routes:
For each protected API call, the React client sends a credentialed request to Neon Auth's /token route to obtain a bearer token from the current session. The Function verifies the JWT signature and issuer against the branch's Auth JWKS before accessing user data.
The Function has seven routes. GET /health is intentionally public so deployment automation can detect the running release; the other six require a valid JWT.
Across those protected routes, the Function coordinates the other branch-local services:
The UI remains local for this tutorial because Neon Functions host backend logic, not frontend sites. In production, deploy the React application to your usual frontend host.
Declare the branch-local backend
The repository describes the backend in neon.ts:
import { defineConfig } from '@neon/config/v1';
import { INCIDENT_ATLAS_RELEASE } from './release.js';
export default defineConfig({
auth: true,
dataApi: false,
aiGateway: true,
buckets: {
'incident-files': {}, // Private by default
},
functions: {
app: {
name: 'Incident Atlas',
source: './functions/app.ts',
env: {
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS ?? 'http://localhost:5173',
INCIDENT_ATLAS_BUCKET: process.env.INCIDENT_ATLAS_BUCKET ?? 'incident-files',
INCIDENT_ATLAS_AI_MODEL: process.env.INCIDENT_ATLAS_AI_MODEL ?? 'gpt-5-mini',
INCIDENT_ATLAS_RELEASE,
},
dev: { port: 8787 },
},
},
});
The service declarations are top-level because Object Storage, Functions, and AI Gateway are GA. Older examples place them under a preview object; the current config package accepts that shape only as a deprecated compatibility path.
neon.ts states what should exist on the target branch. The Neon CLI supplies the Terraform-like workflow: neon config plan previews the changes, and neon config apply reconciles them.
Bring up the disposable backend
Run:
npm run demo:up
The deployment performs four stages:
- It creates
incident-atlas-demo-<timestamp>from the project's default branch and gives it a six-hour expiry. - It plans and deploys Neon Auth, the private bucket, the Incident Atlas Function, and AI Gateway.
- It allows localhost and registers
http://localhost:5173as an Auth domain. - It installs Lakebase Search's
lakebase_textextension through the direct database connection, then creates the table and BM25 index. The migration explicitly usessslmode=verify-full, so the Postgres driver continues to verify the certificate and hostname when its defaults change.
On the validated run, the CLI reported + bucket incident-files, + function app, and the unchanged service line Utilized services: Postgres, Neon Auth, Object Storage, Functions, AI Gateway. The helper ended with Applied migrations/001_incident_atlas.sql and Incident Atlas is ready.
The helper discovers the deployed Function URL and stores it as INCIDENT_APP_URL; you do not copy service URLs between commands.
The lifecycle script treats its local state as untrusted. Before reusing or deleting a recorded branch, it checks the project, branch ID, branch name, parent, and protection flags. If deployment fails after branch creation, it attempts cleanup automatically.
Upload one report
Start the local UI:
npm run demo:open
Open http://localhost:5173, then follow one path:
- Create an account through Neon Auth.
- Upload
fixtures/checkout-timeout-postmortem.md. - Wait for the status to move through
queuedandprocessingtoready. - Search for
checkout 504. - Replace the search text with
Why did checkout time out?and select Ask with citations.
The report does not pass through the Function during upload. The protected presign route creates an owner-scoped object key and returns a five-minute signed URL; the browser then sends the file directly to Object Storage.
When the browser confirms the upload, the Function checks that:
- the key begins with the authenticated user's
users/<owner>/namespace; - the stored byte count exactly matches the signed request;
- the stored content type matches the allowlisted type.
Only then does it create the queued database row. The object key is unique, so retrying the confirmation returns the existing incident instead of creating a duplicate or deleting its file. If a new insert fails and the database can confirm that no row references the object, the Function makes a best-effort cleanup attempt. If the database is unavailable, it preserves the object for later reconciliation rather than risk deleting referenced data.
waitUntil() allows the Function to return HTTP 202 Accepted while a bounded promise reads the private object and calls gpt-5-mini through Neon AI Gateway. The second read rechecks the size and content type and rejects invalid UTF-8. The model extracts a title, severity, summary, systems, and tags, and the Function stores the original text for retrieval. A failed read or model call moves the incident to failed instead of leaving it stuck in queued.
Incident Atlas accepts only Markdown, plain text, and JSON reports up to 32 KiB. That bound lets the Function send the complete report to the model instead of silently truncating a larger file. Browsers sometimes omit the MIME type for Markdown, so the UI falls back to the filename extension before the server enforces the same allowlist.
Search with Lakebase Search, then ask the model
Lakebase Search is the search product. This demo uses its lakebase_text Postgres extension, whose lakebase_bm25 index access method provides BM25 keyword ranking.
The migration creates a generated search column over the title, AI-generated summary, and original report:
content_tsv tsvector GENERATED ALWAYS AS (
to_tsvector(
'english',
coalesce(title, '') || ' ' ||
coalesce(summary, '') || ' ' ||
coalesce(content, '')
)
) STORED
It then creates the BM25 index:
CREATE INDEX incidents_bm25_idx
ON incidents USING lakebase_bm25 (content_tsv tsvector_bm25_ops)
WITH (default_limit = 50);
Search ranks the complete generated column but returns a query-relevant ts_headline excerpt. That distinction matters: returning the first 1,600 characters can rank the right report while hiding the matching evidence from both the reader and the model.
The /ask route takes at most four ranked reports, numbers their excerpts, and calls the model through Neon AI Gateway. Its system instruction requires citations such as [1], restricts the answer to the supplied reports, and treats report text as untrusted data rather than instructions.
AI Gateway remains behind the Function. The browser receives neither its credential nor the raw model request. Neon explains the same boundary in LLMs belong in your backend.
Prove what actually works
A zero exit code from a deployment command does not prove that the services work together. Run:
npm run demo:test
The smoke test:
- waits for the expected Function release;
- proves a protected route rejects an anonymous request;
- creates a Neon Auth session and verifies its JWT against the branch JWKS;
- uploads the real fixture and proves an unsigned read is denied;
- repeats upload confirmation and proves it returns the same incident;
- confirms the Function reads the object and completes AI enrichment;
- checks that Lakebase Search returns the incident with a relevant excerpt;
- checks that the model answer identifies the reported cause and cites the returned source inline;
- deletes the incident, then verifies both the database row and stored object are gone.
That is a live test against the deployed branch, not a mocked integration test. The temporary Auth identity remains inside the disposable branch and is removed during final branch teardown.
Keep user data isolated twice
The Function uses the verified JWT sub claim as the owner. Every database operation starts a transaction and sets that identity locally:
SELECT set_config('app.user_id', $1, true);
The table also enables and forces row-level security:
CREATE POLICY incidents_owner_policy ON incidents
USING (owner_id = current_setting('app.user_id', true))
WITH CHECK (owner_id = current_setting('app.user_id', true));
Explicit owner predicates make application intent visible. Forced RLS adds a database-enforced boundary using the same verified identity. The transaction-local setting is essential because Function requests reuse pooled database connections.
Object Storage applies the same owner ID in users/<owner>/<uuid>/.... A valid user cannot confirm another user's object key, and deletion can only obtain an object key through an owner-filtered database query.
Deletion removes the object before the database row. Object deletion is idempotent, so if the subsequent database operation fails, the caller can retry instead of leaving an unreachable object behind.
Tear down the complete backend
Stop the local UI and run:
npm run demo:down
The cleanup script refuses to proceed unless:
- the state file belongs to
NEON_PROJECT_ID; - the branch name starts with
incident-atlas-demo-; - the remote branch ID, name, and parent match the recorded branch;
- the branch is neither default nor protected.
It then deletes the child branch, polls until Neon reports it absent, removes the local state file, and prunes generated branch credentials from .env.local. Your project ID and API key remain for another run.
Deleting the child removes its Function alongside its database state, Auth identity, stored objects, and AI Gateway host. The parent project and default branch are not deleted. Neon Functions: backend logic next to your data describes how Functions inherit branch identity and lifecycle.
Wrapping up
The useful lesson is not that five products fit into one demo. It is that their state shares one operational boundary.
You create one child branch, deploy an authenticated backend, upload a private report, search it with Lakebase Search, call a model without exposing its credential, verify the complete request path, and delete the environment. The workflow stays approachable because the application code is already present and the four commands focus on the lifecycle a DevOps reader actually needs to understand.
Try it hands-on
Run the commands from this article in the browser. Nothing to install.
We earn commissions when you shop through the links below.
Svix
Webhooks as a service
Svix Dispatch sends your webhooks for you: retries with exponential backoff, signed payloads, idempotency keys, and a delivery log your customers can see.
Atomsized
AWS platform engineering and GitOps
Design and automation for reliable AWS and Kubernetes platforms, safer delivery workflows, and preview and UAT environments your engineers can understand and own.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
SMTPfast
Developer-first email API
Send transactional and marketing email through a clean REST API. Detailed logs, webhooks, and embeddable signup forms in one dashboard.
QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?
Related Posts
Also worth your time on this topic
Auth for a Postgres App, Without a Separate Service
The usual way to add auth is to run a second system next to your database and spend forever keeping the two in sync. Neon Auth puts the auth server in the same project as Postgres: one line in a config file, one deploy, and the user who signs in is a row you can join to your own tables. Here is how it works and why the reconciliation tax disappears.
Database Backup and Recovery
Describe database backup strategies and how you would design a recovery plan for production databases.
mid
CI/CD Pipeline Setup Checklist
Step-by-step checklist for a production-ready CI/CD pipeline: source control, builds, tests, security scans, deploy gates, secrets, and rollback paths.
1-2 hours