Skip to main content

NeonDB Pre-Seeded Dev Data

Connect your kindling environment to a Neon database branch so every developer gets a pre-seeded copy of real schema and sample data without maintaining seed scripts or fixtures.


Why Neon + kindling

ChallengeHow this solves it
Seed scripts drift from production schemaNeon branch copies the real schema
Seeding takes minutes on every resetNeon branches create in <1 second
Shared dev database causes conflictsEvery developer gets their own branch
Local Postgres eats RAMDatabase runs in Neon, not your laptop

If you want a fully local database instead, use kindling's built-in - type: postgres dependency — it auto-provisions Postgres inside Kind.


What you'll build

A FastAPI service that:

  1. Connects to a Neon database branch with pre-loaded product catalog data
  2. Exposes REST endpoints for browsing and searching products
  3. Runs locally with kindling sync for instant code iteration
┌──────────┐     ┌───────────────┐     ┌──────────────────┐
│ Browser │────▶│ FastAPI │────▶│ Neon Postgres │
│ :8000 │◀────│ Product API │ │ (dev branch) │
└──────────┘ └───────────────┘ └──────────────────┘

Project structure

product-api/
├── Dockerfile
├── requirements.txt
├── seed.sql
└── main.py

requirements.txt

fastapi==0.115.0
uvicorn[standard]==0.30.0
asyncpg==0.30.0
python-dotenv==1.0.1

seed.sql

Run this once against your Neon main branch to populate sample data:

CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price NUMERIC(10, 2) NOT NULL,
category TEXT NOT NULL,
in_stock BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);

INSERT INTO products (name, description, price, category) VALUES
('Wireless Keyboard', 'Low-profile mechanical keyboard with Bluetooth', 79.99, 'electronics'),
('Standing Desk Mat', 'Anti-fatigue mat for standing desks, 20x34 inches', 49.99, 'office'),
('USB-C Hub', '7-in-1 hub with HDMI, USB-A, SD card, ethernet', 34.99, 'electronics'),
('Noise Cancelling Headphones', 'Over-ear, 30hr battery, ANC', 199.99, 'electronics'),
('Ergonomic Mouse', 'Vertical mouse with adjustable DPI', 44.99, 'electronics'),
('Desk Lamp', 'LED lamp with 5 brightness levels and USB charging port', 29.99, 'office'),
('Webcam HD', '1080p webcam with auto-focus and ring light', 59.99, 'electronics'),
('Cable Management Kit', 'Clips, sleeves, and ties for under-desk cables', 14.99, 'office'),
('Monitor Riser', 'Bamboo stand with storage drawer', 39.99, 'office'),
('Portable Charger', '20000mAh power bank with PD fast charging', 24.99, 'electronics');

CREATE INDEX IF NOT EXISTS idx_products_category ON products (category);

main.py

import os
from contextlib import asynccontextmanager

import asyncpg
from fastapi import FastAPI, HTTPException

DATABASE_URL = os.environ["DATABASE_URL"]


@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=5)
yield
await app.state.pool.close()


app = FastAPI(title="Product Catalog", lifespan=lifespan)


@app.get("/products")
async def list_products(category: str | None = None, in_stock: bool = True):
"""List products with optional category filter."""
pool = app.state.pool
if category:
rows = await pool.fetch(
"SELECT * FROM products WHERE category = $1 AND in_stock = $2 ORDER BY name",
category,
in_stock,
)
else:
rows = await pool.fetch(
"SELECT * FROM products WHERE in_stock = $1 ORDER BY name", in_stock
)
return [dict(r) for r in rows]


@app.get("/products/{product_id}")
async def get_product(product_id: int):
pool = app.state.pool
row = await pool.fetchrow("SELECT * FROM products WHERE id = $1", product_id)
if not row:
raise HTTPException(404, "Product not found")
return dict(row)


@app.get("/products/search/{query}")
async def search_products(query: str):
"""Full-text search across name and description."""
pool = app.state.pool
rows = await pool.fetch(
"""SELECT *, ts_rank(
to_tsvector('english', name || ' ' || coalesce(description, '')),
plainto_tsquery('english', $1)
) AS rank
FROM products
WHERE to_tsvector('english', name || ' ' || coalesce(description, ''))
@@ plainto_tsquery('english', $1)
ORDER BY rank DESC""",
query,
)
return [dict(r) for r in rows]


@app.get("/health")
async def health():
pool = app.state.pool
await pool.fetchval("SELECT 1")
return {"status": "ok"}

Dockerfile

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Neon setup (one-time)

1. Create a project & seed the main branch

# Install the Neon CLI
brew install neonctl

# Create a project (or use an existing one)
neonctl projects create --name kindling-dev

# Get the connection string for the main branch
neonctl connection-string --project-id <project-id>

# Seed with sample data
psql "$(neonctl connection-string --project-id <project-id>)" -f seed.sql

2. Create a dev branch

Each developer creates their own branch from main. Branches are copy-on-write and instant — no data is copied until you write.

neonctl branches create \
--project-id <project-id> \
--name dev-$(whoami) \
--parent main

# Get this branch's connection string
neonctl connection-string --project-id <project-id> --branch dev-$(whoami)

kindling setup

1. Store the connection string

# Use your dev branch connection string from above
kindling secrets set DATABASE_URL "postgresql://user:pass@ep-cool-name.us-east-2.aws.neon.tech/neondb?sslmode=require"

2. Workflow

No dependencies block needed — the database is external.

name: dev-deploy
on:
push:
branches: [main]
workflow_dispatch:

env:
REGISTRY: registry:5000
TAG: ${{ github.actor }}-${{ github.sha }}

jobs:
deploy:
runs-on: [self-hosted, "${{ github.actor }}"]
steps:
- uses: actions/checkout@v4
- run: rm -rf /builds/*

- name: Build product API
uses: kindling-sh/kindling/.github/actions/kindling-build@main
with:
name: product-api
context: ${{ github.workspace }}
image: "${{ env.REGISTRY }}/product-api:${{ env.TAG }}"

- name: Deploy product API
uses: kindling-sh/kindling/.github/actions/kindling-deploy@main
with:
name: ${{ github.actor }}-product-api
image: "${{ env.REGISTRY }}/product-api:${{ env.TAG }}"
port: "8000"
ingress-host: "${{ github.actor }}-products.localhost"
health-check-path: "/health"
env: |
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: kindling-secret-database-url
key: value

3. Iterate

kindling sync -n <you>-product-api -d .
# Edit query logic, add new endpoints, change response shapes
# Changes appear instantly — no rebuild needed

Resetting dev data

One of the biggest advantages of Neon branches: reset is instant.

# Delete the old branch
neonctl branches delete --project-id <project-id> --branch dev-$(whoami)

# Create a fresh one from main (includes all seed data)
neonctl branches create \
--project-id <project-id> \
--name dev-$(whoami) \
--parent main

This takes under a second regardless of data size — no re-seeding needed.


When to use local Postgres instead

Use Neon branchesUse kindling - type: postgres
Need production-like schemaPrototyping from scratch
Team shares a seed datasetWorking offline
Want instant branch resetWant zero external dependencies
Testing against Neon-specific featuresDon't need persistent data across resets

To switch to local Postgres, remove the DATABASE_URL secret and add a dependency to your workflow:

dependencies:
- type: postgres
name: product-db

kindling will auto-inject PRODUCT_DB_URL as an env var pointing to the local Postgres instance.