Scaling a TypeScript Full‑Stack with Postgres, JWT Auth, and LLM‑Driven CI/CD on a Budget
- Keyword
- TypeScript full-stack scaling
- Length
- 2156 words
- Read
- 10 min
I built a small TypeScript API + Next.js UI that talks to a Neon Postgres instance, uses a simple cookie‑based JWT session, and runs on Render’s free tier. It works for a handful of daily users, but when the traffic spiked to 200 RPS I hit rate limits, cold‑starts, and a $15 surprise invoice. The fix was to let an LLM write the CI/CD steps, move static assets out of the container, and add feature‑toggle guards that let me scale without paying for a full‑size VM.
Below I walk through the three things that saved me:
- LLM‑generated CI/CD – a cheap GitHub Action that writes migrations, lints, and runs integration tests automatically.
- Hosting tricks – static HTML on Cloudflare Pages, image blobs in Backblaze B2, and a Neon connection‑pool shim to stay under the free‑tier connection limit.
- Dynamic feature toggles – a tiny
featurestable in Postgres that lets me turn on/off expensive code paths without a new deploy.
1. How can LLMs automate CI/CD for a TypeScript/Postgres app?

The problem
Free tiers usually limit you to 500 build minutes per month and a maximum of 2 concurrent jobs. My manual pipeline (run lint → run unit tests → run migrations → deploy) took 12 minutes per push and often stalled because the runner hit the 30‑minute timeout on the migration step.
The LLM solution
I let an LLM (Groq llama3‑8b‑instruct) generate a single GitHub Actions workflow that does everything in under 4 minutes. The prompt I used was:
Write a GitHub Actions workflow for a TypeScript/Next.js project that:
- Installs deps with pnpm
- Runs ESLint and Prettier
- Executes `npm test`
- Runs Prisma migrations against a Neon DB (use ${{ secrets.NEON_URL }})
- Deploys to Render if all steps succeed
- Caches pnpm store and node_modules
The LLM returned a concise YAML file; I tweaked the cache keys and added a step to prune idle connections. Here’s the final version:
name: CI/CD
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install pnpm
run: npm i -g pnpm
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Lint & format
run: |
pnpm lint
pnpm format:check
- name: Unit tests
run: pnpm test --ci
- name: Prisma migrate
env:
DATABASE_URL: ${{ secrets.NEON_URL }}
run: pnpm prisma migrate deploy
- name: Deploy to Render
if: success()
uses: render-examples/render-deploy-action@v1
with:
api-key: ${{ secrets.RENDER_API_KEY }}
service-id: ${{ secrets.RENDER_SERVICE_ID }}
Why it works on a budget
- Caching reduces the install time from ~2 min to <30 s.
- Single job respects the free‑tier concurrent‑job limit.
- Prisma migrate runs against Neon’s connection‑pool shim (see section 2) so the migration step never exceeds the 20‑connection free limit.
If the LLM generates a step that fails, the pipeline stops early and I avoid wasting build minutes. I also added a continue-on-error: true guard around a static analysis step that occasionally flaps on the free tier due to network hiccups.
Failure modes to watch
| Symptom | Cause | Fix |
|---|---|---|
429 Too Many Requests from Neon |
Exceeding 20 concurrent connections | Use a singleton pg.Pool with max: 5 and idleTimeoutMillis: 30000. |
Build hangs at pnpm install |
Cache key mismatch after a lockfile change | Include pnpm-lock.yaml hash in the cache key. |
| Deploy fails with “service not found” | Render service ID typo | Store the ID in a secret, never hard‑code. |
2. What cheap cloud hosting tricks keep costs low while scaling?

2.1 Separate static assets from the API container
Putting a public blog or the Next.js /_next/static folder inside a Render worker means the container never sleeps – it stays warm, eats CPU, and adds $5 / month. I moved everything that can be served as static HTML to Cloudflare Pages. The build step in the CI pipeline now runs pnpm build && pnpm export and pushes the out/ folder to the Pages repo via the cloudflare/pages-action.
- name: Export static site
run: pnpm build && pnpm export
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CF_PAGES_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
projectName: my-site
directory: out
Result: the API container only handles /api/* routes; everything else is served from Cloudflare’s edge for free.
2.2 Store images and large blobs in object storage
I used Backblaze B2 because it’s $0.005 / GB per month and has a generous free tier. The upload endpoint in the API streams directly to B2 using a signed URL, so the Render worker never buffers the whole file.
import { createPresignedUrl } from '@backblaze/b2';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end();
const { filename, contentType } = req.body;
const url = await createPresignedUrl({
bucketId: process.env.B2_BUCKET_ID!,
fileName: filename,
expiresIn: 600,
contentType,
});
res.status(200).json({ uploadUrl: url });
}
Because the file never touches the container’s filesystem, the worker can spin down after the request, keeping the free tier’s 15‑minute idle limit intact.
2.3 Neon connection‑pool shim
Neon’s free tier allows 20 active connections. A naïve Next.js app opens a new pg.Pool per request, quickly exhausting the quota. I wrapped the pool in a module that re‑uses a singleton and closes idle connections after 30 seconds.
// db.ts
import { Pool } from 'pg';
let pool: Pool | null = null;
export function getPool(): Pool {
if (!pool) {
pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
idleTimeoutMillis: 30000,
});
}
return pool;
}
All API routes import getPool() instead of creating their own client. This keeps the connection count well below the limit even under burst traffic.
Failure modes to watch
- Cold start latency – Render workers on the free tier spin down after 15 min of inactivity. First request after sleep adds ~1 s latency; acceptable for most UI actions, but not for webhook callbacks. I mitigated by adding a heartbeat GitHub Action that pings
/api/healthevery 10 minutes. - B2 rate limits – Backblaze caps at 5 k requests per hour on the free tier. I batch image uploads when possible and fallback to a local temp folder for low‑traffic dev environments.
- Neon connection spikes – During a migration, I temporarily increase
maxto 10 and run the migration in a dedicated GitHub Action job that closes the pool afterwards.
3. How do dynamic feature toggles help manage growth?

The need
When a new “recommendations” micro‑service became available, I didn’t want to redeploy the whole stack just to test it. I also needed a way to turn off a heavy analytics endpoint that was causing the 429 rate‑limit errors on the free tier.
The implementation
A features table in Postgres stores a boolean flag per feature. The API reads the flag at request time, caches it for 30 seconds, and short‑circuits the expensive code path if the flag is off.
-- migrations/2024-08-31-create-features.sql
CREATE TABLE features (
name TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT FALSE
);
INSERT INTO features (name, enabled) VALUES
('recommendations', FALSE),
('heavyAnalytics', FALSE);
// featureToggle.ts
import { getPool } from './db';
import LRU from 'lru-cache';
const cache = new LRU<string, boolean>({ max: 100, ttl: 30_000 });
export async function isEnabled(name: string): Promise<boolean> {
const cached = cache.get(name);
if (cached !== undefined) return cached;
const pool = getPool();
const { rows } = await pool.query(
'SELECT enabled FROM features WHERE name = $1',
[name],
);
const enabled = rows[0]?.enabled ?? false;
cache.set(name, enabled);
return enabled;
}
Usage in an API route:
import type { NextApiRequest, NextApiResponse } from 'next';
import { isEnabled } from '../../lib/featureToggle';
import { getRecommendations } from '../../lib/recommendations';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!(await isEnabled('recommendations'))) {
return res.status(404).json({ error: 'Feature disabled' });
}
const data = await getRecommendations(req.query.userId as string);
res.json(data);
}
Benefits
- Zero‑downtime rollout – Flip the flag in a single SQL
UPDATEand the change propagates instantly. - Cost control – Turn off
heavyAnalyticsduring peak hours to stay under the free tier’s request quota. - A/B testing – Add a
percentagecolumn and random‑sample users in the toggle logic without touching code.
Failure modes to watch
| Symptom | Cause | Fix |
|---|---|---|
| All requests return 404 after a toggle change | Cache not invalidated on DB restart | Restart the worker or add a POST /admin/flush-feature-cache endpoint. |
| Feature flag table grows unchecked | Developers add rows without cleanup | Add a nightly job that removes rows older than 90 days. |
| Unexpected latency on first request | Cache miss + DB round‑trip | Warm the cache in the CI deploy step (npm run warm-features). |
What I would do on a Monday

- Run the health‑check cron – the GitHub Action that pings
/api/healthto keep the Render worker warm. - Flip the
heavyAnalyticsflag off for the weekend usingpsql -c "UPDATE features SET enabled = FALSE WHERE name='heavyAnalytics'". - Merge the LLM‑generated CI change that adds a new
pnpm auditstep; watch the Action logs for any new vulnerabilities. - Deploy a one‑line schema change (
ALTER TABLE users ADD COLUMN last_seen TIMESTAMP;) via the Prisma migration step; verify the connection pool stays under 20 connections. - Check B2 usage in the dashboard; if we’re near the 5 k request limit, schedule a batch job to compress old images.
That’s it. No fluff, just the concrete actions that keep a TypeScript full‑stack alive on a shoestring budget while still being ready to grow.
4. Managing Postgres Connection Limits on Neon (Free Tier)

Neon’s free tier caps you at 20 active connections. If you spin up a Render worker for every PR, you’ll hit the limit in minutes. The fix is to centralise the pool in a single long‑lived process and make every request reuse that pool.
Why it bites
- Cold starts – A new worker boots, creates its own pool, and immediately consumes a connection.
- Serverless‑style loops – If you use
pg.Poolinside an API route handler, each invocation creates a fresh pool. - Hidden retries – Prisma’s auto‑retry logic will open a second connection if the first one is busy, doubling the consumption.
Step‑by‑step solution
- Create a singleton pool module that lives outside the request handler.
- Export a helper that returns a client from that pool.
- Close the pool only on process exit (Render sends a SIGTERM before shutting down).
- Add a health‑check endpoint that runs a cheap
SELECT 1to keep the pool warm.
1. src/db/pool.ts
import { Pool, PoolClient } from 'pg';
import { config } from 'dotenv';
config(); // loads .env
// The pool is created once per process
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// Neon recommends a max of 10 for free tier; leave headroom for admin queries
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
pool.on('error', (err) => {
console.error('Unexpected idle client error', err);
// In a cheap tier you don’t want the process to crash; just log.
});
export async function getClient(): Promise<PoolClient> {
return pool.connect();
}
// Graceful shutdown for Render
process.on('SIGTERM', async () => {
console.log('SIGTERM received – closing Postgres pool');
await pool.end();
process.exit(0);
});
2. src/routes/user.ts
import type { Request, Response } from 'express';
import { getClient } from '../db/pool';
export async function getProfile(req: Request, res: Response) {
const client = await getClient();
try {
const { rows } = await client.query(
`SELECT id, email, username, last_seen
FROM users
WHERE id = $1`,
[req.user.id] // populated by JWT middleware
);
if (!rows.length) return res.status(404).json({ error: 'User not found' });
res.json(rows[0]);
} finally {
client.release(); // returns the connection to the pool
}
}
3. Health‑check endpoint
import type { Request, Response } from 'express';
import { getClient } from '../db/pool';
export async function health(req: Request, res: Response) {
const client = await getClient();
try {
await client.query('SELECT 1');
res.status(200).json({ status: 'ok' });
} catch (e) {
console.error('Health check failed', e);
res.status(500).json({ status: 'error' });
} finally {
client.release();
}
}
Add it to your router:
app.get('/api/health', health);
What to watch
| Symptom | Likely cause | Remedy |
|---|---|---|
ERROR: remaining connection slots are reserved for non‑superuser users |
Pool max > Neon limit or many workers alive |
Reduce max to ≤ 10, ensure only one worker per PR, or use Render’s “single dyno” mode for CI builds |
| Connections leak (steady rise) | client.release() missing in a catch block |
Wrap DB calls in try/finally as shown; add a lint rule to enforce it |
| Cold start latency > 2 s | First request creates pool + TLS handshake | Add a GitHub Action step npm run warm-db that hits /api/health after deploy |
Bonus: Connection‑limit‑aware Prisma
If you prefer Prisma, set pool_timeout and connection_limit in the datasource URL:
DATABASE_URL="postgresql://user:pass@<host>.neon.tech/dbname?connection_limit=10&pool_timeout=5"
Run npx prisma generate and keep the same singleton pattern – Prisma will reuse the underlying pg pool.
5. Incremental JWT Rotation without a Dedicated Auth Server





JWTs are cheap to verify, but they become a liability when you need to revoke a compromised token. On a free tier you can’t afford a Redis cluster or a separate auth microservice, so I keep the revocation list in Postgres and rotate signing keys incrementally.
The problem in production
- Key leakage – If a secret is exposed, every existing token is instantly invalid.
- User logout – Stateless JWTs can’t be “killed” unless you track them.
- Key rotation – Changing the secret forces all clients to re‑login, which hurts UX.
Design goals
- Zero‑downtime key roll – Old tokens stay valid until they expire.
- Fast revocation check – A single
SELECTagainst a tiny table, no external cache. - No extra services – All logic lives in the same Express/Next API layer.
Schema
-- stores active signing keys; only the newest is used for signing
CREATE TABLE jwt_keys (
kid TEXT PRIMARY KEY, -- key identifier, e.g. "2024-09-01"
secret TEXT NOT NULL, -- base64‑encoded HMAC secret
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
expires_at TIMESTAMP WITH TIME ZONE -- optional, for key retirement
);
-- revocation list; one row per token jti (JWT ID)
CREATE TABLE jwt_revoked (
jti TEXT PRIMARY KEY,
revoked_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
Insert the first key manually (or via a migration script):
INSERT INTO jwt_keys (kid, secret)
VALUES ('2024-09-01', encode(gen_random_bytes(32), 'base64'));
TypeScript helpers
1. Load the current key
import { Pool } from 'pg';
import jwt, { JwtPayload, SignOptions } from 'jsonwebtoken';
import { config } from 'dotenv';
config();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function getCurrentKey(): Promise<{ kid: string; secret: string }> {
const { rows } = await pool.query(
`SELECT kid, secret
FROM jwt_keys
WHERE (expires_at IS NULL OR expires_at > now())
ORDER BY created_at DESC
LIMIT 1`
);
if (!rows.length) throw new Error('No active JWT signing key');
return rows[0];
}
2. Sign a token
export async function signToken(payload: JwtPayload, expiresIn = '1h'): Promise<string> {
const { kid, secret } = await getCurrentKey();
const opts: SignOptions = {
algorithm: 'HS256',
expiresIn,
header: { kid }, // embed key ID so verifier knows which secret to use
};
return jwt.sign(payload, Buffer.from(secret, 'base64'), opts);
}
3. Verify with revocation check
export async function verifyToken(token: string): Promise<JwtPayload> {
// Decode header to get kid without verifying
const decodedHeader = jwt.decode(token, { complete: true })?.header as { kid?: string };
if (!decodedHeader?.kid) throw new Error('Missing kid');
// Pull the matching secret
const { rows } = await pool.query(
`SELECT secret FROM jwt_keys WHERE kid = $1`,
[decodedHeader.kid]
);
if (!rows.length) throw new Error('Unknown signing key');
const secret = Buffer.from(rows[0].secret, 'base64');
// Verify signature and expiration
const payload = jwt.verify(token, secret) as JwtPayload;
// Revocation check
const { rows: revRows } = await pool.query(
`SELECT 1 FROM jwt_revoked WHERE jti = $1`,
[payload.jti]
);
if (revRows.length) throw new Error('Token revoked');
return payload;
}
4. Revoke a token (e.g., on logout)
export async function revokeToken(jti: string): Promise<void> {
await pool.query(
`INSERT INTO jwt_revoked (jti) VALUES ($1) ON CONFLICT DO NOTHING`,
[jti]
);
}
Rolling the key
Create a small script that runs as a GitHub Action nightly:
// scripts/rotate-jwt-key.ts
import { pool } from '../src/db/pool';
import { randomBytes } from 'crypto';
async function rotate() {
const newKid = new Date().toISOString().split('T')[0]; // e.g. "2024-09-02"
const secret = randomBytes(32).toString('base64');
// Mark current key as expiring in 24h (grace period)
await pool.query(
`UPDATE jwt_keys SET expires_at = now() + interval '24 hours' WHERE expires_at IS NULL`
);
// Insert the fresh key
await pool.query(
`INSERT INTO jwt_keys (kid, secret) VALUES ($1, $2)`,
[newKid, secret]
);
console.log(`Rotated JWT key to ${newKid}`);
await pool.end();
}
rotate().catch((e) => {
console.error(e);
process.exit(1);
});
Add to .github/workflows/rotate.yml:
name: Rotate JWT Key
on:
schedule:
- cron: '0 2 * * *' # 02:00 UTC daily
jobs:
rotate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: npx ts-node scripts/rotate-jwt-key.ts
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
Failure modes to monitor
| Symptom | Cause | Mitigation |
|---|---|---|
Token revoked for a brand‑new login |
jti not set when signing |
Ensure payload.jti = crypto.randomUUID() before calling signToken. |
| 429 from Neon during rotation | Rotation script runs too often, opening many connections | Keep the script to once‑daily; reuse the same pool instance (import { pool }). |
| Users forced to re‑login after a key change | expires_at set too early |
Use a 24‑hour grace window; verify that all clients respect the token’s exp. |
| Revoked table grows indefinitely | No cleanup job | Add a nightly DELETE FROM jwt_revoked WHERE revoked_at < now() - interval '30 days'. |
What I do on a Monday with this setup
- Check the rotation log – GitHub Actions UI shows the last run; if it failed, re‑run manually.
- Run
npm run lint:jwt– a custom ESLint rule that enforcespayload.jtipresence. - Inspect
SELECT COUNT(*) FROM jwt_revoked– if the count spikes, investigate recent logout storms (maybe a bot). - Warm the pool – hit
/api/healthto keep the Neon connection alive, preventing a cold‑start penalty for the first auth request of the day. - Deploy a one‑line schema migration (
ALTER TABLE jwt_keys ADD COLUMN description TEXT;) via Prisma, confirming the pool stays under the 20‑connection ceiling.
With a singleton Postgres pool and an in‑DB JWT revocation strategy, the stack stays within Neon’s free limits, avoids a separate Redis layer, and still gives you the ability to rotate keys without breaking active sessions. The pattern is simple enough to drop into any small TypeScript service, yet it scales to dozens of micro‑services as long as they share the same database. No extra cost, no hidden services, just a few SQL rows and a disciplined codebase.
Image credits
- Cover: AI-generated illustration
- inline: ds_30 / Pixabay
- inline: rawpixel / Pixabay
- inline: Tumisu / Pixabay
- inline: cookieone / Pixabay
- inline: Godfrey_atima / Pixabay
- inline: the_iop / Pixabay
- inline: Techaltruistic / Pixabay
- inline: Hans / Pixabay
- inline: ElasticComputeFarm / Pixabay
- inline: sa_ba_sabrina / Pixabay