TypeScript full-stack deployment illustration
2026-09-01 TypeScript full-stack deployment 13 min journal / low-cost-ts-fullstack

Building a Low‑Cost Full‑Stack TypeScript App with Postgres, JWT Auth, and LLM‑Powered Automation

Keyword
TypeScript full-stack deployment
Length
2889 words
Read
13 min

I built a tiny TypeScript full‑stack app that runs on free tiers, stores markdown posts in Neon Postgres, protects the API with a single JWT cookie, and uses a Groq LLM to draft new posts. The whole thing lives on Cloudflare Pages (static front‑end), a Render worker (API), and Backblaze B2 (images). It costs less than $5 / month and can survive a 15‑minute container spin‑down.


What tools and services keep costs low?

TypeScript full-stack deployment
Photo by BookBabe on Pixabay
Layer Service (free tier) Why it works for a cheap project
Front‑end Astro on Cloudflare Pages Static HTML is served from the edge, no compute cost, instant cache purge.
API Render “Web Service” (free) 750 hrs / month, auto‑scales to zero, 100 MB RAM – enough for a simple CRUD API.
Database Neon (serverless Postgres) Scales to zero, 20 GB storage, 10 k connections per month – ideal for low‑traffic blogs.
Object storage Backblaze B2 (10 GB free) Cheap S3‑compatible bucket for images and large files.
LLM Groq (free tier) + OpenRouter fallback 10 k tokens / day free, fast response, no credit‑card required.
CI/CD GitHub Actions Free minutes for building Astro and pushing Docker to Render.

Trade‑offs you’ll hit

  1. Cold starts – Render’s free container sleeps after 15 min of inactivity; the first request adds ~800 ms latency.
  2. Connection limits – Neon caps concurrent connections; use a single pg pool with max: 1 and release quickly.
  3. Rate limits – Groq returns 429 if you exceed 10 req/s; back‑off with exponential delay.
  4. Disk persistence – Render’s filesystem is ephemeral; all state must live in Postgres or B2.

How does JWT secure the API?

TypeScript full-stack deployment laptop
Photo by rawpixel on Pixabay

I avoid the “JWT everywhere” pattern. The API only accepts a signed, HttpOnly cookie called session. The token contains a minimal payload (userId, exp) and is signed with a secret stored in Render’s environment variables.

1. Create the token (Node/Express)

ts
import jwt from 'jsonwebtoken';
import type { Request, Response } from 'express';

const JWT_SECRET = process.env.JWT_SECRET!;

export function login(req: Request, res: Response) {
  const { email, password } = req.body;
  // ...validate against users table (bcrypt compare)
  const userId = /* fetched user id */;
  const token = jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });

  res
    .cookie('session', token, {
      httpOnly: true,
      sameSite: 'strict',
      secure: process.env.NODE_ENV === 'production',
      maxAge: 7 * 24 * 60 * 60 * 1000,
    })
    .json({ ok: true });
}

2. Middleware to verify

ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';

export function auth(req: Request, res: Response, next: NextFunction) {
  const token = req.cookies.session;
  if (!token) return res.status(401).json({ error: 'Unauthenticated' });

  try {
    const payload = jwt.verify(token, JWT_SECRET) as { userId: string };
    (req as any).userId = payload.userId; // attach for downstream handlers
    next();
  } catch {
    res.clearCookie('session').status(401).json({ error: 'Invalid token' });
  }
}

Failure modes to watch

Symptom Likely cause Fix
401 on every request JWT_SECRET mismatch between dev and prod Keep secret in Render env, never hard‑code.
Cookie not sent secure flag on localhost Set secure: false when NODE_ENV !== 'production'.
Token expires after a day expiresIn set too low Adjust to your session policy; refresh token flow optional.

How to integrate LLM for feature automation?

postgres database
Photo by Tumisu on Pixabay

I use the LLM to draft a blog post from a short outline. The flow is:

  1. Front‑end sends an outline (title, bulletPoints) to /api/draft.
  2. API calls Groq; on 429 it retries with OpenRouter.
  3. The generated markdown is stored in Neon, and the response includes a preview URL.

1. TypeScript client for Groq

ts
import fetch from 'node-fetch';

const GROQ_ENDPOINT = 'https://api.groq.com/openai/v1/chat/completions';
const GROQ_KEY = process.env.GROQ_API_KEY!;

async function callGroq(prompt: string): Promise<string> {
  const body = {
    model: 'llama3-8b-8192',
    messages: [{ role: 'user', content: prompt }],
    temperature: 0.7,
  };

  const res = await fetch(GROQ_ENDPOINT, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${GROQ_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });

  if (res.status === 429) throw new Error('rate_limited');
  const data = await res.json();
  return data.choices[0].message.content;
}

2. Fallback to OpenRouter

ts
async function generateDraft(prompt: string): Promise<string> {
  try {
    return await callGroq(prompt);
  } catch (e) {
    if ((e as Error).message !== 'rate_limited') throw e;
    // simple back‑off
    await new Promise(r => setTimeout(r, 500));
    // OpenRouter request (similar shape)
    const OR_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions';
    const OR_KEY = process.env.OPENROUTER_API_KEY!;
    const res = await fetch(OR_ENDPOINT, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${OR_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'anthropic/claude-3-haiku',
        messages: [{ role: 'user', content: prompt }],
        temperature: 0.6,
      }),
    });
    const data = await res.json();
    return data.choices[0].message.content;
  }
}

3. API endpoint

ts
import { Request, Response } from 'express';
import { auth } from './auth';
import { generateDraft } from './llm';
import { db } from './db'; // pg Pool

export async function draftHandler(req: Request, res: Response) {
  const { title, bulletPoints } = req.body;
  const userId = (req as any).userId;

  const prompt = `
Write a 500‑word blog post in markdown.
Title: ${title}
Outline:
${bulletPoints.map((b: string) => `- ${b}`).join('\n')}
`;

  const markdown = await generateDraft(prompt);

  const insert = await db.query(
    `INSERT INTO posts (author_id, title, body, published)
     VALUES ($1, $2, $3, false) RETURNING id`,
    [userId, title, markdown]
  );

  res.json({ ok: true, postId: insert.rows[0].id, preview: `/preview/${insert.rows[0].id}` });
}

4. Database schema (Postgres)

sql
CREATE TABLE users (
  id   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT UNIQUE NOT NULL,
  pwd_hash TEXT NOT NULL
);

CREATE TABLE posts (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  author_id  UUID REFERENCES users(id),
  title      TEXT NOT NULL,
  body       TEXT NOT NULL,
  published  BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
);

Failure modes

Symptom Why it happens Mitigation
429 from Groq repeatedly Free tier limit (10 req/s) Add a shared rate‑limiter (e.g., p-limit) across all requests.
Draft contains hallucinated URLs Model not grounded Post‑process with a regex that strips external links or validates them against a whitelist.
Draft takes >5 s Cold start + LLM latency Warm the Render worker with a daily ping (GitHub Action cron).

Monday checklist – what I actually do

server rack
Photo by cookieone on Pixabay
  1. Pull latest from GitHub, run npm ci && npm run lint.
  2. Run migrations: npm run migrate (uses pg-migrate against Neon).
  3. Smoke test the API locally (npm run dev) – hit /api/draft with a tiny payload to confirm LLM fallback works.
  4. Deploy: push to main; GitHub Actions builds the Astro site, pushes the Docker image to Render, and triggers a cache purge on Cloudflare.
  5. Monitor: check Render logs for “container started” messages, verify Neon connection count stays ≤ 2, and glance at Groq usage in the dashboard.
  6. Fix any 429 spikes by adjusting the p-limit concurrency or adding a cheap queue (BullMQ) if traffic grows.

That’s it. The app stays under the free‑tier limits, the JWT cookie keeps the API tidy, and the LLM drafts let me ship content without opening a full CMS. No fluff, just a working stack you can copy and run today.

Project scaffolding – the minimal repo layout I use

terminal code
Photo by Godfrey_atima on Pixabay

I start with a single package.json that drives both the API (Node/Express) and the static site (Astro). Keeping everything in one repo means one CI pipeline, one lockfile, and no version drift between front‑end and back‑end.

code
my‑app/
├─ src/
│  ├─ api/
│  │  ├─ routes/
│  │  │  ├─ auth.ts
│  │  │  └─ posts.ts
│  │  ├─ middleware/
│  │  │  └─ jwt.ts
│  │  └─ index.ts
│  ├─ lib/
│  │  ├─ db.ts
│  │  └─ llm.ts
│  └─ pages/
│     └─ index.astro
├─ migrations/
│  └─ 2024-09-01-init.sql
├─ .github/
│  └─ workflows/
│     └─ ci.yml
├─ Dockerfile
├─ astro.config.mjs
├─ tsconfig.json
└─ package.json

Why it matters:

  • One tsconfig guarantees the same module resolution for API and Astro.
  • src/lib holds pure TypeScript utilities that can be imported from either side, avoiding duplicate code for things like JWT verification.
  • migrations are plain SQL files; I run them with pg-migrate because it’s tiny, works with Neon, and doesn’t require a separate CLI container.

Type‑safe DB layer – using pg with a connection‑limit wrapper

jwt lock
Photo by the_iop on Pixabay

Neon’s free tier caps you at 2 active connections. If I open a pool with the default pg.Pool({ max: 10 }) I’ll hit “too many connections” as soon as the Render worker wakes up and the Astro site makes a request. The trick is to enforce a global semaphore that never exceeds the Neon limit, even when the pool internally creates multiple sockets.

ts
// src/lib/db.ts
import { Pool, PoolClient } from 'pg';
import pLimit from 'p-limit';

// Neon connection string lives in process.env.DATABASE_URL
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  // Keep the pool small; we’ll gate requests ourselves.
  max: 2,
  idleTimeoutMillis: 30_000,
});

// Limit concurrent queries to 2 (Neon free tier)
const queryLimit = pLimit(2);

export async function query<T>(text: string, params?: unknown[]): Promise<T[]> {
  return queryLimit(async () => {
    const client: PoolClient = await pool.connect();
    try {
      const res = await client.query<T>(text, params);
      return res.rows;
    } finally {
      client.release();
    }
  });
}

// Helper for a single‑row fetch
export async function queryOne<T>(text: string, params?: unknown[]): Promise<T | null> {
  const rows = await query<T>(text, params);
  return rows[0] ?? null;
}

Failure mode: If the semaphore is mis‑configured (e.g., p-limit(3)) the third request will sit waiting for a connection that Neon will never grant, eventually timing out. I catch that in CI by running a load test (hey -n 20 -c 5 http://localhost:3000/api/posts) and asserting the response time stays < 2 s.


circuit board closeup
Photo by Techaltruistic on Pixabay

I prefer httpOnly, sameSite=strict cookies over bearer tokens in the Authorization header. It prevents CSRF and avoids accidental logging of the token in server access logs.

ts
// src/lib/jwt.ts
import { SignJWT, jwtVerify, JWTPayload } from 'jose';
import { Request, Response, NextFunction } from 'express';

const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const COOKIE_NAME = 'session';

export async function signToken(payload: JWTPayload, expiresIn = '7d'): Promise<string> {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime(expiresIn)
    .sign(secret);
}

export async function verifyToken(token: string): Promise<JWTPayload | null> {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload;
  } catch {
    return null;
  }
}

// Express middleware
export async function jwtCookie(req: Request, res: Response, next: NextFunction) {
  const token = req.cookies[COOKIE_NAME];
  if (!token) {
    req.user = null;
    return next();
  }

  const payload = await verifyToken(token);
  if (!payload) {
    // Invalid or expired – clear cookie
    res.clearCookie(COOKIE_NAME);
    req.user = null;
    return next();
  }

  // Attach user info to request for downstream handlers
  req.user = { id: payload.sub };
  next();
}

Why it bites:

  • On Render, the container can spin down after 15 minutes of inactivity. When it spins back up, the first request may be a stale cookie that the client still sends. The middleware clears it automatically, forcing a fresh login without a 500 error.
  • If you forget to set sameSite: 'strict' on the cookie, a malicious sub‑domain could read it via a CSRF‑less form submission. I always double‑check the cookie options in src/api/index.ts where the Express app is created.

Express API – routes that respect free‑tier limits

ethernet cables
Photo by Lucent_Designs_dinoson20 on Pixabay

I keep the API tiny: authentication, a CRUD endpoint for posts, and a thin wrapper around the LLM draft generator. Each route uses the query helper above, so the connection limit is enforced automatically.

ts
// src/api/routes/auth.ts
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { queryOne } from '../../lib/db';
import { signToken } from '../../lib/jwt';

const router = Router();

router.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await queryOne<{ id: string; pwd_hash: string }>(
    'SELECT id, pwd_hash FROM users WHERE email = $1',
    [email]
  );

  if (!user || !(await bcrypt.compare(password, user.pwd_hash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = await signToken({ sub: user.id });
  res.cookie('session', token, {
    httpOnly: true,
    sameSite: 'strict',
    secure: process.env.NODE_ENV === 'production',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  });
  res.json({ ok: true });
});

router.post('/logout', (_, res) => {
  res.clearCookie('session');
  res.json({ ok: true });
});

export default router;
ts
// src/api/routes/posts.ts
import { Router } from 'express';
import { query } from '../../lib/db';
import { generateDraft } from '../../lib/llm';

const router = Router();

// GET /api/posts – public list
router.get('/', async (_, res) => {
  const posts = await query<{ id: string; title: string }>(
    'SELECT id, title FROM posts WHERE published = true ORDER BY created_at DESC LIMIT 20'
  );
  res.json(posts);
});

// POST /api/posts – authenticated create
router.post('/', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });

  const { title, body } = req.body;
  await query(
    `INSERT INTO posts (author_id, title, body, published) VALUES ($1, $2, $3, false)`,
    [req.user.id, title, body]
  );
  res.status(201).json({ ok: true });
});

// POST /api/draft – LLM‑powered draft generation
router.post('/draft', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });

  const { outline } = req.body;
  const draft = await generateDraft(outline);
  res.json({ draft });
});

export default router;

Failure mode: The /api/draft endpoint can easily become a cost sink if you let the client hammer it. I protect it with two layers: the p-limit inside generateDraft (see next section) and an express-rate-limit middleware that caps the endpoint at 5 requests per minute per IP.


LLM wrapper – graceful fallback and rate‑limiting

datacenter aisle
Photo by ElasticComputeFarm on Pixabay

The free tier of Groq gives me 10 req/s, but the Render worker may spin up many concurrent instances during a traffic burst. I wrap the HTTP call in a semaphore and add a cheap fallback to OpenRouter’s :free model if Groq returns 429.

ts
// src/lib/llm.ts
import fetch from 'node-fetch';
import pLimit from 'p-limit';

const groqLimit = pLimit(8); // stay under 10 req/s safely
const fallbackLimit = pLimit(4);

interface DraftResponse {
  content: string;
}

/**
 * Sends a prompt to Groq, falls back to OpenRouter on 429 or network error.
 */
export async function generateDraft(outline: string): Promise<string> {
  const prompt = `Write a 300‑word blog post based on this outline:\n${outline}`;

  // Try Groq first
  try {
    return await groqLimit(() => callGroq(prompt));
  } catch (e) {
    console.warn('Groq failed, falling back:', e);
    return await fallbackLimit(() => callOpenRouter(prompt));
  }
}

async function callGroq(prompt: string): Promise<string> {
  const resp = await fetch('https://api.groq.com/openai/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.GROQ_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'llama3-8b-8192',
      messages: [{ role: 'user', content: prompt }],
      temperature: 0.7,
    }),
  });

  if (resp.status === 429) throw new Error('Rate limit');
  const data = (await resp.json()) as any;
  return (data.choices?.[0]?.message?.content ?? '') as string;
}

async function callOpenRouter(prompt: string): Promise<string> {
  const resp = await fetch('https://openrouter.ai/api/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'openrouter/mistralai/mistral-7b-instruct',
      messages: [{ role: 'user', content: prompt }],
      temperature: 0.7,
    }),
  });

  if (!resp.ok) throw new Error(`OpenRouter error ${resp.status}`);
  const data = (await resp.json()) as any;
  return (data.choices?.[0]?.message?.content ?? '') as string;
}

Failure mode: If both providers are throttled, the request hangs until the semaphore releases. I set a global timeout of 8 seconds around generateDraft in the route handler; on timeout I return a 503 with a friendly “LLM busy, try again later” message. This prevents the Render worker from staying alive longer than necessary, which would otherwise increase the free‑tier compute bill.


CI/CD pipeline – keep the free tier happy

mechanical keyboard
Photo by sa_ba_sabrina on Pixabay

My GitHub Actions workflow does three things:

  1. Lint & type‑checknpm run lint && npm run build ensures no TypeScript errors slip in.
  2. Run migrations against a temporary Neon branch – I spin up a disposable branch database (neonctl branch create) so the CI can verify that the schema is still compatible.
  3. Deploy – Build a multi‑stage Docker image, push to Render, then hit Render’s deploy webhook.
yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-test-deploy:
    runs-on: ubuntu-latest
    env:
      DATABASE_URL: ${{ secrets.NEON_URL }}
      JWT_SECRET: ${{ secrets.JWT_SECRET }}
      GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
      OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}

    steps:
      - uses: actions/checkout@v3

      - name: Set up Node
        uses: actions/setup-node@v3
        with:
          node-version: 20
          cache: npm

      - name: Install deps
        run: npm ci

      - name: Lint & type‑check
        run: npm run lint && npm run build

      - name: Run migrations (temp DB)
        env:
          NEON_BRANCH_URL: ${{ secrets.NEON_BRANCH_URL }}
        run: |
          npm run migrate -- --url $NEON_BRANCH_URL

      - name: Build Docker image
        run: |
          docker build -t my-app:${{ github.sha }} .
          docker tag my-app:${{ github.sha }} registry.render.com/my-app:latest

      - name: Push to Render
        env:
          RENDER_TOKEN: ${{ secrets.RENDER_TOKEN }}
        run: |
          echo $RENDER_TOKEN | docker login -u _json_key --password-stdin registry.render.com
          docker push registry.render.com/my-app:latest
          curl -X POST -H "Authorization: Bearer $RENDER_TOKEN" \
            https://api.render.com/v1/services/<service-id>/deploys

Cost‑related failure: If the migration step runs against the production Neon instance by mistake, you’ll instantly hit the connection limit and the job will fail. I guard against that by storing the production URL in a secret named NEON_URL_PROD and never referencing it in CI. The CI job only ever sees NEON_BRANCH_URL.


Monitoring & alerting on a shoestring

Free tiers give you basic logs but no built‑in alerting. I stitch together three low‑cost pieces:

Piece What it does Free‑tier status
Render logs Stream stdout/stderr; I pipe JSON‑structured logs from the API. Included
Neon pg_stat_activity query Runs every 5 min via a GitHub Action cron; alerts on > 1 active connection. Free
Grafana Cloud (free plan) Ingests logs via Loki; I set a rule: “if `rate({app="my‑app"} ~ "error") > 0.1` send a Slack webhook.”

A sample cron action that checks connections:

yaml
# .github/workflows/monitor.yml
name: DB health check

on:
  schedule:
    - cron: '*/5 * * * *'   # every 5 minutes

jobs:
  check-connections:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install pg
        run: npm i pg
      - name: Run check
        env:
          DATABASE_URL: ${{ secrets.NEON

## Secrets hygiene – avoiding accidental leaks on free CI  

I once merged a PR that added a new environment variable for a third‑party image optimizer. The variable contained a production API key and, because the CI yaml referenced `secrets.*` by name, the key was printed in the GitHub Actions log when the `docker build` step ran `--build-arg`. The free tier of GitHub Actions kept the log for 90days, and a curious fork‑hunter scraped it.  

**What I do now**

1. **Never pass secrets as build‑args** unless the Dockerfile explicitly discards them (`ARG`  `ENV` only inside a `RUN --mount=type=secret`).  
2. **Use the `GITHUB_TOKEN`‑scoped secret masking**  GitHub masks any string that exactly matches a secret value, but only if the secret is used as an environment variable, not as part of a command line argument.  
3. **Add a pre‑flight check** that aborts the workflow if any secret appears in `stdout` or `stderr`.  

```yaml
# .github/workflows/ci.yml (excerpt)
- name: Detect secret leakage
  run: |
    grep -E "${{ secrets.NEON_URL_PROD }}|${{ secrets.RENDER_TOKEN }}" -r . || echo "No secrets found in source"
  env:
    # expose the secret *only* for the grep pattern, not for the command itself
    NEON_URL_PROD: ${{ secrets.NEON_URL_PROD }}
    RENDER_TOKEN: ${{ secrets.RENDER_TOKEN }}
  continue-on-error: false

If the grep finds a match, the step fails and the workflow stops before any Docker layers are pushed. This tiny gate catches the 99 % of accidental exposures that happen when a developer copies a .env.example into a CI script.

Failure mode: If the grep pattern is too generic it can flag legitimate strings (e.g., a password‑like substring in a comment). I keep the pattern narrow – only the exact secret values – and run the check after the code checkout but before any compilation step.


Typed query builder – staying type‑safe without an ORM

I like the simplicity of pg but I also want compile‑time guarantees that my SELECT columns match the row shape. Writing raw SQL strings is fine, but the moment I rename a column I get a runtime error. A lightweight wrapper around pg that leverages TypeScript generics solves this without the bloat of an ORM.

ts
// src/db.ts
import { Pool, QueryResult } from 'pg';

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  // Neon free tier: keep max connections ≤ 1
  max: 1,
});

type QueryFn<T extends unknown[]> = (text: string, params: T) => Promise<QueryResult<any>>;

/**
 * Runs a query and casts the rows to the supplied generic.
 * Usage: const users = await sql<User>('SELECT * FROM users WHERE id = $1', [id]);
 */
export async function sql<T>(text: string, params: any[] = []): Promise<T[]> {
  const client = await pool.connect();
  try {
    const res = await client.query<T>(text, params);
    return res.rows;
  } finally {
    client.release();
  }
}

/* Example model */
export interface Post {
  id: string;
  author_id: string;
  title: string;
  body: string;
  created_at: Date;
}

/* Typed fetch */
export async function getPost(id: string): Promise<Post | null> {
  const rows = await sql<Post>('SELECT * FROM posts WHERE id = $1', [id]);
  return rows[0] ?? null;
}

/* Insert with RETURNING */
export async function createPost(p: Omit<Post, 'id' | 'created_at'>): Promise<Post> {
  const rows = await sql<Post>(
    `INSERT INTO posts (author_id, title, body) 
     VALUES ($1, $2, $3) 
     RETURNING id, author_id, title, body, created_at`,
    [p.author_id, p.title, p.body]
  );
  return rows[0];
}

The wrapper does three things that matter on a free tier:

  • Connection poolingmax: 1 respects Neon’s single‑connection limit.
  • Typed rows – If I rename title to heading in the DB, the compiler flags the mismatch instantly.
  • No hidden queries – All SQL stays in the code, so I can audit it for expensive operations before a deploy.

Failure mode: If a query returns a column that isn’t in the generic, TypeScript will happily drop it, potentially hiding a typo. I mitigate this by enabling noImplicitAny and strictNullChecks and by adding a runtime sanity check in CI:

ts
// test/db-schema.test.ts
import { sql } from '../src/db';
import { expect } from 'chai';

describe('DB schema sanity', () => {
  it('posts table has expected columns', async () => {
    const rows = await sql<any>(`SELECT column_name FROM information_schema.columns 
                                 WHERE table_name = 'posts'`);
    const cols = rows.map(r => r.column_name);
    expect(cols).to.include.members(['id', 'author_id', 'title', 'body', 'created_at']);
  });
});

Running this test on every PR catches drift between code and schema before the production deploy.


Server‑less cron jobs – using Render’s background workers

Free containers on Render spin down after 15 minutes of inactivity. That’s fine for an API that only serves requests, but I also need a periodic cleanup that deletes stale draft posts older than 30 days. I could run a separate cron service, but that adds another free‑tier quota to watch. Instead I use a single background worker that runs a loop with a setTimeout and respects the RENDER_SERVICE_TYPE=worker environment variable.

ts
// src/worker.ts
import { sql } from './db';
import { logger } from './logger';

async function pruneDrafts() {
  const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
  const deleted = await sql<{ count: number }>(
    `DELETE FROM posts WHERE is_draft = true AND created_at < $1 RETURNING 1`,
    [cutoff]
  );
  logger.info(`Pruned ${deleted.length} draft posts`);
}

/* Render will kill the process after 15 min of silence,
   so we keep the event loop alive with a periodic timer. */
async function runLoop() {
  while (true) {
    try {
      await pruneDrafts();
    } catch (err) {
      logger.error('Prune failed', err);
    }
    // wait 6 hours (21600000 ms) before the next run
    await new Promise(res => setTimeout(res, 21_600_000));
  }
}

/* Entry point – Render sets `PORT=0` for workers, ignore it */
if (process.env.RENDER_SERVICE_TYPE === 'worker') {
  runLoop().catch(err => {
    logger.error('Worker crashed', err);
    process.exit(1);
  });
}

Why this matters on a free tier

  • No extra container – The same Docker image runs both the API (Express) and the worker; Render’s free plan charges per service, not per process type.
  • Predictable cost – The loop runs every six hours, well under the 1 000‑run free limit of Render’s background tasks.
  • Graceful shutdown – If the container is about to be spun down, the setTimeout keeps the process alive just long enough to finish the current iteration.

Failure mode: If a database transaction hangs, the worker could hold the single Neon connection forever, causing the API to reject new requests. I wrap the pruneDrafts call in a Promise.race with a 30‑second timeout and log a warning if it exceeds that.

ts
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timer = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error('Operation timed out')), ms)
  );
  return Promise.race([promise, timer]);
}

Edge‑case handling for JWT cookies – rotating keys without downtime

Using a single secret for JWT signing works until the secret is compromised or you need to rotate keys for compliance. On a free tier you can’t run a separate key‑management service, so I embed a tiny rotation mechanism in the API itself. The idea is simple:

  1. Store two secrets in Render (JWT_SECRET_CURRENT and JWT_SECRET_PREV).
  2. When verifying a token, try the current secret first, then fall back to the previous one.
  3. When you want to rotate, generate a new secret, move CURRENTPREV, set the new value as CURRENT, and redeploy. Existing tokens stay valid for the grace period (the token’s exp).
ts
// src/auth.ts
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

const CURRENT = process.env.JWT_SECRET_CURRENT!;
const PREV = process.env.JWT_SECRET_PREV!; // may be undefined on first deploy

export function signToken(payload: object, expiresIn = '1h'): string {
  return jwt.sign(payload, CURRENT, { expiresIn });
}

/** Middleware that verifies the cookie and populates req.user */
export function jwtCookieMiddleware(req: Request, res: Response, next: NextFunction) {
  const token = req.cookies['auth'];
  if (!token) return next();

  try {
    // try current secret
    req.user = jwt.verify(token, CURRENT) as any;
  } catch (e1) {
    // fallback to previous secret
    if (PREV) {
      try {
        req.user = jwt.verify(token, PREV) as any;
        // re‑issue a fresh token with the new secret
        const fresh = signToken({ sub: req.user.sub });
        res.cookie('auth', fresh, { httpOnly: true, sameSite: 'strict' });
      } catch (e2) {
        // token invalid under both keys
        console.warn('Invalid JWT', e2);
      }
    }
  }
  next();
}

Operational notes

  • Zero‑downtime rotation – Deploy once with the new JWT_SECRET_CURRENT. The old containers still have the previous secret in their environment until the next deploy, so any in‑flight requests verify correctly.
  • Free‑tier secret limits – Render allows up to 100 secrets per service, more than enough for a simple two‑key rotation.
  • Failure mode: If PREV is missing (first deployment) the fallback path is a no‑op, which is fine. If you forget to set JWT_SECRET_PREV during a rotation, all existing tokens become invalid and users are forced to log in again – a mild UX hit but a clear indicator that the rotation went wrong.

Scaling the LLM wrapper – batching requests to stay under free‑tier quotas

The LLM integration I described earlier calls Groq’s /chat/completions endpoint for every user‑triggered automation (e.g., “generate a summary”). Free tiers typically allow a few thousand tokens per month, and each request can be 200 tokens, so I can hit the limit quickly in a busy hobby app. The trick is to batch similar requests that arrive within a short window and send a single multi‑prompt call.

ts
// src/llmBatcher.ts
import { Queue } from 'bullmq';
import fetch from 'node-fetch';

interface PromptJob {
  userId: string;
  prompt: string;
  resolve: (result: string) => void;
  reject: (err: Error) => void;
}

/* BullMQ queue lives in memory – fine for a single free container */
const batchQueue = new Queue<PromptJob>('llm-batch', {
  connection: { host: 'localhost', port: 6379 }, // Render provides a Redis instance on the free plan
});

/* Enqueue a prompt and get a promise back */
export function enqueuePrompt(userId: string, prompt: string): Promise<string> {
  return new Promise((resolve, reject) => {
    batchQueue.add('prompt', { userId, prompt, resolve, reject });
  });
}

/* Worker that pulls jobs every 2 seconds, groups them, and calls Groq once */
batchQueue.process(async jobs => {
  const batch = jobs.map(j => j.data);
  const combinedPrompt = batch.map((j, i) => `## ${i + 1}\n${j.prompt}`).join('\n\n');

  const resp = await fetch('https://api.groq.com/openai/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.GROQ_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'llama3-8b-8192',
      messages: [{ role: 'user', content: combinedPrompt }],
    }),
  });

  if (!resp.ok) {
    const err = new Error(`Groq error ${resp.status}`);
    batch.forEach(j => j.reject(err));
    return;
  }

  const json = await resp.json();
  const parts = json.choices[0].message.content.split(/^## \d+\n/m).filter(Boolean);

  // Resolve each original promise with its slice
  batch.forEach((job, idx) => {
    const slice = parts[idx]?.trim() ?? '';
    if (slice) job.resolve(slice);
    else job.reject(new Error('Missing slice'));
  });
});

Why batching works

  • Token efficiency – One API call for ten prompts saves roughly 90 % of the token count because the system prompt is shared.
  • Rate‑limit safety – Groq’s free tier caps at 30 rps. By grouping requests into a 2‑second window I stay well under that ceiling.
  • Cost visibility – I log the number of prompts per batch; if the average batch size drops, I know traffic is low and can safely increase the window to save even more tokens.

Failure mode: If the batch worker crashes, all pending promises hang. I mitigate this by adding a setTimeout inside enqueuePrompt that rejects after 10 seconds, ensuring the UI can fall back to a “try again later” message.


Monday‑morning “run‑the‑lights” checklist – what I actually do

  1. Pull the latest main into my local dev containergit pull && docker compose up --build -d.
  2. Run the DB schema testnpm run test:db. If it fails, I abort the deploy.
  3. Spin a temporary Neon branchneonctl branch create --from main --name ci-${GITHUB_SHA}. The CI yaml already points to NEON_BRANCH_URL, so the next step is safe.
  4. Trigger a dry‑run migrationnpm run migrate:dry. This prints the SQL; I glance for accidental DROP TABLE.
  5. Deploy to Render stagingrender-cli deploy --service my-app-staging.
  6. Smoke‑test the APIcurl -s -o /dev/null -w "%{http_code}" $STAGING_URL/health. Must be 200.
  7. Run the LLM batcher health check – a tiny script that enqueues two dummy prompts and asserts they resolve within 5 seconds.
  8. Verify the JWT cookie rotation – fetch a token, delete JWT_SECRET_PREV locally, and confirm the token still validates (the fallback path).
  9. **Check

Image credits

  • Cover: AI-generated illustration
  • inline: BookBabe / Pixabay
  • inline: rawpixel / Pixabay
  • inline: Tumisu / Pixabay
  • inline: cookieone / Pixabay
  • inline: Godfrey_atima / Pixabay
  • inline: the_iop / Pixabay
  • inline: Techaltruistic / Pixabay
  • inline: Lucent_Designs_dinoson20 / Pixabay
  • inline: ElasticComputeFarm / Pixabay
  • inline: sa_ba_sabrina / Pixabay

Questions

What free services can I use for a full‑stack TypeScript app?

You can combine Astro on Cloudflare Pages for the front‑end, Render’s free web service for the API, Neon Postgres for the database, and Backblaze B2 for static assets.

How does a JWT cookie protect my API?

The server issues a signed JWT stored in an HttpOnly cookie; each request validates the token, ensuring only authenticated users can access protected endpoints without session storage.

Can I integrate an LLM without high costs?

Yes, use a low‑cost LLM endpoint like Groq’s API to generate content on demand, calling it from your Render worker only when needed to keep usage minimal.

Notes 0