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?

| 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
- Cold starts – Render’s free container sleeps after 15 min of inactivity; the first request adds ~800 ms latency.
- Connection limits – Neon caps concurrent connections; use a single
pgpool withmax: 1and release quickly. - Rate limits – Groq returns 429 if you exceed 10 req/s; back‑off with exponential delay.
- Disk persistence – Render’s filesystem is ephemeral; all state must live in Postgres or B2.
How does JWT secure the API?

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

I use the LLM to draft a blog post from a short outline. The flow is:
- Front‑end sends an outline (
title,bulletPoints) to/api/draft. - API calls Groq; on 429 it retries with OpenRouter.
- The generated markdown is stored in Neon, and the response includes a preview URL.
1. TypeScript client for Groq
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
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
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)
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

- Pull latest from GitHub, run
npm ci && npm run lint. - Run migrations:
npm run migrate(usespg-migrateagainst Neon). - Smoke test the API locally (
npm run dev) – hit/api/draftwith a tiny payload to confirm LLM fallback works. - Deploy: push to
main; GitHub Actions builds the Astro site, pushes the Docker image to Render, and triggers a cache purge on Cloudflare. - Monitor: check Render logs for “container started” messages, verify Neon connection count stays ≤ 2, and glance at Groq usage in the dashboard.
- Fix any 429 spikes by adjusting the
p-limitconcurrency 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

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.
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
tsconfigguarantees the same module resolution for API and Astro. src/libholds pure TypeScript utilities that can be imported from either side, avoiding duplicate code for things like JWT verification.migrationsare plain SQL files; I run them withpg-migratebecause 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

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.
// 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.
JWT cookie middleware – keeping the token out of the URL

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.
// 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 insrc/api/index.tswhere the Express app is created.
Express API – routes that respect free‑tier limits

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.
// 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;
// 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

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

My GitHub Actions workflow does three things:
- Lint & type‑check –
npm run lint && npm run buildensures no TypeScript errors slip in. - 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. - Deploy – Build a multi‑stage Docker image, push to Render, then hit Render’s deploy webhook.
# .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:
# .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 90 days, 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.
// 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 pooling –
max: 1respects Neon’s single‑connection limit. - Typed rows – If I rename
titletoheadingin 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:
// 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.
// 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
setTimeoutkeeps 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.
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:
- Store two secrets in Render (
JWT_SECRET_CURRENTandJWT_SECRET_PREV). - When verifying a token, try the current secret first, then fall back to the previous one.
- When you want to rotate, generate a new secret, move
CURRENT→PREV, set the new value asCURRENT, and redeploy. Existing tokens stay valid for the grace period (the token’sexp).
// 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
PREVis missing (first deployment) the fallback path is a no‑op, which is fine. If you forget to setJWT_SECRET_PREVduring 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.
// 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
- Pull the latest
maininto my local dev container –git pull && docker compose up --build -d. - Run the DB schema test –
npm run test:db. If it fails, I abort the deploy. - Spin a temporary Neon branch –
neonctl branch create --from main --name ci-${GITHUB_SHA}. The CI yaml already points toNEON_BRANCH_URL, so the next step is safe. - Trigger a dry‑run migration –
npm run migrate:dry. This prints the SQL; I glance for accidentalDROP TABLE. - Deploy to Render staging –
render-cli deploy --service my-app-staging. - Smoke‑test the API –
curl -s -o /dev/null -w "%{http_code}" $STAGING_URL/health. Must be200. - Run the LLM batcher health check – a tiny script that enqueues two dummy prompts and asserts they resolve within 5 seconds.
- Verify the JWT cookie rotation – fetch a token, delete
JWT_SECRET_PREVlocally, and confirm the token still validates (the fallback path). - **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