TypeScript full-stack scaling illustration
2026-09-01 TypeScript full-stack scaling 10 min journal / ts-fullstack-scaling-budget

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:

  1. LLM‑generated CI/CD – a cheap GitHub Action that writes migrations, lints, and runs integration tests automatically.
  2. 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.
  3. Dynamic feature toggles – a tiny features table 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?

TypeScript full-stack scaling
Photo by ds_30 on Pixabay

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:

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

yaml
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?

TypeScript full-stack scaling laptop
Photo by rawpixel on Pixabay

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.

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

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

ts
// 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/health every 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 max to 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?

postgres database
Photo by Tumisu on Pixabay

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.

sql
-- 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);
ts
// 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:

ts
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 UPDATE and the change propagates instantly.
  • Cost control – Turn off heavyAnalytics during peak hours to stay under the free tier’s request quota.
  • A/B testing – Add a percentage column 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

server rack
Photo by cookieone on Pixabay
  1. Run the health‑check cron – the GitHub Action that pings /api/health to keep the Render worker warm.
  2. Flip the heavyAnalytics flag off for the weekend using psql -c "UPDATE features SET enabled = FALSE WHERE name='heavyAnalytics'".
  3. Merge the LLM‑generated CI change that adds a new pnpm audit step; watch the Action logs for any new vulnerabilities.
  4. 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.
  5. 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)

terminal code
Photo by Godfrey_atima on Pixabay

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.Pool inside 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

  1. Create a singleton pool module that lives outside the request handler.
  2. Export a helper that returns a client from that pool.
  3. Close the pool only on process exit (Render sends a SIGTERM before shutting down).
  4. Add a health‑check endpoint that runs a cheap SELECT 1 to keep the pool warm.

1. src/db/pool.ts

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

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

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

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

env
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

jwt lock
Photo by the_iop on Pixabay
circuit board closeup
Photo by Techaltruistic on Pixabay
ethernet cables
Photo by Hans on Pixabay
datacenter aisle
Photo by ElasticComputeFarm on Pixabay
mechanical keyboard
Photo by sa_ba_sabrina on Pixabay

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

  1. Zero‑downtime key roll – Old tokens stay valid until they expire.
  2. Fast revocation check – A single SELECT against a tiny table, no external cache.
  3. No extra services – All logic lives in the same Express/Next API layer.

Schema

sql
-- 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):

sql
INSERT INTO jwt_keys (kid, secret)
VALUES ('2024-09-01', encode(gen_random_bytes(32), 'base64'));

TypeScript helpers

1. Load the current key

ts
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

ts
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

ts
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)

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

ts
// 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:

yaml
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

  1. Check the rotation log – GitHub Actions UI shows the last run; if it failed, re‑run manually.
  2. Run npm run lint:jwt – a custom ESLint rule that enforces payload.jti presence.
  3. Inspect SELECT COUNT(*) FROM jwt_revoked – if the count spikes, investigate recent logout storms (maybe a bot).
  4. Warm the pool – hit /api/health to keep the Neon connection alive, preventing a cold‑start penalty for the first auth request of the day.
  5. 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

Questions

How can an LLM generate CI/CD pipelines for a TypeScript app?

By prompting an LLM with your repo structure, it can output GitHub Actions YAML that runs migrations, linting, tests, and deployment steps, then you commit the file to automate builds.

What cheap cloud hosting tricks keep costs low while scaling?

Serve static assets on Cloudflare Pages, store media in Backblaze B2, use Neon’s serverless connection‑pool shim, and keep the app container lightweight on Render’s free tier.

How do dynamic feature toggles help manage growth?

Feature toggles let you enable/disable heavy routes or new features per user or traffic level, preventing overload and allowing gradual rollouts without extra infrastructure.

Notes 0